OverviewFilesCommitsIssuesDocsReleasesPeers

kodi-jacktook commits / 59a3076

Rebase local patches onto upstream 1.15.0, release 1.15.1
Upstream 1.15.0 (five releases newer than the 1.10.0 base) with the proxyHeaders work reapplied. Adopted after an upstream update was accepted by mistake and overwrote the previous fork -- the tree it replaced is exactly what this repo exists to prevent losing, so the newer base is taken deliberately rather than reverting to 1.10. Drift was substantial in the touched files (search.py ~900 changed lines, base_window.py ~530, player/utils.py ~310), but every anchor survived and the structures are unchanged: StreamBehaviorHints still parses proxyHeaders, resolve_playback_url still has the Easynews/Jackgram branch the header append hangs off, and prepare_source_data still builds the playback dict. So the patches went on as-is rather than needing redesign. Reapplied: models.py extract_proxy_headers() + Stream.get_proxy_headers() torrent.py proxyHeaders field on TorrentStream addon_client.py populate it from the parsed stream base_window.py carry it into playback data (getattr: unpickled cached search.py results predate the field, pickle skips dataclass defaults) catalog_menus.py the three catalog playback paths player/utils.py append_proxy_headers(), applied in the DIRECT branch only, so Easynews/Jackgram/debrid/torrent paths are untouched updater.py repointed at stortron; downgrade menu reads index.json Whole tree compiles. Unit tests cover nested/flat/absent/garbage proxyHeaders, percent-encoding, no-double-append, and the pre-field-pickle path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
59a307618a421bd90a172f87941520f0af133e77
by plankatron <fleeky@gmail.com> · 2026-07-28T19:13:06.000Z

Changed files (293) +34115 -9245

plugin.video.jacktook/AGENTS.md +42 -9
……@@ -111,23 +111,46 @@ Notes:
111111
112112## 7) Lint / Static Checks
113113
114No dedicated linter config (`pyproject.toml`, `setup.cfg`, `tox.ini`, `pytest.ini`) is present.
114Configuration lives in `pyproject.toml` under `[tool.ruff]` and `[tool.mypy]`.
115115
116Recommended lightweight checks:
116### Ruff — linter + formatter
117
118Ruff replaces Black, isort, Flake8, and many other tools. Configuration in `pyproject.toml`.
119
120```bash
121# Lint project source (auto-fix where safe)
122.venv/bin/ruff check --fix
123
124# Format all source files (Black-compatible)
125.venv/bin/ruff format
126
127# Check formatting without modifying
128.venv/bin/ruff format --check
129```
130
131Notable rule groups enabled: `E`, `W`, `F`, `I`, `N`, `UP`, `B`, `SIM`, `D` (Google convention, relaxed), `YTT`, `RUF`.
132
133### mypy — static type checker
117134
118135```bash
119python3 -m py_compile jacktook.py service.py
120python3 scripts/check_py37_compat.py .
136# Type-check all project source (excludes tests, scripts, docs, lib/vendor)
137.venv/bin/mypy .
121138```
122139
123`scripts/check_py37_compat.py` scans for syntax/features that break Python 3.7 compatibility assumptions.
140- `ignore_missing_imports = true` — Kodi modules (`xbmc`, `xbmcgui`, etc.) are expected to be unavailable in the dev environment.
141- Vendor code (`lib/vendor/`, `lib/api/tmdbv3api/`, `lib/utils/parsers/xmltodict.py`) is excluded via `[[tool.mypy.overrides]]` with `follow_imports = "skip"` and `ignore_errors = true`.
142- `no_implicit_optional = true` — all `Optional` type hints must be explicit.
143
144### CI-friendly one-liner
145
146```bash
147.venv/bin/ruff check && .venv/bin/ruff format --check && .venv/bin/mypy .
148```
124149
125150## 8) Known Test Environment Caveats
126151
127- In this environment, `python3 -m pytest -q` currently has one failing test:
128 - `tests/unit/test_general_utils.py::test_supported_video_extensions`
129- Root cause is environment/mocking behavior of `xbmc.getSupportedMedia` (returns empty without specific mock setup).
130- If touching video extension logic, ensure deterministic mocking of `getSupportedMedia`.
152- All unit tests pass cleanly with `python3 -m pytest -q`.
153- If touching video extension logic, ensure deterministic mocking of `getSupportedMedia` in tests.
131154
132155## 9) Code Style Guidelines
133156
……@@ -193,3 +216,13 @@ Follow existing codebase conventions first; avoid style-only churn.
193216- When changing routing/action names, update all call sites and query param builders.
194217- Run relevant tests first (single-test command), then broader suite when feasible.
195218- If tests fail due to known environment stubs, document clearly in your handoff.
219
220## 11) Git Workflow Rules
221
222### NO Automatic Commits
223- **NEVER** run `git commit` automatically on behalf of the user.
224- **NEVER** use `git commit --amend` without explicit user request.
225- The user decides when and how to commit changes.
226- If the user says "commit", confirm the message and scope before executing.
227- Present changes with `git diff --stat` or `git status` and wait for explicit approval.
228- Exception: if the user explicitly asks "commit this" or "make a commit", then proceed.
plugin.video.jacktook/CHANGELOG.md +112 -0
……@@ -1,3 +1,115 @@
1## 1.15.0
2- Debrid: Add Offcloud provider with OAuth device-flow authentication.
3- Stremio: Add rename aliases support and reorganize related settings.
4- Sources: Make Manage Sources a strict whitelist so deselected providers are actually skipped.
5- Sources: Fix External Scraper and Easynews crashes caused by unexpected `variant`/`year` search arguments.
6- Playback: Prefer torrent URLs when resolving sources for Elementum.
7
8Spanish:
9- Debrid: Añadido proveedor Offcloud con autenticación OAuth por flujo de dispositivo.
10- Stremio: Añadido soporte de alias renombrados y reorganizados ajustes relacionados.
11- Fuentes: Manage Sources ahora es una lista blanca estricta; los proveedores deseleccionados se omiten realmente.
12- Fuentes: Corregidos crashes del External Scraper y Easynews causados por argumentos inesperados `variant`/`year` en búsquedas.
13- Reproducción: Preferencia por URLs de torrent al resolver fuentes para Elementum.
14
15## 1.14.1
16- Startup: Fix a Kodi startup crash caused by a Python runtime-incompatible `tuple[str, str]` type annotation.
17- Maintenance: Harden the Python 3.7 compatibility checker to catch similar runtime-incompatible typing patterns before release.
18
19Spanish:
20- Inicio: Corregido un crash al iniciar Kodi causado por una anotación de tipo `tuple[str, str]` incompatible con el runtime de Python.
21- Mantenimiento: Reforzado el verificador de compatibilidad con Python 3.7 para detectar patrones de typing incompatibles antes de publicar.
22
23## 1.14.0
24- PlayNext: Add and stabilize the built-in Next Episode dialog with localized labels and safer playback transitions.
25- Search: Stabilize the detailed search status window on Windows/Kodi and reduce unsafe GUI threading during search startup.
26- Search: Fix episode-title searches containing ampersands and improve Stremio source-size parsing for European number formats.
27- TorrServer/TorBox: Handle empty/list torrent responses and queued TorBox torrents more safely during playback resolution.
28- Maintenance: Add developer tooling configuration and harden lint/type-cleanup issues for safer future changes.
29
30Spanish:
31- PlayNext: Añadido y estabilizado el diálogo integrado de Siguiente Episodio con etiquetas localizadas y transiciones de reproducción más seguras.
32- Búsqueda: Estabilizada la ventana detallada de estado de búsqueda en Windows/Kodi y reducidos riesgos de threading GUI al iniciar búsquedas.
33- Búsqueda: Corregidas búsquedas de episodios con ampersands y mejorado el parseo de tamaños de fuentes Stremio con formatos numéricos europeos.
34- TorrServer/TorBox: Manejo más seguro de respuestas vacías/lista de torrents y torrents en cola de TorBox durante la resolución de reproducción.
35- Mantenimiento: Añadida configuración de herramientas de desarrollo y limpieza de lint/tipos para cambios futuros más seguros.
36
37## 1.13.0
38- Downloads: Add a downloads manager for managing downloaded files inside Jacktook.
39- External Scraper: Add generic external scraper module support for addons like Magneto, CocoScrapers, etc, with settings and configuration dialog.
40- TMDB: Add configurable excluded languages filter for hiding content in selected original languages.
41- Search: Add a loading spinner while sources resolve and improve stale-cache fallback behavior.
42- Source Manager: Auto-select newly enabled sources so provider changes take effect immediately.
43- Burst: Harden result parsing and internal imports for safer provider integration.
44- Localization: Fix missing and misaligned Romanian strings and improve Romanian translations.
45
46Spanish:
47- Descargas: Añadido gestor de descargas para administrar archivos descargados dentro de Jacktook.
48- Scraper Externo: Añadido soporte genérico de módulo de scraper externo para addons como Magneto, CocoScrapers, etc, con ajustes y diálogo de configuración.
49- TMDB: Añadido filtro configurable de idiomas excluidos para ocultar contenido en idiomas originales seleccionados.
50- Busqueda: Añadido spinner de carga mientras se resuelven las fuentes y mejorado el fallback de cache obsoleta.
51- Source Manager: Seleccion automatica de fuentes recien activadas para que los cambios de proveedores apliquen inmediatamente.
52- Burst: Reforzado el parseo de resultados y las importaciones internas para una integracion de proveedores mas segura.
53- Localizacion: Corregidas cadenas rumanas faltantes y desalineadas y mejoradas las traducciones al rumano.
54
55Note:
56If you enjoy this addon, please consider supporting the project with a donation. Your contributions help keep improving and maintaining it. Thank you!
57
58Si disfrutas de este addon, por favor considera apoyar el proyecto con una donación. Tus contribuciones ayudan a seguir mejorándolo y manteniéndolo. ¡Gracias!
59
60Dacă îți place acest add-on, te rog să iei în considerare susținerea proiectului printr-o donație. Contribuțiile tale ajută la îmbunătățirea și menținerea acestuia. Mulțumesc!
61
62Если вам нравится этот аддон, пожалуйста, подумайте о поддержке проекта пожертвованием. Ваш вклад помогает улучшать и поддерживать его. Спасибо!
63
64Se você gosta deste addon, considere apoiar o projeto com uma doação. As suas contribuições ajudam a continuar melhorando e mantendo-o. Obrigado!
65
66## 1.12.0
67- Services: Add AutoStart service to automatically open Jacktook when Kodi boots (optional toggle in Settings > General > Startup).
68- Services: Add WidgetRefresh background service to periodically refresh Kodi widgets on a configurable timer (0-180 minutes) with optional notifications.
69- IMDb/Extras: Replace regex-based IMDb scraping with stable GraphQL API. Now fetches user reviews (with ratings, dates, and spoiler warnings), trivia, goofs/blunders, and structured parental guide (Sex & Nudity, Violence & Gore, Profanity, Alcohol/Drugs/Smoking, Frightening & Intense Scenes) in a single request.
70- IMDb/Extras: Add IMDb user reviews to the Extras window alongside existing TMDB reviews and Trakt comments.
71- TMDB/Search: Add TV show title-year search mode for finding shows by title and year.
72- TMDB/Cache: Resolve language change and cache issues. Improve cache invalidation, English fallback behavior, and TMDB initialization after settings changes.
73- TMDB/UI: Reorder manage sources context item for better menu organization.
74- Localization: Add Romanian language support
75
76Spanish:
77- Servicios: Añadido servicio AutoStart para abrir Jacktook automaticamente al arrancar Kodi (interruptor opcional en Ajustes > General > Inicio).
78- Servicios: Añadido servicio WidgetRefresh en segundo plano para actualizar widgets de Kodi periodicamente con un temporizador configurable (0-180 minutos) y notificaciones opcionales.
79- IMDb/Extras: Reemplazado el scraper regex de IMDb por la API GraphQL estable. Ahora obtiene reviews de usuarios (con ratings, fechas y advertencias de spoilers), trivia, errores/blunders, y guia parental estructurada (Sexo y Desnudez, Violencia y Sangre, Lenguaje, Alcohol/Drogas/Tabaco, Escenas Aterradoras e Intensas) en una sola peticion.
80- IMDb/Extras: Añadidas reviews de usuarios de IMDb en la ventana Extras junto a las reviews de TMDB y comentarios de Trakt.
81- TMDB/Busqueda: Añadido modo de busqueda por titulo-ano para series de TV.
82- TMDB/Cache: Resueltos problemas de cambio de idioma y cache. Mejorada invalidacion de cache, fallback en ingles e inicializacion de TMDB tras cambios de ajustes.
83- TMDB/UI: Reordenado el item de contexto "manage sources" para mejor organizacion del menu.
84- Localizacion: Añadido soporte para rumano.
85
86
87## 1.11.0
88- Source Manager: Add a dialog to enable or disable individual stream sources/providers.
89- Playback: Add Autoscrape Next Episode with resolved playback caching for faster next-episode playback.
90- Playback: Add stinger/post-credit scene notifications for movies with during-credits or after-credits scenes.
91- TMDB/Artwork: Add image resolution tiers for TMDB artwork.
92- Posters: Add RPDB integration for rating posters.
93- Filters: Add Codec, HDR, and Dolby Vision filters for search results.
94- Downloads: Add resumable downloads with movie/TV folder organization.
95- Torrents: Add subtitle download and playback from TorrServer torrent and file context menus.
96- Playback: Route TorrServer file clicks and subtitle playback through Jacktorr `Play Torrent`/`buffer_and_play` so buffering, pause statistics, and resume work correctly.
97- Source Select: Remove Download Video from torrent source context menus and add Download subtitles.
98- Localization: Rename Buffer and play to Play Torrent and add localized missing-metadata messages.
99
100Spanish:
101- Source Manager: Añadido dialogo para activar o desactivar fuentes/proveedores de streams individualmente.
102- Reproduccion: Añadido Autoscrape Next Episode con cache de reproduccion resuelta para acelerar la reproduccion del siguiente episodio.
103- Reproduccion: Añadidas notificaciones de escenas post-creditos/stinger para peliculas con escenas durante o despues de los creditos.
104- TMDB/Arte: Añadidos niveles de resolucion para imagenes de TMDB.
105- Posters: Añadida integracion con RPDB para posters con ratings.
106- Filtros: Añadidos filtros de Codec, HDR y Dolby Vision para resultados de busqueda.
107- Descargas: Añadidas descargas reanudables con organizacion de carpetas para peliculas/series.
108- Torrents: Añadida descarga y reproduccion de subtitulos desde los menus contextuales de torrents y archivos de TorrServer.
109- Reproduccion: Enrutados los clicks de archivos TorrServer y la reproduccion con subtitulos por Jacktorr `Play Torrent`/`buffer_and_play` para mantener buffering, estadisticas al pausar y reanudacion.
110- Source Select: Eliminada la opcion Descargar Video de fuentes torrent y añadida Descargar subtitulos.
111- Localizacion: Renombrado Buffer and play a Play Torrent y añadidos mensajes localizados para metadata faltante.
112
1113## 1.10.0
2114- Search: Add Search Title Language mode with localized first, English first, and English only ordering for Easynews/Jackett/Prowlarr context searches.
3115- Search: Improve English-first fallback behavior to prioritize TMDB original titles before localized titles when English translations are missing.
plugin.video.jacktook/README.md +9 -8
……@@ -1,6 +1,6 @@
11<div align="center">
22
3# 🚀 Jacktook
3# Jacktook
44
55*An advanced meta-scraper and streaming Kodi addon for enthusiasts.*
66
……@@ -14,19 +14,20 @@
1414
1515</div>
1616
17## 🌟 Overview
17## Overview
1818
1919**Jacktook** is a Kodi addon designed to aggregate streaming sources and metadata from a wide range of providers. Whether you prefer P2P streaming, Stremio addons, or Debrid services, Jacktook offers a unified and streamlined interface for seamless media discovery and playback.
2020
2121---
2222
23## ✨ Features
23## Features
2424
2525### 🔍 Search & Scraping
2626- **Aggregated Sources**: Stremio Addons, Jacktook Burst, Jackgram, Jackett, Prowlarr, Usenet, Debrid Services.
27- **Debrid Support**: Native support for RealDebrid, AllDebrid, Premiumize, and Torbox.
27- **Debrid Support**: Native support for RealDebrid, AllDebrid, Premiumize, OffCloud and Torbox.
2828- **Torrent Scraping**: Integration with Jackett and Prowlarr for torrent sources.
2929- **Usenet Scraping**: EasyNews integration for Usenet sources.
30- **External Scrapers**: Support for external scrapers like Magneto and CocoScrapers.
3031- **Telegram Scraping**: Jackgram integration for streaming from Telegram channels.
3132- **Rich Metadata**: Integration with TMDB, MDBList, Trakt, Fanart.tv, and Stremio catalogs.
3233- **Smart Filtering**: Advanced results filtering by quality, size, and source.
……@@ -67,7 +68,7 @@ The recommended installation method is via the official repository to ensure you
6768
6869## ⚙️ Configuration & Usage
6970
70### 🚀 Stremio Integration
71### Stremio Integration
7172Jacktook can import configurations and Debrid services directly from your Stremio account.
7273- Enable Stremio in settings.
7374- Login to your Stremio account to sync your installed addons and Debrid tokens.
……@@ -102,7 +103,7 @@ If you are not using Debrid services, you can use these engines for P2P streamin
102103
103104---
104105
105## 📸 Screenshots
106## Screenshots
106107
107108| Home Screen | TV Shows |
108109| :---: | :---: |
……@@ -112,7 +113,7 @@ If you are not using Debrid services, you can use these engines for P2P streamin
112113
113114---
114115
115## ☕ Support
116## Support
116117
117118If you enjoy using **Jacktook** and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements.
118119
……@@ -120,7 +121,7 @@ If you enjoy using **Jacktook** and want to support its development, you can buy
120121
121122---
122123
123## ⚖️ Disclaimer
124## Disclaimer
124125
125126This addon is a meta-search tool. It does not host or scrape torrent websites directly. Users are responsible for the content they access and should comply with local copyright laws.
126127
plugin.video.jacktook/addon.xml +1 -1
……@@ -1,5 +1,5 @@
11<?xml version="1.0" encoding="utf-8"?>
2 <addon id="plugin.video.jacktook" name="Jacktook" version="1.10.1" provider-name="Sam-Max">
2 <addon id="plugin.video.jacktook" name="Jacktook" version="1.15.1" provider-name="Sam-Max">
33 <requires>
44 <import addon="xbmc.python" version="3.00.0"/>
55 <import addon="script.module.requests" version="2.25.1"/>
plugin.video.jacktook/jacktook.py.bak +0 -7
……@@ -1,7 +0,0 @@
1#!/usr/bin/env python3
2
3from lib.router import addon_router
4from lib.utils.kodi.utils import load_saved_view_properties
5
6load_saved_view_properties()
7addon_router()
plugin.video.jacktook/lib/actions.py +5 -4
……@@ -1,13 +1,14 @@
11import json
2
23from lib.domain.torrent import TorrentStream
34from lib.gui.resolver_window import ResolverWindow
45from lib.utils.kodi.utils import ADDON_PATH
5from lib.downloader import handle_download_file
6
67
78def resolve_for_pack_selection(params):
89 data = json.loads(params["data"])
910 info_hash = data.get("infoHash")
10
11
1112 source = TorrentStream(
1213 guid="",
1314 url=data.get("url"),
……@@ -21,6 +22,7 @@ def resolve_for_pack_selection(params):
2122 subindexer=data.get("subindexer"),
2223 addonKey=data.get("addonKey", ""),
2324 addonName=data.get("addonName", ""),
25 addonSourceName=data.get("addonSourceName", ""),
2426 addonInstanceLabel=data.get("addonInstanceLabel", ""),
2527 quality=data.get("quality"),
2628 isPack=data.get("isPack", False),
……@@ -64,6 +66,7 @@ def resolve_for_subtitles(params):
6466 subindexer=data.get("subindexer"),
6567 addonKey=data.get("addonKey", ""),
6668 addonName=data.get("addonName", ""),
69 addonSourceName=data.get("addonSourceName", ""),
6770 addonInstanceLabel=data.get("addonInstanceLabel", ""),
6871 quality=data.get("quality"),
6972 isPack=data.get("isPack"),
……@@ -89,5 +92,3 @@ def resolve_for_subtitles(params):
8992 )
9093 resolver_window.doModal()
9194 del resolver_window
92
93
plugin.video.jacktook/lib/api/debrid/alldebrid.py +5 -14
……@@ -1,8 +1,7 @@
1import requests
21from lib.api.debrid.base import DebridClient, ProviderException
32from lib.jacktook.utils import kodilog
4from lib.utils.kodi.utils import dialog_ok, set_setting, translation
53from lib.services.debrid.auth import run_alldebrid_auth
4from lib.utils.kodi.utils import dialog_ok, set_setting, translation
65
76
87class AllDebrid(DebridClient):
……@@ -70,9 +69,7 @@ class AllDebrid(DebridClient):
7069 if error_code == "AUTH_BAD_APIKEY":
7170 raise ProviderException("Invalid AllDebrid API key")
7271 elif error_code == "NO_SERVER":
73 raise ProviderException(
74 f"Failed to add magnet link to AllDebrid {response_data}"
75 )
72 raise ProviderException(f"Failed to add magnet link to AllDebrid {response_data}")
7673 elif error_code == "AUTH_BLOCKED":
7774 raise ProviderException("API got blocked on AllDebrid")
7875 elif error_code == "MAGNET_MUST_BE_PREMIUM":
……@@ -82,14 +79,10 @@ class AllDebrid(DebridClient):
8279 elif error_code == "MAGNET_INVALID_ID":
8380 raise ProviderException("Invalid magnet link for AllDebrid")
8481 else:
85 raise ProviderException(
86 f"Failed to add magnet link to AllDebrid {response_data}"
87 )
82 raise ProviderException(f"Failed to add magnet link to AllDebrid {response_data}")
8883
8984 def add_magnet(self, magnet_link):
90 response = self._make_request(
91 "POST", "/magnet/upload", data={"magnets[]": magnet_link}
92 )
85 response = self._make_request("POST", "/magnet/upload", data={"magnets[]": magnet_link})
9386 self._validate_error_response(response)
9487 return response
9588
……@@ -129,9 +122,7 @@ class AllDebrid(DebridClient):
129122 return response.get("data", {}).get("magnets", {})
130123
131124 def get_torrent_instant_availability(self, magnet_links):
132 response = self._make_request(
133 "POST", "/magnet/instant", data={"magnets[]": magnet_links}
134 )
125 response = self._make_request("POST", "/magnet/instant", data={"magnets[]": magnet_links})
135126 return response.get("data", {}).get("magnets", [])
136127
137128 def get_available_torrent(self, info_hash):
plugin.video.jacktook/lib/api/debrid/base.py +21 -29
……@@ -1,28 +1,30 @@
1import requests
21import traceback
32from abc import ABC, abstractmethod
43from json.decoder import JSONDecodeError
4from typing import Any, Dict, Optional
5
6import requests
57
68from lib.utils.kodi.utils import kodilog, notification
7from typing import Optional, Dict, Any
89
910
1011class DebridClient(ABC):
1112 """
1213 Abstract base class for Debrid service clients.
14
1315 Handles HTTP requests, error handling, and session management.
1416 """
1517
16 def __init__(
17 self, token: str, timeout: int = 15, session: Optional[requests.Session] = None
18 ):
19 """
18 def __init__(self, token: str, timeout: int = 15, session: Optional[requests.Session] = None):
19 """Initialize the debrid client.
20
2021 Args:
2122 token (str): API token for authentication.
2223 timeout (int): Request timeout in seconds.
2324 session (requests.Session, optional): Custom session for requests.
2425 """
2526 self.token = token
27
2628 self.timeout = timeout
2729 self.session = session or requests.Session()
2830 self.headers = {}
……@@ -68,20 +70,18 @@ class DebridClient(ABC):
6870 )
6971 except requests.exceptions.Timeout as e:
7072 kodilog(f"Timeout: {e}")
71 raise ProviderException("Request timed out.")
73 raise ProviderException("Request timed out.") from e
7274 except requests.exceptions.ConnectionError as e:
7375 kodilog(f"ConnectionError: {e}")
74 raise ProviderException(f"ConnectionError: {e}")
76 raise ProviderException(f"ConnectionError: {e}") from e
7577 except requests.exceptions.RequestException as e:
7678 kodilog(f"RequestException: {e}")
77 raise ProviderException(f"Request failed: {str(e)}")
79 raise ProviderException(f"Request failed: {e!s}") from e
7880 except Exception as e:
7981 kodilog(f"Unexpected error: {e}")
80 raise ProviderException(f"Unexpected error: {str(e)}")
82 raise ProviderException(f"Unexpected error: {e!s}") from e
8183
82 def _handle_errors(
83 self, response: requests.Response, is_expected_to_fail: bool
84 ) -> None:
84 def _handle_errors(self, response: requests.Response, is_expected_to_fail: bool) -> None:
8585 """
8686 Handle HTTP errors and raise ProviderException as needed.
8787 """
……@@ -110,13 +110,11 @@ class DebridClient(ABC):
110110
111111 # Specific cases
112112 if status_code == 401:
113 raise ProviderException("Invalid token", status_code, error_content)
113 raise ProviderException("Invalid token", status_code, error_content) from error
114114 elif status_code == 403:
115 raise ProviderException("Forbidden", status_code, error_content)
115 raise ProviderException("Forbidden", status_code, error_content) from error
116116 elif status_code == 500:
117 raise ProviderException(
118 "Internal server error", status_code, error_content
119 )
117 raise ProviderException("Internal server error", status_code, error_content) from error
120118 else:
121119 kodilog(
122120 f"Error: {''.join(traceback.format_exception(type(error), error, error.__traceback__))}"
……@@ -125,7 +123,7 @@ class DebridClient(ABC):
125123 f"API Error: {status_code} for {url}\nDetails: {error_content}",
126124 status_code,
127125 error_content,
128 )
126 ) from error
129127
130128 @abstractmethod
131129 def initialize_headers(self) -> None:
……@@ -142,9 +140,7 @@ class DebridClient(ABC):
142140 raise NotImplementedError
143141
144142 @staticmethod
145 def _parse_response(
146 response: requests.Response, is_return_none: bool
147 ) -> Dict[str, Any]:
143 def _parse_response(response: requests.Response, is_return_none: bool) -> Dict[str, Any]:
148144 """
149145 Parse the HTTP response as JSON.
150146 """
……@@ -155,12 +151,10 @@ class DebridClient(ABC):
155151 except JSONDecodeError as error:
156152 raise ProviderException(
157153 f"Failed to parse response error: {error}. \nresponse: {response.text}"
158 )
154 ) from error
159155
160156 @abstractmethod
161 def _handle_service_specific_errors(
162 self, error_data: dict, status_code: int
163 ) -> None:
157 def _handle_service_specific_errors(self, error_data: dict, status_code: int) -> None:
164158 """
165159 Service specific errors on api requests. Must be implemented by subclasses.
166160 """
……@@ -168,9 +162,7 @@ class DebridClient(ABC):
168162
169163
170164class ProviderException(Exception):
171 def __init__(
172 self, message: str, status_code: Optional[int] = None, error_content: Any = None
173 ):
165 def __init__(self, message: str, status_code: Optional[int] = None, error_content: Any = None):
174166 self.message = message
175167 self.status_code = status_code
176168 self.error_content = error_content
plugin.video.jacktook/lib/api/debrid/debrider.py +5 -9
……@@ -1,7 +1,7 @@
11from lib.api.debrid.base import DebridClient
22from lib.jacktook.utils import kodilog
3from lib.utils.kodi.utils import dialog_ok, set_setting, translation
43from lib.services.debrid.auth import run_debrider_auth
4from lib.utils.kodi.utils import dialog_ok, set_setting, translation
55
66
77class Debrider(DebridClient):
……@@ -58,7 +58,7 @@ class Debrider(DebridClient):
5858 )
5959
6060 def get_device_auth_status(self, code):
61 "Check the status of device authentication using a code."
61 """Check the status of device authentication using a code."""
6262 return self._make_request(
6363 "GET",
6464 f"{self.AUTH_URL}/device/auth",
……@@ -109,16 +109,12 @@ class Debrider(DebridClient):
109109 import datetime
110110
111111 try:
112 expires = datetime.datetime.strptime(
113 premium_until, "%Y-%m-%dT%H:%M:%S.%fZ"
114 )
112 expires = datetime.datetime.strptime(premium_until, "%Y-%m-%dT%H:%M:%S.%fZ")
115113 except ValueError:
116 expires = datetime.datetime.strptime(
117 premium_until, "%Y-%m-%dT%H:%M:%SZ"
118 )
114 expires = datetime.datetime.strptime(premium_until, "%Y-%m-%dT%H:%M:%SZ")
119115
120116 days = (expires - datetime.datetime.utcnow()).days
121117 return days
122 except Exception as e:
118 except Exception:
123119 # kodilog(f"Error calculating Debrider days remaining: {e}")
124120 return None
plugin.video.jacktook/lib/api/debrid/offcloud.py +123 -0
……@@ -0,0 +1,123 @@
1from lib.api.debrid.base import DebridClient, ProviderException
2
3
4class Offcloud(DebridClient):
5 BASE_URL = "https://offcloud.com/api"
6 OAUTH_BASE_URL = "https://offcloud.com"
7 DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
8
9 def initialize_headers(self):
10 self.headers = {
11 "Accept": "application/json",
12 "Content-Type": "application/json",
13 }
14 if self.token:
15 self.headers["Authorization"] = f"Bearer {self.token}"
16
17 def disable_access_token(self):
18 pass
19
20 def _handle_service_specific_errors(self, error_data: dict, status_code: int):
21 error_code = error_data.get("error")
22 if error_code == "NOAUTH":
23 raise ProviderException("Invalid Offcloud token", status_code, error_data)
24 if error_code == "Bad archive":
25 raise ProviderException(
26 "Offcloud cannot explore single-file torrents", status_code, error_data
27 )
28
29 def _make_request(
30 self,
31 method,
32 url,
33 data=None,
34 params=None,
35 json=None,
36 is_return_none=False,
37 is_expected_to_fail=False,
38 ):
39 return super()._make_request(
40 method,
41 self.BASE_URL + url,
42 data=data,
43 params=params or {},
44 json=json,
45 is_return_none=is_return_none,
46 is_expected_to_fail=is_expected_to_fail,
47 )
48
49 def _make_oauth_request(self, method, url, json=None, is_expected_to_fail=False):
50 headers = self.headers
51 self.headers = {
52 "Accept": "application/json",
53 "Content-Type": "application/json",
54 }
55 try:
56 return super()._make_request(
57 method,
58 self.OAUTH_BASE_URL + url,
59 json=json,
60 params={},
61 is_expected_to_fail=is_expected_to_fail,
62 )
63 finally:
64 self.headers = headers
65
66 def get_device_code(self):
67 return self._make_oauth_request("POST", "/oauth/device/code", json={})
68
69 def get_token(self, device_code, is_expected_to_fail=False):
70 return self._make_oauth_request(
71 "POST",
72 "/oauth/token",
73 json={"grant_type": self.DEVICE_GRANT_TYPE, "device_code": device_code},
74 is_expected_to_fail=is_expected_to_fail,
75 )
76
77 def authorize(self, device_code):
78 return self.get_token(device_code, is_expected_to_fail=True)
79
80 def remove_auth(self):
81 from lib.utils.kodi.utils import dialog_ok, set_setting, translation
82
83 self.token = ""
84 set_setting("offcloud_token", "")
85 set_setting("offcloud_user", "")
86 set_setting("offcloud_authorized", "false")
87 dialog_ok(translation(90544), translation(90561))
88
89 def get_account_info(self):
90 return self._make_request("GET", "/account/info")
91
92 def get_cache_info(self, urls, include_files=False):
93 return self._make_request(
94 "POST",
95 "/cache/info",
96 json={"urls": urls, "includeFiles": include_files},
97 )
98
99 def create_cache_download(self, url):
100 return self._make_request("POST", "/cache/download", json={"url": url})
101
102 def add_cloud_download(self, url):
103 return self._make_request("POST", "/cloud", json={"url": url})
104
105 def get_cloud_status(self, request_id):
106 response = self._make_request("POST", "/cloud/status", json={"requestId": request_id})
107 status = response.get("status")
108 return status if isinstance(status, dict) else response
109
110 def explore_cloud_download(self, request_id, detailed=True):
111 params = {"format": "detailed"} if detailed else None
112 return self._make_request(
113 "GET",
114 f"/cloud/explore/{request_id}",
115 params=params,
116 is_expected_to_fail=True,
117 )
118
119 def get_cloud_history(self):
120 return self._make_request("GET", "/cloud/history")
121
122 def remove_cloud_downloads(self, request_ids):
123 return self._make_request("POST", "/cloud/remove", json={"requests": request_ids})
plugin.video.jacktook/lib/api/debrid/premiumize.py +3 -6
……@@ -21,6 +21,7 @@ class Premiumize(DebridClient):
2121
2222 def remove_auth(self):
2323 from lib.utils.kodi.utils import set_setting
24
2425 self.token = ""
2526 set_setting("premiumize_token", "")
2627 set_setting("premiumize_authorized", "false")
……@@ -114,11 +115,7 @@ class Premiumize(DebridClient):
114115 def get_torrent_info(self, torrent_id):
115116 transfer_list = self.get_transfer_list()
116117 torrent_info = next(
117 (
118 torrent
119 for torrent in transfer_list["transfers"]
120 if torrent["id"] == torrent_id
121 ),
118 (torrent for torrent in transfer_list["transfers"] if torrent["id"] == torrent_id),
122119 None,
123120 )
124121 return torrent_info
……@@ -180,6 +177,6 @@ class Premiumize(DebridClient):
180177 expires = datetime.datetime.fromtimestamp(premium_until)
181178 days = (expires - datetime.datetime.now()).days
182179 return days
183 except Exception as e:
180 except Exception:
184181 # kodilog(f"Error calculating Premiumize days remaining: {e}")
185182 return None
plugin.video.jacktook/lib/api/debrid/realdebrid.py +10 -16
……@@ -1,13 +1,14 @@
1from base64 import b64decode, b64encode
2
13from lib.api.debrid.base import DebridClient, ProviderException
2from base64 import b64encode, b64decode
4from lib.services.debrid.auth import run_realdebrid_auth
5from lib.services.debrid.download import run_realdebrid_download
36from lib.utils.kodi.utils import (
47 dialog_ok,
58 kodilog,
69 set_setting,
710 translation,
811)
9from lib.services.debrid.auth import run_realdebrid_auth
10from lib.services.debrid.download import run_realdebrid_download
1112
1213
1314class RealDebrid(DebridClient):
……@@ -30,9 +31,7 @@ class RealDebrid(DebridClient):
3031 token_data["client_secret"],
3132 token_data["code"],
3233 )
33 self.headers["Authorization"] = (
34 f"Bearer {access_token_data['access_token']}"
35 )
34 self.headers["Authorization"] = f"Bearer {access_token_data['access_token']}"
3635
3736 def _handle_service_specific_errors(self, error_data: dict, status_code: int):
3837 error_code = error_data.get("error_code")
……@@ -125,7 +124,7 @@ class RealDebrid(DebridClient):
125124 "code": code,
126125 }
127126 except Exception as e:
128 raise ProviderException(f"Invalid token {e}")
127 raise ProviderException(f"Invalid token {e}") from e
129128
130129 def get_device_code(self):
131130 return self._make_request(
……@@ -247,9 +246,7 @@ class RealDebrid(DebridClient):
247246 return self._make_request("GET", f"{self.BASE_URL}/torrents")
248247
249248 def get_user_downloads_list(self, page=1):
250 return self._make_request(
251 "GET", f"{self.BASE_URL}/downloads", params={"page": page}
252 )
249 return self._make_request("GET", f"{self.BASE_URL}/downloads", params={"page": page})
253250
254251 def get_user(self):
255252 return self._make_request("GET", f"{self.BASE_URL}/user")
……@@ -267,9 +264,7 @@ class RealDebrid(DebridClient):
267264 import datetime
268265
269266 try:
270 expires = datetime.datetime.strptime(
271 expiration, "%Y-%m-%dT%H:%M:%S.%fZ"
272 )
267 expires = datetime.datetime.strptime(expiration, "%Y-%m-%dT%H:%M:%S.%fZ")
273268 except ValueError:
274269 expires = datetime.datetime.strptime(expiration, "%Y-%m-%dT%H:%M:%SZ")
275270
……@@ -316,9 +311,8 @@ class RealDebrid(DebridClient):
316311 if "download" in response:
317312 return response
318313
319 if "error_code" in response:
320 if response["error_code"] == 23:
321 raise ProviderException("Exceed remote traffic limit")
314 if "error_code" in response and response["error_code"] == 23:
315 raise ProviderException("Exceed remote traffic limit")
322316 raise ProviderException(f"Failed to create download link. response: {response}")
323317
324318 def delete_torrent(self, torrent_id):
plugin.video.jacktook/lib/api/debrid/torbox.py +52 -15
……@@ -1,4 +1,10 @@
1import datetime
2
3import requests
4from requests.adapters import HTTPAdapter
5
16from lib.api.debrid.base import DebridClient, ProviderException
7from lib.services.debrid.auth import run_torbox_auth
28from lib.utils.kodi.utils import (
39 dialog_ok,
410 kodilog,
……@@ -6,10 +12,6 @@ from lib.utils.kodi.utils import (
612 set_setting,
713 translation,
814)
9import datetime
10import requests
11from requests.adapters import HTTPAdapter
12from lib.services.debrid.auth import run_torbox_auth
1315
1416
1517class Torbox(DebridClient):
……@@ -55,15 +57,46 @@ class Torbox(DebridClient):
5557 ):
5658 params = params or {}
5759 url = self.BASE_URL + url
58 return super()._make_request(
59 method,
60 url,
61 data=data,
62 params=params,
63 json=json,
64 is_return_none=is_return_none,
65 is_expected_to_fail=is_expected_to_fail,
66 )
60 response = self._perform_request(method, url, data, params, json)
61 validated_response = self._validate_error_response(response)
62 if validated_response is not None:
63 return validated_response
64 self._handle_errors(response, is_expected_to_fail)
65 return self._parse_response(response, is_return_none)
66
67 def _validate_error_response(self, response):
68 if response.ok:
69 return None
70
71 content_type = response.headers.get("Content-Type", "")
72 if not content_type.startswith("application/json"):
73 return None
74
75 try:
76 error_data = response.json()
77 except ValueError:
78 return None
79
80 if not isinstance(error_data, dict):
81 return None
82
83 error_code = error_data.get("error", "")
84 error_detail = str(error_data.get("detail", "")).lower()
85
86 if (
87 response.status_code == 400
88 and error_code == "DIFF_ISSUE"
89 and "already queued" in error_detail
90 ):
91 kodilog(
92 "Torbox._validate_error_response: treating duplicate queued torrent as recoverable state"
93 )
94 normalized = dict(error_data)
95 normalized["success"] = True
96 normalized["queued"] = True
97 return normalized
98
99 return None
67100
68101 def add_magnet_link(self, magnet_link):
69102 return self._make_request(
……@@ -77,7 +110,11 @@ class Torbox(DebridClient):
77110 response = self.session.request(
78111 method="POST",
79112 url=f"{self.BASE_URL}/torrents/createtorrent",
80 data={"seed": 3, "allow_zip": "false", "name": torrent_name or "torrent.torrent"},
113 data={
114 "seed": 3,
115 "allow_zip": "false",
116 "name": torrent_name or "torrent.torrent",
117 },
81118 files={
82119 "file": (
83120 torrent_name or "torrent.torrent",
……@@ -102,7 +139,7 @@ class Torbox(DebridClient):
102139 if "Request timed out" in str(exc):
103140 raise ProviderException(
104141 "Torbox timed out while checking your cloud torrents. Please try again."
105 )
142 ) from exc
106143 raise
107144
108145 def get_torrent_info(self, magnet_id):
plugin.video.jacktook/lib/api/fanart/fanart.py +133 -136
……@@ -1,136 +1,133 @@
1import requests
2from lib.utils.kodi.utils import get_setting
3
4API_KEY = "a7ad21743fd710fccb738232f2fbdcfc"
5CLIENT_KEY = "fe073550acf157bdb8a4217f215c0882"
6BASE_URL = "https://webservice.fanart.tv/v3/{media_type}/{media_id}"
7TIMEOUT = 3.0
8
9DEFAULT_FANART = {
10 "poster2": "",
11 "fanart2": "",
12 "banner": "",
13 "clearart": "",
14 "clearlogo": "",
15 "landscape": "",
16 "discart": "",
17 "fanart_added": False,
18}
19
20# Shared HTTP session (persistent, connection pooled)
21session = requests.Session()
22session.mount(
23 "https://webservice.fanart.tv", requests.adapters.HTTPAdapter(pool_maxsize=100)
24)
25
26
27def get_fanart(media_type: str, language: str, media_id: str) -> dict:
28 """
29 Fetch localized artwork (poster, fanart, clearlogo, etc.) from fanart.tv.
30 Supports movies and TV shows, with language fallback: local → English → universal.
31 """
32 if not media_id:
33 return DEFAULT_FANART.copy()
34
35 url = BASE_URL.format(media_type=media_type, media_id=media_id)
36 client_key = get_setting("fanart_client_key") or CLIENT_KEY
37 headers = {"client-key": client_key, "api-key": API_KEY}
38
39 try:
40 response = session.get(url, headers=headers, timeout=TIMEOUT)
41 data = response.json()
42 except Exception:
43 return DEFAULT_FANART.copy()
44
45 # Handle API errors
46 if not data or "error_message" in data:
47 return DEFAULT_FANART.copy()
48
49 # Convenience alias
50 get = data.get
51
52 if media_type == "movies":
53 return {
54 "poster": select_art(get("movieposter"), language),
55 "fanart": select_art(get("moviebackground"), language),
56 "banner": select_art(get("moviebanner"), language),
57 "clearart": select_art(
58 get("movieart", []) + get("hdmovieclearart", []), language
59 ),
60 "clearlogo": select_art(
61 get("movielogo", []) + get("hdmovielogo", []), language
62 ),
63 "landscape": select_art(get("moviethumb"), language),
64 "discart": select_art(get("moviedisc"), language),
65 "fanart_added": True,
66 }
67
68 # Default to TV shows
69 return {
70 "poster": select_art(get("tvposter"), language),
71 "fanart": select_art(get("showbackground"), language),
72 "banner": select_art(get("tvbanner"), language),
73 "clearart": select_art(get("clearart", []) + get("hdclearart", []), language),
74 "clearlogo": select_art(get("hdtvlogo", []) + get("clearlogo", []), language),
75 "landscape": select_art(get("tvthumb"), language),
76 "discart": "",
77 "fanart_added": True,
78 }
79
80
81# --- Artwork Selector ---
82def select_art(art_list: list, language: str) -> str:
83 """
84 Return the best matching artwork URL from a list of art objects.
85 Preference order: user's language → English → universal.
86 Sorts by number of likes (descending).
87 """
88 if not art_list:
89 return ""
90
91 try:
92 # 1. User's language (e.g. 'es')
93 matches = [
94 (x["url"], int(x.get("likes", 0)))
95 for x in art_list
96 if x.get("lang") == language
97 ]
98
99 # 2. English fallback
100 if not matches and language != "en":
101 matches = [
102 (x["url"], int(x.get("likes", 0)))
103 for x in art_list
104 if x.get("lang") == "en"
105 ]
106
107 # 3. Universal fallback ('00' or no lang)
108 if not matches:
109 matches = [
110 (x["url"], int(x.get("likes", 0)))
111 for x in art_list
112 if x.get("lang") in ("00", "", None)
113 ]
114
115 if matches:
116 # Sort by likes descending, return top URL
117 matches.sort(key=lambda x: x[1], reverse=True)
118 return matches[0][0]
119
120 except Exception:
121 pass
122
123 return ""
124
125
126# --- Helper: Merge Results into Existing Metadata ---
127def add_fanart(media_type: str, language: str, media_id: str, meta: dict) -> dict:
128 """
129 Update an existing metadata dictionary with fanart data.
130 Always returns a complete dict (never None).
131 """
132 try:
133 meta.update(get_fanart(media_type, language, media_id))
134 except Exception:
135 meta.update(DEFAULT_FANART)
136 return meta
1import requests
2
3from lib.utils.kodi.utils import get_setting
4
5API_KEY = "a7ad21743fd710fccb738232f2fbdcfc"
6CLIENT_KEY = "fe073550acf157bdb8a4217f215c0882"
7BASE_URL = "https://webservice.fanart.tv/v3/{media_type}/{media_id}"
8TIMEOUT = 3.0
9
10DEFAULT_FANART = {
11 "poster2": "",
12 "fanart2": "",
13 "banner": "",
14 "clearart": "",
15 "clearlogo": "",
16 "landscape": "",
17 "discart": "",
18 "fanart_added": False,
19}
20
21# Shared HTTP session (persistent, connection pooled)
22session = requests.Session()
23session.mount("https://webservice.fanart.tv", requests.adapters.HTTPAdapter(pool_maxsize=100))
24
25
26def get_fanart(media_type: str, language: str, media_id: str) -> dict:
27 """
28 Fetch localized artwork (poster, fanart, clearlogo, etc.) from fanart.tv.
29
30 Supports movies and TV shows, with language fallback: local → English → universal.
31 """
32 if not media_id:
33
34 return DEFAULT_FANART.copy()
35
36 url = BASE_URL.format(media_type=media_type, media_id=media_id)
37 client_key = get_setting("fanart_client_key") or CLIENT_KEY
38 headers = {"client-key": client_key, "api-key": API_KEY}
39
40 try:
41 response = session.get(url, headers=headers, timeout=TIMEOUT)
42 data = response.json()
43 except Exception:
44 return DEFAULT_FANART.copy()
45
46 # Handle API errors
47 if not data or "error_message" in data:
48 return DEFAULT_FANART.copy()
49
50 # Convenience alias
51 get = data.get
52
53 if media_type == "movies":
54 return {
55 "poster": select_art(get("movieposter"), language),
56 "fanart": select_art(get("moviebackground"), language),
57 "banner": select_art(get("moviebanner"), language),
58 "clearart": select_art(get("movieart", []) + get("hdmovieclearart", []), language),
59 "clearlogo": select_art(get("movielogo", []) + get("hdmovielogo", []), language),
60 "landscape": select_art(get("moviethumb"), language),
61 "discart": select_art(get("moviedisc"), language),
62 "fanart_added": True,
63 }
64
65 # Default to TV shows
66 return {
67 "poster": select_art(get("tvposter"), language),
68 "fanart": select_art(get("showbackground"), language),
69 "banner": select_art(get("tvbanner"), language),
70 "clearart": select_art(get("clearart", []) + get("hdclearart", []), language),
71 "clearlogo": select_art(get("hdtvlogo", []) + get("clearlogo", []), language),
72 "landscape": select_art(get("tvthumb"), language),
73 "discart": "",
74 "fanart_added": True,
75 }
76
77
78# --- Artwork Selector ---
79def select_art(art_list: list, language: str) -> str:
80 """
81 Return the best matching artwork URL from a list of art objects.
82
83 Preference order: user's language → English → universal.
84 Sorts by number of likes (descending).
85 """
86 if not art_list:
87
88 return ""
89
90 try:
91 # 1. User's language (e.g. 'es')
92 matches = [
93 (x["url"], int(x.get("likes", 0))) for x in art_list if x.get("lang") == language
94 ]
95
96 # 2. English fallback
97 if not matches and language != "en":
98 matches = [
99 (x["url"], int(x.get("likes", 0))) for x in art_list if x.get("lang") == "en"
100 ]
101
102 # 3. Universal fallback ('00' or no lang)
103 if not matches:
104 matches = [
105 (x["url"], int(x.get("likes", 0)))
106 for x in art_list
107 if x.get("lang") in ("00", "", None)
108 ]
109
110 if matches:
111 # Sort by likes descending, return top URL
112 matches.sort(key=lambda x: x[1], reverse=True)
113 return matches[0][0]
114
115 except Exception:
116 pass
117
118 return ""
119
120
121# --- Helper: Merge Results into Existing Metadata ---
122def add_fanart(media_type: str, language: str, media_id: str, meta: dict) -> dict:
123 """
124 Update an existing metadata dictionary with fanart data.
125
126 Always returns a complete dict (never None).
127 """
128 try:
129
130 meta.update(get_fanart(media_type, language, media_id))
131 except Exception:
132 meta.update(DEFAULT_FANART)
133 return meta
plugin.video.jacktook/lib/api/imdb/imdb_scraper.py +281 -160
……@@ -1,196 +1,317 @@
1import requests
2import re
31import html
4from lib.utils.kodi.utils import kodilog
5from lib.db.cached import cache
2import re
63from datetime import timedelta
74
5import requests
86
9def clean_html(text):
7from lib.db.cached import cache
8from lib.utils.kodi.utils import kodilog
9
10GQL_URL = "https://graphql.imdb.com/"
11GQL_HEADERS = {
12 "Content-Type": "application/json",
13 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
14 "Accept": "application/json",
15 "Origin": "https://www.imdb.com",
16 "Referer": "https://www.imdb.com/",
17 "x-imdb-client-name": "imdb-web-next-localized",
18 "x-imdb-user-language": "en-US",
19 "x-imdb-user-country": "US",
20}
21
22
23def _clean(text):
1024 if not text:
1125 return ""
12 # Remove all HTML tags
13 text = re.sub(r"<[^>]+>", " ", text)
14 # Unescape HTML entities
26 text = text.replace("<br/><br/>", "\n").replace("<br/>", "\n").replace("<br>", "\n")
27 text = re.sub(r"<a[^>]*>", "", text).replace("</a>", "")
28 text = re.sub(r"<[^>]+>", "", text)
1529 text = html.unescape(text)
16 # Cleanup whitespace
17 text = " ".join(text.split())
1830 return text.strip()
1931
2032
21def get_imdb_trivia(imdb_id):
33def get_imdb_extras(imdb_id):
2234 """
23 Scrapes the IMDb trivia page for a given IMDb ID.
24 Returns a list of trivia strings.
35 Fetch IMDb extras (reviews, trivia, goofs, parental guide) via GraphQL.
36
37 Returns a dict with keys: reviews, trivia, blunders, parentsguide.
2538 """
2639 if not imdb_id:
27 return []
2840
29 cache_key = f"imdb_trivia_regex_v1_{imdb_id}"
30 cached_data = cache.get(cache_key)
31 if cached_data is not None:
32 return cached_data
41 return {"reviews": [], "trivia": [], "blunders": [], "parentsguide": []}
3342
34 url = f"https://www.imdb.com/title/{imdb_id}/trivia/"
35 headers = {
36 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
37 "Accept-Language": "en-US,en;q=0.5",
38 }
43 cache_key = f"imdb_extras_gql_{imdb_id}"
44 cached = cache.get(cache_key)
45 if cached is not None:
46 return cached
3947
4048 try:
41 response = requests.get(url, headers=headers, timeout=10)
42 if response.status_code != 200:
43 return []
44
45 # Find all trivia content divs using regex
46 # Pattern: class="ipc-html-content-inner-div" ... > Content </div>
47 matches = re.findall(
48 r"class=\"ipc-html-content-inner-div\"[^>]*>(.*?)</div>",
49 response.text,
50 re.DOTALL,
51 )
52
53 trivia_list = []
54 for m in matches:
55 text = clean_html(m)
56 if text:
57 trivia_list.append(text)
58
59 kodilog(f"Scraped {len(trivia_list)} IMDb trivia items (regex) for {imdb_id}")
60 cache.set(cache_key, trivia_list, timedelta(hours=24))
61 return trivia_list
49 payload = {"query": IMDB_EXTRAS_QUERY % imdb_id}
50 response = requests.post(GQL_URL, json=payload, headers=GQL_HEADERS, timeout=10)
51 data = response.json().get("data", {}).get("title", {})
52
53 reviews, trivia, blunders, parentsguide = [], [], [], []
54
55 # Reviews
56 try:
57 count = 1
58 for edge in sorted(
59 data["reviews"]["edges"],
60 key=lambda k: k["node"]["submissionDate"],
61 reverse=True,
62 ):
63 try:
64 content = html.unescape(
65 _clean(edge["node"]["text"]["originalText"]["plaidHtml"])
66 )
67 if not content:
68 continue
69 spoiler = edge["node"].get("spoiler", False)
70 rating = edge["node"].get("authorRating")
71 rating_str = str(rating) if rating is not None else "-"
72 title = edge["node"].get("summary", {}).get("originalText", "-----")
73 date = edge["node"].get("submissionDate", "-----")
74 review_text = (
75 f"[B]{count:02d}. [I]{rating_str}/10 - {date} - {title}[/I][/B]"
76 f"[CR][CR]{content}"
77 )
78 if spoiler:
79 review_text = (
80 "[B][COLOR red][CONTAINS SPOILERS][/COLOR][CR][/B]" + review_text
81 )
82 count += 1
83 reviews.append(review_text)
84 except Exception:
85 pass
86 except Exception:
87 pass
88
89 # Trivia
90 try:
91 count = 1
92 for edge in sorted(
93 data["trivia"]["edges"],
94 key=lambda k: k["node"]["interestScore"]["usersVoted"],
95 reverse=True,
96 ):
97 try:
98 content = html.unescape(
99 _clean(edge["node"]["displayableArticle"]["body"]["plaidHtml"])
100 )
101 trivia.append(f"[B]TRIVIA {count:02d}.[/B][CR][CR]{content}")
102 count += 1
103 except Exception:
104 pass
105 except Exception:
106 pass
107
108 # Goofs / Blunders
109 try:
110 count = 1
111 for edge in sorted(
112 data["goofs"]["edges"],
113 key=lambda k: k["node"]["interestScore"]["usersVoted"],
114 reverse=True,
115 ):
116 try:
117 content = html.unescape(
118 _clean(edge["node"]["displayableArticle"]["body"]["plaidHtml"])
119 )
120 blunders.append(f"[B]BLUNDERS {count:02d}.[/B][CR][CR]{content}")
121 count += 1
122 except Exception:
123 pass
124 except Exception:
125 pass
126
127 # Parents Guide
128 try:
129 title_converter = {
130 "nudity": "Sex & Nudity",
131 "violence": "Violence & Gore",
132 "profanity": "Profanity",
133 "alcohol": "Alcohol, Drugs & Smoking",
134 "frightening": "Frightening & Intense Scenes",
135 }
136 for category in data["parentsGuide"]["categories"]:
137 try:
138 cat_id = category["category"]["id"].lower()
139 title = title_converter.get(cat_id, cat_id.capitalize())
140 ranking = category["severity"]["id"].replace("Votes", "")
141 try:
142 listings = [
143 html.unescape(_clean(x["node"]["text"]["plaidHtml"]))
144 for x in category["guideItems"]["edges"]
145 ]
146 content = "\n\n".join([f"{c:02d}. {i}" for c, i in enumerate(listings, 1)])
147 except Exception:
148 listings = []
149 content = ""
150 total_count = len(listings)
151 parentsguide.append(
152 {
153 "title": title,
154 "ranking": ranking,
155 "content": content,
156 "total_count": total_count,
157 }
158 )
159 except Exception:
160 pass
161 except Exception:
162 pass
163
164 result = {
165 "reviews": reviews,
166 "trivia": trivia,
167 "blunders": blunders,
168 "parentsguide": parentsguide,
169 }
170 cache.set(cache_key, result, timedelta(hours=168))
171 return result
62172
63173 except Exception as e:
64 kodilog(f"Error scraping IMDb trivia for {imdb_id}: {str(e)}")
65 return []
174 kodilog(f"Error fetching IMDb GraphQL extras for {imdb_id}: {e}")
175 return {"reviews": [], "trivia": [], "blunders": [], "parentsguide": []}
66176
67177
68def get_imdb_goofs(imdb_id):
178def get_imdb_more_like_this(imdb_id):
69179 """
70 Scrapes the IMDb goofs page for a given IMDb ID.
71 Returns a list of goof strings.
180 Fetch "More Like This" titles from IMDb via GraphQL.
181
182 Returns a list of IMDb IDs (tt...).
72183 """
73184 if not imdb_id:
74 return []
75185
76 cache_key = f"imdb_goofs_regex_v1_{imdb_id}"
77 cached_data = cache.get(cache_key)
78 if cached_data is not None:
79 return cached_data
186 return []
80187
81 url = f"https://www.imdb.com/title/{imdb_id}/goofs/"
82 headers = {
83 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
84 "Accept-Language": "en-US,en;q=0.5",
85 }
188 cache_key = f"imdb_more_like_this_{imdb_id}"
189 cached = cache.get(cache_key)
190 if cached is not None:
191 return cached
86192
87193 try:
88 response = requests.get(url, headers=headers, timeout=10)
89 if response.status_code != 200:
90 return []
91
92 matches = re.findall(
93 r"class=\"ipc-html-content-inner-div\"[^>]*>(.*?)</div>",
94 response.text,
95 re.DOTALL,
96 )
97
98 goofs_list = []
99 for m in matches:
100 text = clean_html(m)
101 if text:
102 goofs_list.append(text)
103
104 kodilog(f"Scraped {len(goofs_list)} IMDb goofs (regex) for {imdb_id}")
105 cache.set(cache_key, goofs_list, timedelta(hours=24))
106 return goofs_list
107
194 payload = {
195 "query": "query($id:ID!){title(id:$id){moreLikeThisTitles(first:12){edges{node{id}}}}}",
196 "variables": {"id": imdb_id},
197 }
198 response = requests.post(GQL_URL, json=payload, headers=GQL_HEADERS, timeout=10)
199 edges = response.json()["data"]["title"]["moreLikeThisTitles"]["edges"]
200 result = [edge["node"]["id"] for edge in edges if edge["node"]["id"].startswith("tt")]
201 # Deduplicate
202 seen = set()
203 result = [x for x in result if not (x in seen or seen.add(x))]
204 cache.set(cache_key, result, timedelta(hours=168))
205 return result
108206 except Exception as e:
109 kodilog(f"Error scraping IMDb goofs for {imdb_id}: {str(e)}")
207 kodilog(f"Error fetching IMDb More Like This for {imdb_id}: {e}")
110208 return []
111209
112210
113def get_imdb_parentsguide(imdb_id):
114 """
115 Scrapes the IMDb parents guide page for a given IMDb ID.
116 Returns a list of parental guide strings grouped by category.
117 """
118 if not imdb_id:
119 return []
120
121 cache_key = f"imdb_parentsguide_regex_v1_{imdb_id}"
122 cached_data = cache.get(cache_key)
123 if cached_data is not None:
124 return cached_data
211# Keep legacy functions for backward compatibility until fully migrated
212def get_imdb_trivia(imdb_id):
213 """Legacy wrapper. Use get_imdb_extras() for new code."""
214 return get_imdb_extras(imdb_id).get("trivia", [])
125215
126 url = f"https://www.imdb.com/title/{imdb_id}/parentalguide/"
127 headers = {
128 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
129 "Accept-Language": "en-US,en;q=0.5",
130 }
131216
132 try:
133 response = requests.get(url, headers=headers, timeout=10)
134 if response.status_code != 200:
135 return []
136
137 # Map internal IDs to user-friendly titles
138 categories = {
139 "nudity": "Sex & Nudity",
140 "violence": "Violence & Gore",
141 "profanity": "Profanity",
142 "alcohol": "Alcohol, Drugs & Smoking",
143 "frightening": "Frightening & Intense Scenes",
144 }
217def get_imdb_goofs(imdb_id):
218 """Legacy wrapper. Use get_imdb_extras() for new code."""
219 return get_imdb_extras(imdb_id).get("blunders", [])
145220
146 guide_list = []
147 text = response.text
148
149 # Split page by category IDs
150 segments = re.split(
151 r"id=\"(nudity|violence|profanity|alcohol|frightening)\"", text
152 )
153
154 # segments[0] is header
155 # segments[1] is id, segments[2] is content, etc
156 for i in range(1, len(segments), 2):
157 cat_id = segments[i]
158 content = segments[i + 1]
159 cat_name = categories.get(cat_id, cat_id.capitalize())
160
161 # Extract severity
162 severity = ""
163 sev_match = re.search(
164 r"class=\"ipc-signpost__text\"[^>]*>(.*?)</div>", content, re.DOTALL
165 )
166 if sev_match:
167 severity = clean_html(sev_match.group(1))
168
169 header = f"[B]{cat_name}[/B]"
170 if severity:
171 header += f" — {severity}"
172
173 # Extract individual items in this section
174 items = re.findall(
175 r"class=\"ipc-html-content-inner-div\"[^>]*>(.*?)</div>",
176 content,
177 re.DOTALL,
178 )
179
180 if items:
181 for item in items:
182 item_text = clean_html(item)
183 if item_text:
184 guide_list.append(f"{header}\n{item_text}")
185 else:
186 guide_list.append(header)
187
188 kodilog(
189 f"Scraped {len(guide_list)} IMDb parental guide items (regex) for {imdb_id}"
190 )
191 cache.set(cache_key, guide_list, timedelta(hours=24))
192 return guide_list
193221
194 except Exception as e:
195 kodilog(f"Error scraping IMDb parental guide for {imdb_id}: {str(e)}")
196 return []
222def get_imdb_parentsguide(imdb_id):
223 """Legacy wrapper. Use get_imdb_extras() for new code."""
224 extras = get_imdb_extras(imdb_id)
225
226 guide = extras.get("parentsguide", [])
227 # Convert to legacy text format for compatibility
228 result = []
229 for item in guide:
230 header = f"[B]{item['title']}[/B]"
231 if item.get("ranking"):
232 header += f" — {item['ranking']}"
233 if item.get("content"):
234 result.append(f"{header}\n{item['content']}")
235 else:
236 result.append(header)
237 return result
238
239
240IMDB_EXTRAS_QUERY = """\
241query {
242 title(id: "%s") {
243 id
244 titleText {
245 text
246 }
247 trivia(first: 20) {
248 edges {
249 node {
250 displayableArticle {
251 body {
252 plaidHtml
253 }
254 }
255 interestScore {
256 usersVoted
257 }
258 }
259 }
260 }
261 goofs(first: 20) {
262 edges {
263 node {
264 displayableArticle {
265 body {
266 plaidHtml
267 }
268 }
269 interestScore {
270 usersVoted
271 }
272 }
273 }
274 }
275 reviews(first: 50) {
276 edges {
277 node {
278 spoiler
279 author {
280 nickName
281 }
282 authorRating
283 summary {
284 originalText
285 }
286 text {
287 originalText {
288 plaidHtml
289 }
290 }
291 submissionDate
292 }
293 }
294 }
295 parentsGuide {
296 categories {
297 category {
298 id
299 }
300 guideItems(first: 10) {
301 edges {
302 node {
303 isSpoiler
304 text {
305 plaidHtml
306 }
307 }
308 }
309 }
310 severity {
311 id
312 votedFor
313 }
314 }
315 }
316 }
317}"""
plugin.video.jacktook/lib/api/jacktorr/jacktorr.py +84 -52
……@@ -1,90 +1,124 @@
11from json import dumps
2from urllib.parse import quote
3
24import requests
35from requests.auth import HTTPBasicAuth
4from urllib.parse import quote
6
57from lib.utils.kodi.utils import notification
68
79
8class TorrServer(object):
10class TorrServer:
911 def __init__(self, host, port, username, password, ssl_enabled=False, session=None):
10 self._base_url = "{}://{}:{}".format(
11 "https" if ssl_enabled else "http", host, port
12 )
12 self._base_url = "{}://{}:{}".format("https" if ssl_enabled else "http", host, port)
1313 self._username = username
1414 self._password = password
1515 self._auth = HTTPBasicAuth(self._username, self._password)
1616 self._session = session or requests
1717
18 @staticmethod
19 def _normalize_json_response(data):
20 """Normalize TorrServer responses that may return a list instead of a dict.
21
22 Some TorrServer versions wrap single-object responses in a list.
23 This helper unwraps the first element so callers can always
24 access keys like ["hash"] without TypeError.
25 """
26 if isinstance(data, list):
27 if not data:
28 raise TorrServerError("TorrServer returned empty list")
29 return data[0]
30 return data
31
32 def _extract_hash(self, response):
33 """Parse a TorrServer response and extract the torrent hash.
34
35 Handles both dict and list response formats defensively.
36 """
37 result = self._normalize_json_response(response.json())
38 return result["hash"]
39
1840 @property
1941 def torr_version(self):
20 """tests server status"""
42 """Tests server status"""
2143 return self._get("/echo").content
2244
2345 def add_magnet(self, magnet, title="", poster="", data=""):
24 return self._post(
25 "/torrents",
26 data=dumps(
27 {
28 "action": "add",
29 "link": magnet,
30 "title": title,
31 "poster": poster,
32 "data": data,
33 "save_to_db": True,
34 }
35 ),
36 ).json()["hash"]
46 return self._extract_hash(
47 self._post(
48 "/torrents",
49 data=dumps(
50 {
51 "action": "add",
52 "link": magnet,
53 "title": title,
54 "poster": poster,
55 "data": data,
56 "save_to_db": True,
57 }
58 ),
59 )
60 )
3761
3862 def add_torrent(self, path, title="", poster="", data=""):
3963 with open(path, "rb") as file:
40 return self._post(
64 return self._extract_hash(
65 self._post(
66 "/torrent/upload",
67 files={"file": file},
68 data={
69 "save": "true",
70 "title": title,
71 "poster": poster,
72 "data": data,
73 },
74 )
75 )
76
77 def add_torrent_obj(self, obj, title="", poster="", data=""):
78 return self._extract_hash(
79 self._post(
4180 "/torrent/upload",
42 files={"file": file},
81 files={"file": obj},
4382 data={
4483 "save": "true",
4584 "title": title,
4685 "poster": poster,
4786 "data": data,
4887 },
49 ).json()["hash"]
50
51 def add_torrent_obj(self, obj, title="", poster="", data=""):
52 return self._post(
53 "/torrent/upload",
54 files={"file": obj},
55 data={
56 "save": "true",
57 "title": title,
58 "poster": poster,
59 "data": data,
60 },
61 ).json()["hash"]
88 )
89 )
6290
6391 def torrents(self):
64 """read info about all torrents (doesn't fill file_stats info)"""
92 """Read info about all torrents (doesn't fill file_stats info)
93
94 Returns the raw list from TorrServer. Unlike single-object endpoints,
95 /torrents list returns a proper JSON array — pass it through as-is.
96 """
6597 return self._post("/torrents", data=dumps({"action": "list"})).json()
6698
6799 def get_torrent_info_by_hash(self, hash):
68 """not extended info"""
69 return self._post(
70 "/torrents", data=dumps({"action": "get", "hash": hash})
71 ).json()
100 """Not extended info"""
101 return self._normalize_json_response(
102 self._post("/torrents", data=dumps({"action": "get", "hash": hash})).json()
103 )
72104
73105 def get_torrent_info(self, link):
74 """read extended info of one torrent"""
75 return self._get("/stream", params={"link": link, "stat": "true"}).json()
106 """Read extended info of one torrent"""
107 return self._normalize_json_response(
108 self._get("/stream", params={"link": link, "stat": "true"}).json()
109 )
76110
77111 def get_torrent_file_info(self, link, file_index=1):
78 """read extended info of file of torrent"""
79 return self._get(
80 "/stream", params={"link": link, "index": file_index, "stat": "true"}
81 ).json()
112 """Read extended info of file of torrent"""
113 return self._normalize_json_response(
114 self._get("/stream", params={"link": link, "index": file_index, "stat": "true"}).json()
115 )
82116
83117 def drop_torrent(self, hash):
84118 return self._post("/torrents", data=dumps({"action": "drop", "hash": hash}))
85119
86120 def remove_torrent(self, info_hash, save_to_db=True):
87 """delete torrent from TorrServer"""
121 """Delete torrent from TorrServer"""
88122 return self._post(
89123 "/torrents",
90124 data=dumps({"action": "rem", "hash": info_hash, "save_to_db": save_to_db}),
……@@ -104,7 +138,7 @@ class TorrServer(object):
104138 )
105139
106140 def preload_torrent(self, link, file_id=1, title=""):
107 """preload torrent"""
141 """Preload torrent"""
108142 return self._get(
109143 "/stream",
110144 params={
……@@ -117,7 +151,7 @@ class TorrServer(object):
117151 )
118152
119153 def get_stream_url(self, link, path, file_id):
120 """returns the stream url"""
154 """Returns the stream url"""
121155 return f"{self._base_url}/stream/{quote(path)}?link={link}&index={file_id}&play"
122156
123157 def get_settings(self):
……@@ -138,11 +172,9 @@ class TorrServer(object):
138172
139173 def _request(self, method, url, **kwargs):
140174 try:
141 return self._session.request(
142 method, self._base_url + url, auth=self._auth, **kwargs
143 )
175 return self._session.request(method, self._base_url + url, auth=self._auth, **kwargs)
144176 except Exception as e:
145 raise TorrServerError(str(e))
177 raise TorrServerError(str(e)) from e
146178
147179
148180class TorrServerError(Exception):
plugin.video.jacktook/lib/api/mdblist/mdblist.py +23 -11
……@@ -1,5 +1,7 @@
1from typing import Any, Dict, List, Optional
2
13import requests
2from typing import List, Dict, Any, Optional
4
35from lib.utils.kodi.utils import get_setting
46
57
……@@ -18,9 +20,11 @@ class MDblistAPI:
1820 def search_lists(self, query: str) -> List[Dict[str, Any]]:
1921 """
2022 Searches lists on MDblist by query string.
23
2124 Returns a list of dictionaries representing each matching list.
2225 """
2326 if not query:
27
2428 raise ValueError("Query string must not be empty.")
2529 url = f"{self.BASE_URL}lists/search"
2630 params = {"query": query, "apikey": self.api_key}
……@@ -29,42 +33,48 @@ class MDblistAPI:
2933 response.raise_for_status()
3034 return response.json()
3135 except requests.RequestException as e:
32 raise MDblistAPIError(f"Failed to search lists: {e}")
36 raise MDblistAPIError(f"Failed to search lists: {e}") from e
3337
3438 def get_user_lists(self) -> List[Dict[str, Any]]:
3539 """
3640 Fetches user lists from MDblist API.
41
3742 Returns a list of dictionaries representing each user list.
3843 """
3944 url = f"{self.BASE_URL}lists/user"
45
4046 params = {"apikey": self.api_key}
4147 try:
4248 response = self.session.get(url, params=params)
4349 response.raise_for_status()
4450 return response.json()
4551 except requests.RequestException as e:
46 raise MDblistAPIError(f"Failed to fetch user lists: {e}")
52 raise MDblistAPIError(f"Failed to fetch user lists: {e}") from e
4753
4854 def get_top_lists(self) -> List[Dict[str, Any]]:
4955 """
5056 Fetches top lists from MDblist API.
57
5158 Returns a list of dictionaries representing each top list.
5259 """
5360 url = f"{self.BASE_URL}lists/top"
61
5462 params = {"apikey": self.api_key}
5563 try:
5664 response = self.session.get(url, params=params)
5765 response.raise_for_status()
5866 return response.json()
5967 except requests.RequestException as e:
60 raise MDblistAPIError(f"Failed to fetch top lists: {e}")
68 raise MDblistAPIError(f"Failed to fetch top lists: {e}") from e
6169
6270 def get_list_by_id(self, list_id: str) -> Optional[Dict[str, Any]]:
6371 """
6472 Fetches a single list by its ID.
73
6574 Returns a dictionary representing the list, or None if not found.
6675 """
6776 if not list_id:
77
6878 raise ValueError("List ID must not be empty.")
6979 url = f"{self.BASE_URL}lists/{list_id}"
7080 params = {"apikey": self.api_key}
……@@ -73,9 +83,9 @@ class MDblistAPI:
7383 response.raise_for_status()
7484 return response.json()
7585 except requests.HTTPError as e:
76 raise MDblistAPIError(f"Failed to fetch list by ID: {e}")
86 raise MDblistAPIError(f"Failed to fetch list by ID: {e}") from e
7787 except requests.RequestException as e:
78 raise MDblistAPIError(f"Failed to fetch list by ID: {e}")
88 raise MDblistAPIError(f"Failed to fetch list by ID: {e}") from e
7989
8090 def get_bulk_ratings(
8191 self,
……@@ -86,9 +96,11 @@ class MDblistAPI:
8696 ) -> Dict[str, Any]:
8797 """
8898 Fetches bulk ratings for a list of IDs from MDblist API.
99
89100 Returns a dictionary with ratings for each ID.
90101 """
91102 if not ids or not isinstance(ids, list):
103
92104 raise ValueError("IDs must be a non-empty list.")
93105 provider = provider or self.provider
94106 url = f"{self.BASE_URL}rating/{media_type}/{return_rating}"
……@@ -96,13 +108,11 @@ class MDblistAPI:
96108 payload = {"ids": ids, "provider": provider}
97109 headers = {"Content-Type": "application/json"}
98110 try:
99 response = self.session.post(
100 url, params=params, json=payload, headers=headers
101 )
111 response = self.session.post(url, params=params, json=payload, headers=headers)
102112 response.raise_for_status()
103113 return response.json()
104114 except requests.RequestException as e:
105 raise MDblistAPIError(f"Failed to fetch bulk ratings: {e}")
115 raise MDblistAPIError(f"Failed to fetch bulk ratings: {e}") from e
106116
107117 def get_list_items(
108118 self,
……@@ -121,9 +131,11 @@ class MDblistAPI:
121131 ) -> Dict[str, Any]:
122132 """
123133 Fetches items from a specified list with pagination and filters.
134
124135 Returns a dict with 'movies', 'shows', and pagination info.
125136 """
126137 if not list_id:
138
127139 raise ValueError("List ID must not be empty.")
128140 url = f"{self.BASE_URL}lists/{list_id}/items"
129141 params = {
……@@ -149,4 +161,4 @@ class MDblistAPI:
149161 response.raise_for_status()
150162 return response.json()
151163 except requests.RequestException as e:
152 raise MDblistAPIError(f"Failed to fetch list items: {e}")
164 raise MDblistAPIError(f"Failed to fetch list items: {e}") from e
plugin.video.jacktook/lib/api/plex/media_server.py +7 -8
……@@ -1,9 +1,9 @@
11import json
2import requests
3
42from http import HTTPStatus
3
4import requests
55from requests.exceptions import ConnectionError, Timeout
6from lib.api.plex.settings import settings
6
77from lib.api.plex.models.plex_models import (
88 PlexEpisodeMeta,
99 PlexLibrarySection,
……@@ -11,6 +11,7 @@ from lib.api.plex.models.plex_models import (
1111 PlexMediaType,
1212 PlexServer,
1313)
14from lib.api.plex.settings import settings
1415from lib.api.plex.utils import PlexUnauthorizedError
1516from lib.utils.kodi.utils import kodilog
1617
……@@ -24,9 +25,7 @@ def check_server_connection(url, access_token):
2425 },
2526 timeout=settings.plex_requests_timeout,
2627 )
27 if response.status_code != HTTPStatus.OK:
28 return False
29 return True
28 return response.status_code == HTTPStatus.OK
3029 except (Timeout, ConnectionError):
3130 return False
3231
……@@ -128,7 +127,7 @@ def imdb_to_plex_id(imdb_id, token, mode, media_type):
128127 url="https://metadata.provider.plex.tv/library/metadata/matches",
129128 params={
130129 "X-Plex-Token": token,
131 "type": 1 if (media_type == "movies" or mode =="movies") else 2,
130 "type": 1 if (media_type == "movies" or mode == "movies") else 2,
132131 "title": f"imdb-{imdb_id}",
133132 "guid": f"com.plexapp.agents.imdb://{imdb_id}?lang=en",
134133 },
……@@ -192,4 +191,4 @@ def get_json(url, params=None):
192191 kodilog("Received error from plex server")
193192 return json.loads(response.content)
194193 except TimeoutError:
195 kodilog("Plex server timeout error")
194 kodilog("Plex server timeout error")
plugin.video.jacktook/lib/api/plex/models/plex_models.py +26 -25
……@@ -2,6 +2,7 @@ import os
22from datetime import datetime
33from enum import Enum
44from urllib import parse
5
56from lib.utils.kodi_utils import get_setting
67from lib.utils.utils import Indexer
78
……@@ -48,27 +49,27 @@ class PlexMediaMeta:
4849 self.type = kwargs.get("type")
4950 self.title = kwargs.get("title")
5051 self.added_at = kwargs.get("addedAt")
51 self.rating_key = kwargs.get("ratingKey", None)
52 self.key = kwargs.get("key", None)
53 self.studio = kwargs.get("studio", None)
54 self.title_sort = kwargs.get("titleSort", None)
55 self.library_section_title = kwargs.get("librarySectionTitle", None)
56 self.library_sectionID = kwargs.get("librarySectionID", None)
57 self.library_section_key = kwargs.get("librarySectionKey", None)
58 self.content_rating = kwargs.get("contentRating", None)
52 self.rating_key = kwargs.get("ratingKey")
53 self.key = kwargs.get("key")
54 self.studio = kwargs.get("studio")
55 self.title_sort = kwargs.get("titleSort")
56 self.library_section_title = kwargs.get("librarySectionTitle")
57 self.library_sectionID = kwargs.get("librarySectionID")
58 self.library_section_key = kwargs.get("librarySectionKey")
59 self.content_rating = kwargs.get("contentRating")
5960 self.summary = kwargs.get("summary", "")
60 self.rating = kwargs.get("rating", None)
61 self.audience_rating = kwargs.get("audienceRating", None)
62 self.year = kwargs.get("year", None)
63 self.tagline = kwargs.get("tagline", None)
64 self.thumb = kwargs.get("thumb", None)
65 self.art = kwargs.get("art", None)
66 self.duration = kwargs.get("duration", None)
67 self.originally_available_at = kwargs.get("originallyAvailableAt", None)
68 self.updated_at = kwargs.get("updatedAt", None)
69 self.audience_rating_image = kwargs.get("audienceRatingImage", None)
70 self.has_premium_primary_extra = kwargs.get("hasPremiumPrimaryExtra", None)
71 self.rating_image = kwargs.get("ratingImage", None)
61 self.rating = kwargs.get("rating")
62 self.audience_rating = kwargs.get("audienceRating")
63 self.year = kwargs.get("year")
64 self.tagline = kwargs.get("tagline")
65 self.thumb = kwargs.get("thumb")
66 self.art = kwargs.get("art")
67 self.duration = kwargs.get("duration")
68 self.originally_available_at = kwargs.get("originallyAvailableAt")
69 self.updated_at = kwargs.get("updatedAt")
70 self.audience_rating_image = kwargs.get("audienceRatingImage")
71 self.has_premium_primary_extra = kwargs.get("hasPremiumPrimaryExtra")
72 self.rating_image = kwargs.get("ratingImage")
7273 self.media = kwargs.get("Media")
7374 self.genre = kwargs.get("Genre")
7475 self.country = kwargs.get("Country")
……@@ -98,10 +99,10 @@ class PlexMediaMeta:
9899
99100 description_template = (
100101 f"{filename}\n"
101 f'{"/".join(sorted(audio_languages))} '
102 f'({"/".join(sorted(subtitles_languages))})'
102 f"{'/'.join(sorted(audio_languages))} "
103 f"({'/'.join(sorted(subtitles_languages))})"
103104 )
104
105
105106 encoded_params = parse.urlencode(
106107 {
107108 "X-Plex-Token": get_setting("plex_server_token"),
……@@ -111,7 +112,7 @@ class PlexMediaMeta:
111112 {
112113 "title": f"Direct Play - {name} - {description_template}",
113114 "indexer": Indexer.PLEX,
114 "quality_description": f'Direct Play {media["videoResolution"]}',
115 "quality_description": f"Direct Play {media['videoResolution']}",
115116 "url": f"{get_setting('plex_streaming_url')}/{media['Part'][0]['key'][1:]}?{encoded_params}",
116117 }
117118 )
……@@ -133,7 +134,7 @@ class PlexMediaMeta:
133134 {
134135 "title": f"Transcode - {name} - {description_template}",
135136 "indexer": Indexer.PLEX,
136 "quality_description": f'Transcode {media["videoResolution"]} (original)',
137 "quality_description": f"Transcode {media['videoResolution']} (original)",
137138 "url": f"{get_setting('plex_streaming_url')}/video/:/transcode/universal/start.m3u8?{encoded_params}",
138139 }
139140 )
plugin.video.jacktook/lib/api/plex/plex.py +10 -11
……@@ -1,14 +1,16 @@
1from http import HTTPStatus
1import contextlib
22import json
3from http import HTTPStatus
34from time import sleep, time
5
46import requests
7import xbmcgui
58
6from lib.api.plex.settings import settings
79from lib.api.plex.models.plex_models import AuthPin, PlexUser
10from lib.api.plex.settings import settings
811from lib.api.plex.utils import HTTPException, PlexUnauthorizedError
912from lib.utils.kodi.utils import copy2clip, dialog_ok, kodilog, set_setting
1013
11import xbmcgui
1214
1315class PlexApi:
1416 def __init__(self) -> None:
……@@ -21,9 +23,9 @@ class PlexApi:
2123 def login(self):
2224 auth_pin = self.create_auth_pin()
2325 copy2clip(auth_pin.code)
24 content = "%s[CR]%s" % (
25 f"Navigate to: [B]https://www.plex.tv/link[/B]",
26 f"and enter the code: [COLOR seagreen][B]{auth_pin.code}[/B][/COLOR] "
26 content = "{}[CR]{}".format(
27 "Navigate to: [B]https://www.plex.tv/link[/B]",
28 f"and enter the code: [COLOR seagreen][B]{auth_pin.code}[/B][/COLOR] ",
2729 )
2830 progressDialog = xbmcgui.DialogProgress()
2931 progressDialog.create("Plex Auth")
……@@ -93,7 +95,7 @@ class PlexApi:
9395 raise HTTPException(
9496 status_code=504,
9597 detail="Plex server timeout error",
96 )
98 ) from None
9799
98100 def get_plex_user(self):
99101 response = self.client.get(
……@@ -114,14 +116,11 @@ class PlexApi:
114116 def logout(self):
115117 self.auth_token = ""
116118 set_setting("plex_token", "")
117 try:
119 with contextlib.suppress(BaseException):
118120 self.client.close()
119 except:
120 pass
121121 set_setting("plex_user", "")
122122 set_setting("plex_server_name", "")
123123 set_setting("plex_discovery_url", "")
124124 set_setting("plex_streaming_url", "")
125125 set_setting("plex_token", "")
126126 set_setting("plex_server_token", "")
127
plugin.video.jacktook/lib/api/plex/settings.py +3 -2
……@@ -1,8 +1,9 @@
11from uuid import uuid4
22
3class Settings():
3
4class Settings:
45 identifier: str = str(uuid4())
5 product_name: str = 'Jacktook'
6 product_name: str = "Jacktook"
67 plex_requests_timeout: int = 30
78 plex_matching_token: str
89
plugin.video.jacktook/lib/api/plex/utils.py +0 -4
……@@ -1,4 +1,3 @@
1
21class PlexUnauthorizedError(BaseException):
32 pass
43
……@@ -11,6 +10,3 @@ class HTTPException(BaseException):
1110 ):
1211 self.status_code = status_code
1312 self.detail = detail
14
15
16
plugin.video.jacktook/lib/api/stremio/addon_manager.py +10 -26
……@@ -1,5 +1,5 @@
11import json
2from typing import List, Optional, Any
2from typing import Any, List, Optional
33from urllib.parse import urlsplit, urlunsplit
44
55
……@@ -16,9 +16,7 @@ def normalize_transport_url(url: str) -> str:
1616 if path.endswith("/manifest.json"):
1717 path = path[: -len("/manifest.json")]
1818
19 return urlunsplit(
20 (parts.scheme.lower(), parts.netloc.lower(), path, parts.query, "")
21 )
19 return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path, parts.query, ""))
2220
2321
2422def build_addon_base_url(transport_url: str) -> str:
……@@ -35,12 +33,7 @@ def build_addon_instance_key(addon_like: Any) -> str:
3533 transport_url = addon_like.transport_url or ""
3634 elif isinstance(addon_like, dict):
3735 manifest = addon_like.get("manifest") or {}
38 addon_id = (
39 manifest.get("id")
40 or addon_like.get("id")
41 or addon_like.get("name")
42 or ""
43 )
36 addon_id = manifest.get("id") or addon_like.get("id") or addon_like.get("name") or ""
4437 transport_url = addon_like.get("transportUrl") or addon_like.get("transport_url") or ""
4538
4639 normalized_url = normalize_transport_url(transport_url)
……@@ -57,10 +50,7 @@ def build_addon_instance_label(addon_like: Any) -> str:
5750 else:
5851 manifest = addon_like.get("manifest") or {}
5952 name = (
60 manifest.get("name")
61 or manifest.get("id")
62 or addon_like.get("name")
63 or "Unknown Addon"
53 manifest.get("name") or manifest.get("id") or addon_like.get("name") or "Unknown Addon"
6454 )
6555 transport_url = addon_like.get("transportUrl") or addon_like.get("transport_url") or ""
6656 transport_name = addon_like.get("transportName") or addon_like.get("transport_name") or ""
……@@ -80,7 +70,7 @@ def build_addon_instance_label(addon_like: Any) -> str:
8070
8171
8272class Catalog:
83 def __init__(self, type: str, id: str, name: str, extra: List[dict] = None):
73 def __init__(self, type: str, id: str, name: str, extra: Optional[List[dict]] = None):
8474 self.type = type
8575 self.id = id
8676 self.name = name
……@@ -237,9 +227,7 @@ class AddonManager:
237227 )
238228 if isinstance(resource, str)
239229 else Resource(
240 name=(
241 resource["name"] if isinstance(resource, dict) else resource
242 ),
230 name=(resource["name"] if isinstance(resource, dict) else resource),
243231 types=resource.get("types", item["manifest"].get("types", [])),
244232 id_prefixes=resource.get(
245233 "idPrefixes", item["manifest"].get("idPrefixes", [])
……@@ -297,10 +285,9 @@ class AddonManager:
297285 if addon.manifest.id == "org.stremio.local":
298286 continue
299287 for resource in addon.manifest.resources:
300 if isinstance(resource, str):
301 if resource == resource_name:
302 result.append(addon)
303 break
288 if isinstance(resource, str) and resource == resource_name:
289 result.append(addon)
290 break
304291
305292 if resource.name == resource_name:
306293 result.append(addon)
……@@ -316,10 +303,7 @@ class AddonManager:
316303 continue
317304 for resource in addon.manifest.resources:
318305 if isinstance(resource, str):
319 if (
320 resource == resource_name
321 and id_prefix in addon.manifest.id_prefixes
322 ):
306 if resource == resource_name and id_prefix in addon.manifest.id_prefixes:
323307 result.append(addon)
324308 break
325309
plugin.video.jacktook/lib/api/stremio/api_client.py +7 -12
……@@ -1,9 +1,11 @@
11from json import JSONDecodeError
2from requests.exceptions import RequestException, Timeout, TooManyRedirects
2
33from requests import Session
4from requests.exceptions import RequestException, Timeout, TooManyRedirects
5
46from lib.utils.general.utils import USER_AGENT_HEADER
5from lib.utils.kodi.utils import kodilog
67from lib.utils.kodi.settings import get_int_setting
8from lib.utils.kodi.utils import kodilog
79
810
911class Stremio:
……@@ -17,9 +19,7 @@ class Stremio:
1719 if method == "GET":
1820 resp = self.session.get(url, timeout=get_int_setting("stremio_timeout"))
1921 elif method == "POST":
20 resp = self.session.post(
21 url, json=data, timeout=get_int_setting("stremio_timeout")
22 )
22 resp = self.session.post(url, json=data, timeout=get_int_setting("stremio_timeout"))
2323 else:
2424 raise ValueError(f"Unsupported HTTP method: {method}")
2525
……@@ -32,9 +32,7 @@ class Stremio:
3232 try:
3333 return resp.json()
3434 except JSONDecodeError:
35 kodilog(
36 f"Failed to decode JSON response for URL: {url}. Response: {resp.text}"
37 )
35 kodilog(f"Failed to decode JSON response for URL: {url}. Response: {resp.text}")
3836 raise
3937 except Timeout:
4038 kodilog(f"Request timed out for URL: {url}")
……@@ -54,7 +52,6 @@ class Stremio:
5452
5553 def login(self, email, password):
5654 """Login to Stremio account."""
57
5855 data = {
5956 "authKey": self.authKey,
6057 "email": email,
……@@ -71,9 +68,7 @@ class Stremio:
7168 res = self._post("https://api.strem.io/api/dataExport", data)
7269 exportId = res.get("result", {}).get("exportId", None)
7370
74 dataExport = self._get(
75 f"https://api.strem.io/data-export/{exportId}/export.json"
76 )
71 dataExport = self._get(f"https://api.strem.io/data-export/{exportId}/export.json")
7772 return dataExport
7873
7974 def get_my_addons(self):
plugin.video.jacktook/lib/api/stremio/models.py +31 -25
……@@ -1,5 +1,6 @@
1from typing import List, Optional, Any, Dict, Union
21from dataclasses import dataclass, field
2from typing import Any, Dict, List, Optional, Union
3
34
45@dataclass
56class MetaLink:
……@@ -8,13 +9,14 @@ class MetaLink:
89 url: str
910
1011 @classmethod
11 def from_dict(cls, data: Dict[str, Any]) -> 'MetaLink':
12 def from_dict(cls, data: Dict[str, Any]) -> "MetaLink":
1213 return cls(
1314 name=data.get("name", ""),
1415 category=data.get("category", ""),
15 url=data.get("url", "")
16 url=data.get("url", ""),
1617 )
1718
19
1820def extract_proxy_headers(raw: Any) -> Dict[str, str]:
1921 """Normalise a stream's proxyHeaders into flat request headers.
2022
……@@ -41,7 +43,7 @@ class StreamBehaviorHints:
4143 filename: Optional[str] = None
4244
4345 @classmethod
44 def from_dict(cls, data: Dict[str, Any]) -> 'StreamBehaviorHints':
46 def from_dict(cls, data: Dict[str, Any]) -> "StreamBehaviorHints":
4547 return cls(
4648 countryWhitelist=data.get("countryWhitelist", []),
4749 notWebReady=data.get("notWebReady", False),
……@@ -49,9 +51,10 @@ class StreamBehaviorHints:
4951 proxyHeaders=data.get("proxyHeaders", {}),
5052 videoHash=data.get("videoHash"),
5153 videoSize=data.get("videoSize"),
52 filename=data.get("filename")
54 filename=data.get("filename"),
5355 )
5456
57
5558@dataclass
5659class Stream:
5760 url: Optional[str] = None
……@@ -63,7 +66,7 @@ class Stream:
6366 title: Optional[str] = None
6467 description: Optional[str] = None
6568 behaviorHints: Optional[StreamBehaviorHints] = None
66
69
6770 # Custom/Extended fields
6871 fileMustInclude: Optional[List[str]] = None
6972 nzbUrl: Optional[str] = None
……@@ -73,11 +76,11 @@ class Stream:
7376 sevenZipUrls: List[str] = field(default_factory=list)
7477 tgzUrls: List[str] = field(default_factory=list)
7578 tarUrls: List[str] = field(default_factory=list)
76
79
7780 meta: Dict[str, Any] = field(default_factory=dict)
7881
7982 @classmethod
80 def from_dict(cls, data: Dict[str, Any]) -> 'Stream':
83 def from_dict(cls, data: Dict[str, Any]) -> "Stream":
8184 hints = data.get("behaviorHints")
8285 return cls(
8386 url=data.get("url"),
……@@ -97,24 +100,24 @@ class Stream:
97100 sevenZipUrls=data.get("7zipUrls", []),
98101 tgzUrls=data.get("tgzUrls", []),
99102 tarUrls=data.get("tarUrls", []),
100 meta=data.get("meta", {})
103 meta=data.get("meta", {}),
101104 )
102105
103106 def get_parsed_title(self) -> str:
104107 filename = self.behaviorHints.filename if self.behaviorHints else None
105108 title = filename or self.description or self.title
106109 return title.splitlines()[0] if title else ""
107
110
108111 def get_sub_indexer(self, addon: Any) -> str:
109112 if not self.name:
110113 return ""
111
114
112115 name_parts = self.name.split()
113116 if len(name_parts) > 1:
114117 return name_parts[1]
115
116 return ""
117
118
119 return ""
120
118121 def get_proxy_headers(self) -> Dict[str, str]:
119122 """Request headers this stream needs at playback time."""
120123 return extract_proxy_headers(
……@@ -124,10 +127,11 @@ class Stream:
124127 def get_parsed_size(self) -> int:
125128 size = self.behaviorHints.videoSize if self.behaviorHints else None
126129 return size or self.meta.get("size") or 0
127
128 def get_provider(self) -> str:
130
131 def get_provider(self) -> str:
129132 return self.meta.get("indexer") or ""
130133
134
131135@dataclass
132136class Video:
133137 id: str
……@@ -144,7 +148,7 @@ class Video:
144148 imdbEpisode: Optional[Union[int, str]] = None
145149
146150 @classmethod
147 def from_dict(cls, data: Dict[str, Any]) -> 'Video':
151 def from_dict(cls, data: Dict[str, Any]) -> "Video":
148152 return cls(
149153 id=data.get("id", ""),
150154 title=data.get("title", ""),
……@@ -157,17 +161,19 @@ class Video:
157161 trailers=[Stream.from_dict(t) for t in data.get("trailers", [])],
158162 overview=data.get("overview"),
159163 imdbSeason=data.get("imdbSeason"),
160 imdbEpisode=data.get("imdbEpisode")
164 imdbEpisode=data.get("imdbEpisode"),
161165 )
162166
167
163168@dataclass
164169class MetaBehaviorHints:
165170 defaultVideoId: Optional[str] = None
166
171
167172 @classmethod
168 def from_dict(cls, data: Dict[str, Any]) -> 'MetaBehaviorHints':
173 def from_dict(cls, data: Dict[str, Any]) -> "MetaBehaviorHints":
169174 return cls(defaultVideoId=data.get("defaultVideoId"))
170175
176
171177@dataclass
172178class Meta:
173179 id: str
……@@ -183,7 +189,7 @@ class Meta:
183189 cast: List[str] = field(default_factory=list)
184190 imdbRating: Optional[str] = None
185191 released: Optional[str] = None
186 trailers: List[Dict[str, str]] = field(default_factory=list) # simplified for now
192 trailers: List[Dict[str, str]] = field(default_factory=list) # simplified for now
187193 links: List[MetaLink] = field(default_factory=list)
188194 videos: List[Video] = field(default_factory=list)
189195 runtime: Optional[str] = None
……@@ -197,7 +203,7 @@ class Meta:
197203 genres: List[str] = field(default_factory=list)
198204
199205 @classmethod
200 def from_dict(cls, data: Dict[str, Any]) -> 'Meta':
206 def from_dict(cls, data: Dict[str, Any]) -> "Meta":
201207 hints = data.get("behaviorHints")
202208 return cls(
203209 id=data.get("id", ""),
……@@ -224,7 +230,7 @@ class Meta:
224230 behaviorHints=MetaBehaviorHints.from_dict(hints) if hints else None,
225231 moviedb_id=data.get("moviedb_id"),
226232 imdb_id=data.get("imdb_id"),
227 genres=data.get("genres", [])
233 genres=data.get("genres", []),
228234 )
229235
230236
……@@ -249,7 +255,7 @@ class MetaPreview:
249255 streams: List[Stream] = field(default_factory=list)
250256
251257 @classmethod
252 def from_dict(cls, data: Dict[str, Any]) -> 'MetaPreview':
258 def from_dict(cls, data: Dict[str, Any]) -> "MetaPreview":
253259 return cls(
254260 id=data.get("id", ""),
255261 type=data.get("type", ""),
……@@ -267,5 +273,5 @@ class MetaPreview:
267273 trailers=data.get("trailers", []),
268274 moviedb_id=data.get("moviedb_id"),
269275 imdb_id=data.get("imdb_id"),
270 streams=[Stream.from_dict(s) for s in data.get("streams", [])]
276 streams=[Stream.from_dict(s) for s in data.get("streams", [])],
271277 )
plugin.video.jacktook/lib/api/tmdbv3api/as_obj.py +2 -8
……@@ -1,6 +1,4 @@
1# encoding: utf-8
21import sys
3from .exceptions import TMDbException
42
53
64class AsObj:
……@@ -12,9 +10,7 @@ class AsObj:
1210 self._obj_list = []
1311 self._list_only = False
1412 if isinstance(self._json, list):
15 self._obj_list = [
16 AsObj(o) if isinstance(o, (dict, list)) else o for o in self._json
17 ]
13 self._obj_list = [AsObj(o) if isinstance(o, (dict, list)) else o for o in self._json]
1814 self._list_only = True
1915 elif dict_key:
2016 self._obj_list = [
……@@ -30,9 +26,7 @@ class AsObj:
3026 for key, value in self._json.items():
3127 if isinstance(value, (dict, list)):
3228 if self._key and key == self._key:
33 final = AsObj(
34 value, dict_key=isinstance(value, dict), dict_key_name=key
35 )
29 final = AsObj(value, dict_key=isinstance(value, dict), dict_key_name=key)
3630 self._obj_list = final
3731 else:
3832 final = AsObj(value)
plugin.video.jacktook/lib/api/tmdbv3api/objs/account.py +10 -15
……@@ -1,9 +1,9 @@
11import os
2
23from lib.api.tmdbv3api.exceptions import TMDbException
34from lib.api.tmdbv3api.tmdb import TMDb
45
56
6
77class Account(TMDb):
88 _urls = {
99 "details": "/account",
……@@ -31,8 +31,7 @@ class Account(TMDb):
3131 :return:
3232 """
3333 return self._request_obj(
34 self._urls["details"],
35 params="session_id=%s" % self.session_id
34 self._urls["details"], params=f"session_id={self.session_id}"
3635 )
3736
3837 def created_lists(self, page=1):
……@@ -43,19 +42,15 @@ class Account(TMDb):
4342 """
4443 return self._request_obj(
4544 self._urls["created_lists"] % self.account_id,
46 params="session_id=%s&page=%s" % (self.session_id, page),
47 key="results"
45 params=f"session_id={self.session_id}&page={page}",
46 key="results",
4847 )
4948
5049 def _get_list(self, url, asc_sort=True, page=1):
51 params = "session_id=%s&page=%s" % (self.session_id, page)
50 params = f"session_id={self.session_id}&page={page}"
5251 if asc_sort is False:
5352 params += "&sort_by=created_at.desc"
54 return self._request_obj(
55 self._urls[url] % self.account_id,
56 params=params,
57 key="results"
58 )
53 return self._request_obj(self._urls[url] % self.account_id, params=params, key="results")
5954
6055 def favorite_movies(self, asc_sort=True, page=1):
6156 """
……@@ -86,13 +81,13 @@ class Account(TMDb):
8681 raise TMDbException("Media Type should be tv or movie.")
8782 self._request_obj(
8883 self._urls["favorite"] % self.account_id,
89 params="session_id=%s" % self.session_id,
84 params=f"session_id={self.session_id}",
9085 method="POST",
9186 json={
9287 "media_type": media_type,
9388 "media_id": media_id,
9489 "favorite": favorite,
95 }
90 },
9691 )
9792
9893 def unmark_as_favorite(self, media_id, media_type):
……@@ -159,13 +154,13 @@ class Account(TMDb):
159154 raise TMDbException("Media Type should be tv or movie.")
160155 self._request_obj(
161156 self._urls["watchlist"] % self.account_id,
162 "session_id=%s" % self.session_id,
157 f"session_id={self.session_id}",
163158 method="POST",
164159 json={
165160 "media_type": media_type,
166161 "media_id": media_id,
167162 "watchlist": watchlist,
168 }
163 },
169164 )
170165
171166 def remove_from_watchlist(self, media_id, media_type):
plugin.video.jacktook/lib/api/tmdbv3api/objs/anime.py +24 -62
……@@ -14,100 +14,67 @@ class TmdbAnime(TMDb):
1414
1515 def cartoons_popular(self, mode, page_no):
1616 return self._request_obj(
17 (
18 self._urls["discover_tv"]
19 if mode == "tv"
20 else self._urls["discover_movie"]
21 ),
22 params="with_keywords=6513-cartoon&page=%s" % page_no,
17 (self._urls["discover_tv"] if mode == "tv" else self._urls["discover_movie"]),
18 params=f"with_keywords=6513-cartoon&page={page_no}",
2319 )
2420
2521 def animation_popular(self, mode, page_no):
2622 return self._request_obj(
27 (
28 self._urls["discover_tv"]
29 if mode == "tv"
30 else self._urls["discover_movie"]
31 ),
32 params="with_keywords=297442&page=%s" % page_no,
23 (self._urls["discover_tv"] if mode == "tv" else self._urls["discover_movie"]),
24 params=f"with_keywords=297442&page={page_no}",
3325 )
3426
3527 def anime_popular(self, mode, page_no):
3628 return self._request_obj(
37 (
38 self._urls["discover_tv"]
39 if mode == "tv"
40 else self._urls["discover_movie"]
41 ),
42 params="with_keywords=210024&page=%s" % page_no,
29 (self._urls["discover_tv"] if mode == "tv" else self._urls["discover_movie"]),
30 params=f"with_keywords=210024&page={page_no}",
4331 )
4432
4533 def anime_popular_recent(self, mode, page_no):
4634 return self._request_obj(
47 (
48 self._urls["discover_tv"]
49 if mode == "tv"
50 else self._urls["discover_movie"]
51 ),
35 (self._urls["discover_tv"] if mode == "tv" else self._urls["discover_movie"]),
5236 params=(
53 "with_keywords=210024&sort_by=first_air_date.desc&include_null_first_air_dates=false&first_air_date_year=%s&page=%s"
54 % (years_tvshows[0]["id"], page_no)
37 "with_keywords=210024&sort_by=first_air_date.desc&include_null_first_air_dates=false&first_air_date_year={}&page={}".format(
38 years_tvshows[0]["id"], page_no
39 )
5540 ),
5641 )
5742
5843 def anime_year(self, params):
59 year_param = (
60 "first_air_date_year" if params["mode"] == "tv" else "primary_release_year"
61 )
44 year_param = "first_air_date_year" if params["mode"] == "tv" else "primary_release_year"
6245 return self._request_obj(
63 (
64 self._urls["discover_tv"]
65 if params["mode"] == "tv"
66 else self._urls["discover_movie"]
46 (self._urls["discover_tv"] if params["mode"] == "tv" else self._urls["discover_movie"]),
47 params="with_keywords=210024&include_null_first_air_dates=false&{}={}&page={}".format(
48 year_param, params["year"], params["page"]
6749 ),
68 params="with_keywords=210024&include_null_first_air_dates=false&%s=%s&page=%s"
69 % (year_param, params["year"], params["page"]),
7050 )
7151
7252 def anime_genres(self, params):
7353 return self._request_obj(
74 (
75 self._urls["discover_tv"]
76 if params["mode"] == "tv"
77 else self._urls["discover_movie"]
54 (self._urls["discover_tv"] if params["mode"] == "tv" else self._urls["discover_movie"]),
55 params="&with_keywords=210024&with_genres={}&include_null_first_air_dates=false&first_air_date.lte={}&page={}".format(
56 params["genre_id"], get_current_date(), params["page"]
7857 ),
79 params="&with_keywords=210024&with_genres=%s&include_null_first_air_dates=false&first_air_date.lte=%s&page=%s"
80 % (params["genre_id"], get_current_date(), params["page"]),
8158 )
8259
8360 def anime_on_the_air(self, mode, page_no):
8461 current_date, future_date = get_dates(7, reverse=False)
8562 return self._request_obj(
86 (
87 self._urls["discover_tv"]
88 if mode == "tv"
89 else self._urls["discover_movie"]
90 ),
91 params="with_keywords=210024&air_date.gte=%s&air_date.lte=%s&page=%s"
92 % (current_date, future_date, page_no),
63 (self._urls["discover_tv"] if mode == "tv" else self._urls["discover_movie"]),
64 params=f"with_keywords=210024&air_date.gte={current_date}&air_date.lte={future_date}&page={page_no}",
9365 )
9466
9567 def anime_top_rated(self, mode, page_no):
9668 return self._request_obj(
97 (
98 self._urls["discover_tv"]
99 if mode == "tv"
100 else self._urls["discover_movie"]
101 ),
102 params="with_keywords=210024&sort_by=vote_average.desc&vote_count.gte=100&page=%s"
103 % page_no,
69 (self._urls["discover_tv"] if mode == "tv" else self._urls["discover_movie"]),
70 params=f"with_keywords=210024&sort_by=vote_average.desc&vote_count.gte=100&page={page_no}",
10471 )
10572
10673 def anime_search(self, query, mode, page_no, adult=False):
107 params = "query=%s&page=%s" % (query, page_no)
74 params = f"query={query}&page={page_no}"
10875
10976 if adult is not None:
110 params += "&include_adult=%s" % "true" if adult else "false"
77 params += "&include_adult={}".format("true") if adult else "false"
11178
11279 return self._request_obj(
11380 self._urls["search_tv"] if mode == "tv" else self._urls["search_movie"],
……@@ -117,10 +84,5 @@ class TmdbAnime(TMDb):
11784
11885 def tmdb_keywords(self, mode, tmdb_id):
11986 return self._request_obj(
120 (
121 self._urls["tv_keywords"]
122 if mode == "tv"
123 else self._urls["movie_keywords"]
124 )
125 % tmdb_id,
87 (self._urls["tv_keywords"] if mode == "tv" else self._urls["movie_keywords"]) % tmdb_id,
12688 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/auth.py +4 -3
……@@ -1,5 +1,6 @@
11from lib.api.tmdbv3api.tmdb import TMDb
22
3
34class Authentication(TMDb):
45 _urls = {
56 "create_request_token": "/authentication/token/new",
……@@ -32,7 +33,7 @@ class Authentication(TMDb):
3233 response = self._request_obj(
3334 self._urls["create_session"],
3435 method="POST",
35 json={"request_token": self.request_token}
36 json={"request_token": self.request_token},
3637 )
3738 self.session_id = response.session_id
3839
……@@ -47,7 +48,7 @@ class Authentication(TMDb):
4748 "username": self.username,
4849 "password": self.password,
4950 "request_token": self.request_token,
50 }
51 },
5152 )
5253
5354 def delete_session(self):
……@@ -58,6 +59,6 @@ class Authentication(TMDb):
5859 self._request_obj(
5960 self._urls["delete_session"],
6061 method="DELETE",
61 json={"session_id": self.session_id}
62 json={"session_id": self.session_id},
6263 )
6364 self.session_id = ""
plugin.video.jacktook/lib/api/tmdbv3api/objs/certification.py +1 -0
……@@ -1,5 +1,6 @@
11from lib.api.tmdbv3api.tmdb import TMDb
22
3
34class Certification(TMDb):
45 _urls = {
56 "movie_list": "/certification/movie/list",
plugin.video.jacktook/lib/api/tmdbv3api/objs/change.py +5 -9
……@@ -5,20 +5,16 @@ class Change(TMDb):
55 _urls = {
66 "movie": "/movie/changes",
77 "tv": "/tv/changes",
8 "person": "/person/changes"
8 "person": "/person/changes",
99 }
1010
1111 def _change_list(self, change_type, start_date="", end_date="", page=1):
12 params = "page=%s" % page
12 params = f"page={page}"
1313 if start_date:
14 params += "&start_date=%s" % start_date
14 params += f"&start_date={start_date}"
1515 if end_date:
16 params += "&end_date=%s" % end_date
17 return self._request_obj(
18 self._urls[change_type],
19 params=params,
20 key="results"
21 )
16 params += f"&end_date={end_date}"
17 return self._request_obj(self._urls[change_type], params=params, key="results")
2218
2319 def movie_change_list(self, start_date="", end_date="", page=1):
2420 """
plugin.video.jacktook/lib/api/tmdbv3api/objs/collection.py +2 -2
……@@ -4,8 +4,8 @@ from lib.api.tmdbv3api.tmdb import TMDb
44class Collection(TMDb):
55 _urls = {
66 "details": "/collection/%s",
7 "images": "/collection/%s/images",
8 "translations": "/collection/%s/translations"
7 "images": "/collection/%s/images",
8 "translations": "/collection/%s/translations",
99 }
1010
1111 def details(self, collection_id):
plugin.video.jacktook/lib/api/tmdbv3api/objs/company.py +3 -5
……@@ -3,10 +3,10 @@ from lib.api.tmdbv3api.tmdb import TMDb
33
44class Company(TMDb):
55 _urls = {
6 "details": "/company/%s",
6 "details": "/company/%s",
77 "alternative_names": "/company/%s/alternative_names",
88 "images": "/company/%s/images",
9 "movies": "/company/%s/movies"
9 "movies": "/company/%s/movies",
1010 }
1111
1212 def details(self, company_id):
……@@ -41,7 +41,5 @@ class Company(TMDb):
4141 :return:
4242 """
4343 return self._request_obj(
44 self._urls["movies"] % company_id,
45 params="page=%s" % page,
46 key="results"
44 self._urls["movies"] % company_id, params=f"page={page}", key="results"
4745 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/configuration.py +6 -3
……@@ -1,4 +1,5 @@
11import warnings
2
23from lib.api.tmdbv3api.tmdb import TMDb
34
45
……@@ -9,12 +10,14 @@ class Configuration(TMDb):
910 "jobs": "/configuration/jobs",
1011 "languages": "/configuration/languages",
1112 "primary_translations": "/configuration/primary_translations",
12 "timezones": "/configuration/timezones"
13 "timezones": "/configuration/timezones",
1314 }
1415
1516 def info(self):
16 warnings.warn("info method is deprecated use tmdbv3api.Configuration().api_configuration()",
17 DeprecationWarning)
17 warnings.warn(
18 "info method is deprecated use tmdbv3api.Configuration().api_configuration()",
19 DeprecationWarning,
20 )
1821 return self.api_configuration()
1922
2023 def api_configuration(self):
plugin.video.jacktook/lib/api/tmdbv3api/objs/credit.py +2 -3
……@@ -1,9 +1,8 @@
11from lib.api.tmdbv3api.tmdb import TMDb
22
3
34class Credit(TMDb):
4 _urls = {
5 "details": "/credit/%s"
6 }
5 _urls = {"details": "/credit/%s"}
76
87 def details(self, credit_id):
98 """
plugin.video.jacktook/lib/api/tmdbv3api/objs/discover.py +4 -4
……@@ -34,10 +34,10 @@ class Discover(TMDb):
3434 current_date, future_date = get_dates(7, reverse=False)
3535 params = (
3636 "&with_original_language=en"
37 "&air_date.gte=%s"
38 "&air_date.lte=%s"
39 "&with_networks=%s"
40 "&page=%s" % (current_date, future_date, network_ids, page)
37 f"&air_date.gte={current_date}"
38 f"&air_date.lte={future_date}"
39 f"&with_networks={network_ids}"
40 f"&page={page}"
4141 )
4242 return self._request_obj(
4343 self._urls["tv"],
plugin.video.jacktook/lib/api/tmdbv3api/objs/episode.py +24 -21
……@@ -15,7 +15,13 @@ class Episode(TMDb):
1515 "videos": "/tv/%s/season/%s/episode/%s/videos",
1616 }
1717
18 def details(self, tv_id, season_num, episode_num, append_to_response="trailers,images,casts,translations"):
18 def details(
19 self,
20 tv_id,
21 season_num,
22 episode_num,
23 append_to_response="trailers,images,casts,translations",
24 ):
1925 """
2026 Get the TV episode details by id.
2127 :param tv_id: int
……@@ -26,7 +32,7 @@ class Episode(TMDb):
2632 """
2733 return self._request_obj(
2834 self._urls["details"] % (tv_id, season_num, episode_num),
29 params="append_to_response=%s" % append_to_response
35 params=f"append_to_response={append_to_response}",
3036 )
3137
3238 def account_states(self, tv_id, season_num, episode_num):
……@@ -39,7 +45,7 @@ class Episode(TMDb):
3945 """
4046 return self._request_obj(
4147 self._urls["account_states"] % (tv_id, season_num, episode_num),
42 params="session_id=%s" % self.session_id
48 params=f"session_id={self.session_id}",
4349 )
4450
4551 def changes(self, episode_id, start_date=None, end_date=None, page=1):
……@@ -52,16 +58,12 @@ class Episode(TMDb):
5258 :param page: int
5359 :return:
5460 """
55 params = "page=%s" % page
61 params = f"page={page}"
5662 if start_date:
57 params += "&start_date=%s" % start_date
63 params += f"&start_date={start_date}"
5864 if end_date:
59 params += "&end_date=%s" % end_date
60 return self._request_obj(
61 self._urls["changes"] % episode_id,
62 params=params,
63 key="changes"
64 )
65 params += f"&end_date={end_date}"
66 return self._request_obj(self._urls["changes"] % episode_id, params=params, key="changes")
6567
6668 def credits(self, tv_id, season_num, episode_num):
6769 """
……@@ -94,8 +96,10 @@ class Episode(TMDb):
9496 """
9597 return self._request_obj(
9698 self._urls["images"] % (tv_id, season_num, episode_num),
97 params="include_image_language=%s" % include_image_language if include_image_language else "",
98 key="stills"
99 params=f"include_image_language={include_image_language}"
100 if include_image_language
101 else "",
102 key="stills",
99103 )
100104
101105 def translations(self, tv_id, season_num, episode_num):
……@@ -108,7 +112,7 @@ class Episode(TMDb):
108112 """
109113 return self._request_obj(
110114 self._urls["translations"] % (tv_id, season_num, episode_num),
111 key="translations"
115 key="translations",
112116 )
113117
114118 def rate_tv_episode(self, tv_id, season_num, episode_num, rating):
……@@ -121,9 +125,9 @@ class Episode(TMDb):
121125 """
122126 self._request_obj(
123127 self._urls["rate_tv_episode"] % (tv_id, season_num, episode_num),
124 params="session_id=%s" % self.session_id,
128 params=f"session_id={self.session_id}",
125129 method="POST",
126 json={"value": rating}
130 json={"value": rating},
127131 )
128132
129133 def delete_rating(self, tv_id, season_num, episode_num):
……@@ -135,8 +139,8 @@ class Episode(TMDb):
135139 """
136140 self._request_obj(
137141 self._urls["delete_rating"] % (tv_id, season_num, episode_num),
138 params="session_id=%s" % self.session_id,
139 method="DELETE"
142 params=f"session_id={self.session_id}",
143 method="DELETE",
140144 )
141145
142146 def videos(self, tv_id, season_num, episode_num, include_video_language=None):
……@@ -150,8 +154,7 @@ class Episode(TMDb):
150154 """
151155 params = ""
152156 if include_video_language:
153 params += "&include_video_language=%s" % include_video_language
157 params += f"&include_video_language={include_video_language}"
154158 return self._request_obj(
155 self._urls["videos"] % (tv_id, season_num, episode_num),
156 params=params
159 self._urls["videos"] % (tv_id, season_num, episode_num), params=params
157160 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/find.py +2 -4
……@@ -2,9 +2,7 @@ from lib.api.tmdbv3api.tmdb import TMDb
22
33
44class Find(TMDb):
5 _urls = {
6 "find": "/find/%s"
7 }
5 _urls = {"find": "/find/%s"}
86
97 def find(self, external_id, external_source):
108 """
……@@ -15,7 +13,7 @@ class Find(TMDb):
1513 """
1614 return self._request_obj(
1715 self._urls["find"] % external_id.replace("/", "%2F"),
18 params="external_source=" + external_source
16 params="external_source=" + external_source,
1917 )
2018
2119 def find_by_imdb_id(self, imdb_id):
plugin.video.jacktook/lib/api/tmdbv3api/objs/genre.py +1 -4
……@@ -2,10 +2,7 @@ from lib.api.tmdbv3api.tmdb import TMDb
22
33
44class Genre(TMDb):
5 _urls = {
6 "movie_list": "/genre/movie/list",
7 "tv_list": "/genre/tv/list"
8 }
5 _urls = {"movie_list": "/genre/movie/list", "tv_list": "/genre/tv/list"}
96
107 def movie_list(self):
118 """
plugin.video.jacktook/lib/api/tmdbv3api/objs/group.py +1 -3
……@@ -2,9 +2,7 @@ from lib.api.tmdbv3api.tmdb import TMDb
22
33
44class Group(TMDb):
5 _urls = {
6 "details": "/tv/episode_group/%s"
7 }
5 _urls = {"details": "/tv/episode_group/%s"}
86
97 def details(self, group_id):
108 """
plugin.video.jacktook/lib/api/tmdbv3api/objs/keyword.py +2 -4
……@@ -1,10 +1,8 @@
11from lib.api.tmdbv3api.tmdb import TMDb
22
3
34class Keyword(TMDb):
4 _urls = {
5 "details": "/keyword/%s",
6 "movies": "/keyword/%s/movies"
7 }
5 _urls = {"details": "/keyword/%s", "movies": "/keyword/%s/movies"}
86
97 def details(self, keyword_id):
108 """
plugin.video.jacktook/lib/api/tmdbv3api/objs/list.py +14 -15
……@@ -1,5 +1,6 @@
11from lib.api.tmdbv3api.tmdb import TMDb
22
3
34class List(TMDb):
45 _urls = {
56 "details": "/list/%s",
……@@ -26,7 +27,9 @@ class List(TMDb):
2627 :param movie_id: int
2728 :return:
2829 """
29 return self._request_obj(self._urls["check_status"] % list_id, params="movie_id=%s" % movie_id)["item_present"]
30 return self._request_obj(
31 self._urls["check_status"] % list_id, params=f"movie_id={movie_id}"
32 )["item_present"]
3033
3134 def create_list(self, name, description):
3235 """
……@@ -37,13 +40,9 @@ class List(TMDb):
3740 """
3841 return self._request_obj(
3942 self._urls["create"],
40 params="session_id=%s" % self.session_id,
43 params=f"session_id={self.session_id}",
4144 method="POST",
42 json={
43 "name": name,
44 "description": description,
45 "language": self.language
46 }
45 json={"name": name, "description": description, "language": self.language},
4746 ).list_id
4847
4948 def add_movie(self, list_id, movie_id):
……@@ -54,9 +53,9 @@ class List(TMDb):
5453 """
5554 self._request_obj(
5655 self._urls["add_movie"] % list_id,
57 params="session_id=%s" % self.session_id,
56 params=f"session_id={self.session_id}",
5857 method="POST",
59 json={"media_id": movie_id}
58 json={"media_id": movie_id},
6059 )
6160
6261 def remove_movie(self, list_id, movie_id):
……@@ -67,9 +66,9 @@ class List(TMDb):
6766 """
6867 self._request_obj(
6968 self._urls["remove_movie"] % list_id,
70 params="session_id=%s" % self.session_id,
69 params=f"session_id={self.session_id}",
7170 method="POST",
72 json={"media_id": movie_id}
71 json={"media_id": movie_id},
7372 )
7473
7574 def clear_list(self, list_id):
……@@ -79,8 +78,8 @@ class List(TMDb):
7978 """
8079 self._request_obj(
8180 self._urls["clear_list"] % list_id,
82 params="session_id=%s&confirm=true" % self.session_id,
83 method="POST"
81 params=f"session_id={self.session_id}&confirm=true",
82 method="POST",
8483 )
8584
8685 def delete_list(self, list_id):
……@@ -90,6 +89,6 @@ class List(TMDb):
9089 """
9190 self._request_obj(
9291 self._urls["delete_list"] % list_id,
93 params="session_id=%s" % self.session_id,
94 method="DELETE"
92 params=f"session_id={self.session_id}",
93 method="DELETE",
9594 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/movie.py +54 -82
……@@ -1,5 +1,7 @@
11import warnings
2
23from lib.api.tmdbv3api.tmdb import TMDb
4
35from .find import Find
46from .search import Search
57
……@@ -31,7 +33,11 @@ class Movie(TMDb):
3133 "upcoming": "/movie/upcoming",
3234 }
3335
34 def details(self, movie_id, append_to_response="videos,trailers,images,casts,translations,keywords,release_dates,runtime,external_ids"):
36 def details(
37 self,
38 movie_id,
39 append_to_response="videos,trailers,images,casts,translations,keywords,release_dates,runtime,external_ids",
40 ):
3541 """
3642 Get the primary information about a movie.
3743 :param movie_id: int
……@@ -40,7 +46,7 @@ class Movie(TMDb):
4046 """
4147 return self._request_obj(
4248 self._urls["details"] % movie_id,
43 params="append_to_response=%s" % append_to_response
49 params=f"append_to_response={append_to_response}",
4450 )
4551
4652 def account_states(self, movie_id):
……@@ -52,7 +58,7 @@ class Movie(TMDb):
5258 """
5359 return self._request_obj(
5460 self._urls["account_states"] % movie_id,
55 params="session_id=%s" % self.session_id
61 params=f"session_id={self.session_id}",
5662 )
5763
5864 def alternative_titles(self, movie_id, country=None):
……@@ -64,8 +70,8 @@ class Movie(TMDb):
6470 """
6571 return self._request_obj(
6672 self._urls["alternative_titles"] % movie_id,
67 params="country=%s" % country if country else "",
68 key="titles"
73 params=f"country={country}" if country else "",
74 key="titles",
6975 )
7076
7177 def changes(self, movie_id, start_date=None, end_date=None, page=1):
……@@ -78,16 +84,12 @@ class Movie(TMDb):
7884 :param page: int
7985 :return:
8086 """
81 params = "page=%s" % page
87 params = f"page={page}"
8288 if start_date:
83 params += "&start_date=%s" % start_date
89 params += f"&start_date={start_date}"
8490 if end_date:
85 params += "&end_date=%s" % end_date
86 return self._request_obj(
87 self._urls["changes"] % movie_id,
88 params=params,
89 key="changes"
90 )
91 params += f"&end_date={end_date}"
92 return self._request_obj(self._urls["changes"] % movie_id, params=params, key="changes")
9193
9294 def credits(self, movie_id):
9395 """
……@@ -104,11 +106,11 @@ class Movie(TMDb):
104106 :return:
105107 """
106108 return self._request_obj(self._urls["external_ids"] % movie_id)
107
109
108110 def images(self, movie_id, include_image_language=None):
109111 """
110112 Get the images that belong to a movie.
111 Querying images with a language parameter will filter the results.
113 Querying images with a language parameter will filter the results.
112114 If you want to include a fallback language (especially useful for backdrops)
113115 you can use the include_image_language parameter.
114116 This should be a comma separated value like so: include_image_language=en,null.
……@@ -118,7 +120,9 @@ class Movie(TMDb):
118120 """
119121 return self._request_obj(
120122 self._urls["images"] % movie_id,
121 params="include_image_language=%s" % include_image_language if include_image_language else ""
123 params=f"include_image_language={include_image_language}"
124 if include_image_language
125 else "",
122126 )
123127
124128 def keywords(self, movie_id):
……@@ -127,10 +131,7 @@ class Movie(TMDb):
127131 :param movie_id: int
128132 :return:
129133 """
130 return self._request_obj(
131 self._urls["keywords"] % movie_id,
132 key="keywords"
133 )
134 return self._request_obj(self._urls["keywords"] % movie_id, key="keywords")
134135
135136 def lists(self, movie_id, page=1):
136137 """
……@@ -140,9 +141,7 @@ class Movie(TMDb):
140141 :return:
141142 """
142143 return self._request_obj(
143 self._urls["lists"] % movie_id,
144 params="page=%s" % page,
145 key="results"
144 self._urls["lists"] % movie_id, params=f"page={page}", key="results"
146145 )
147146
148147 def recommendations(self, movie_id, page=1):
……@@ -154,8 +153,8 @@ class Movie(TMDb):
154153 """
155154 return self._request_obj(
156155 self._urls["recommendations"] % movie_id,
157 params="page=%s" % page,
158 key="results"
156 params=f"page={page}",
157 key="results",
159158 )
160159
161160 def release_dates(self, movie_id):
……@@ -164,10 +163,7 @@ class Movie(TMDb):
164163 :param movie_id: int
165164 :return:
166165 """
167 return self._request_obj(
168 self._urls["release_dates"] % movie_id,
169 key="results"
170 )
166 return self._request_obj(self._urls["release_dates"] % movie_id, key="results")
171167
172168 def reviews(self, movie_id, page=1):
173169 """
……@@ -177,9 +173,7 @@ class Movie(TMDb):
177173 :return:
178174 """
179175 return self._request_obj(
180 self._urls["reviews"] % movie_id,
181 params="page=%s" % page,
182 key="results"
176 self._urls["reviews"] % movie_id, params=f"page={page}", key="results"
183177 )
184178
185179 def similar(self, movie_id, page=1):
……@@ -190,9 +184,7 @@ class Movie(TMDb):
190184 :return:
191185 """
192186 return self._request_obj(
193 self._urls["similar"] % movie_id,
194 params="page=%s" % page,
195 key="results"
187 self._urls["similar"] % movie_id, params=f"page={page}", key="results"
196188 )
197189
198190 def translations(self, movie_id):
……@@ -201,10 +193,7 @@ class Movie(TMDb):
201193 :param movie_id: int
202194 :return:
203195 """
204 return self._request_obj(
205 self._urls["translations"] % movie_id,
206 key="translations"
207 )
196 return self._request_obj(self._urls["translations"] % movie_id, key="translations")
208197
209198 def videos(self, movie_id, page=1):
210199 """
……@@ -214,9 +203,7 @@ class Movie(TMDb):
214203 :return:
215204 """
216205 return self._request_obj(
217 self._urls["videos"] % movie_id,
218 params="page=%s" % page,
219 key="results"
206 self._urls["videos"] % movie_id, params=f"page={page}", key="results"
220207 )
221208
222209 def watch_providers(self, movie_id):
……@@ -225,10 +212,7 @@ class Movie(TMDb):
225212 :param movie_id: int
226213 :return:
227214 """
228 return self._request_obj(
229 self._urls["watch_providers"] % movie_id,
230 key="results"
231 )
215 return self._request_obj(self._urls["watch_providers"] % movie_id, key="results")
232216
233217 def rate_movie(self, movie_id, rating):
234218 """
……@@ -238,9 +222,9 @@ class Movie(TMDb):
238222 """
239223 self._request_obj(
240224 self._urls["rate_movie"] % movie_id,
241 params="session_id=%s" % self.session_id,
225 params=f"session_id={self.session_id}",
242226 method="POST",
243 json={"value": rating}
227 json={"value": rating},
244228 )
245229
246230 def delete_rating(self, movie_id):
……@@ -250,8 +234,8 @@ class Movie(TMDb):
250234 """
251235 self._request_obj(
252236 self._urls["delete_rating"] % movie_id,
253 params="session_id=%s" % self.session_id,
254 method="DELETE"
237 params=f"session_id={self.session_id}",
238 method="DELETE",
255239 )
256240
257241 def latest(self):
……@@ -268,14 +252,10 @@ class Movie(TMDb):
268252 :param page: int
269253 :return:
270254 """
271 params = "page=%s" % page
255 params = f"page={page}"
272256 if region:
273 params += "&region=%s" % region
274 return self._request_obj(
275 self._urls["now_playing"],
276 params=params,
277 key="results"
278 )
257 params += f"&region={region}"
258 return self._request_obj(self._urls["now_playing"], params=params, key="results")
279259
280260 def popular(self, region=None, page=1):
281261 """
……@@ -284,14 +264,10 @@ class Movie(TMDb):
284264 :param page: int
285265 :return:
286266 """
287 params = "page=%s" % page
267 params = f"page={page}"
288268 if region:
289 params += "&region=%s" % region
290 return self._request_obj(
291 self._urls["popular"],
292 params=params,
293 key="results"
294 )
269 params += f"&region={region}"
270 return self._request_obj(self._urls["popular"], params=params, key="results")
295271
296272 def top_rated(self, region=None, page=1):
297273 """
……@@ -300,14 +276,10 @@ class Movie(TMDb):
300276 :param page: int
301277 :return:
302278 """
303 params = "page=%s" % page
279 params = f"page={page}"
304280 if region:
305 params += "&region=%s" % region
306 return self._request_obj(
307 self._urls["top_rated"],
308 params=params,
309 key="results"
310 )
281 params += f"&region={region}"
282 return self._request_obj(self._urls["top_rated"], params=params, key="results")
311283
312284 def upcoming(self, region=None, page=1):
313285 """
……@@ -316,14 +288,10 @@ class Movie(TMDb):
316288 :param page: int
317289 :return:
318290 """
319 params = "page=%s" % page
291 params = f"page={page}"
320292 if region:
321 params += "&region=%s" % region
322 return self._request_obj(
323 self._urls["upcoming"],
324 params=params,
325 key="results"
326 )
293 params += f"&region={region}"
294 return self._request_obj(self._urls["upcoming"], params=params, key="results")
327295
328296 def search(self, term, page=1):
329297 """
……@@ -332,8 +300,10 @@ class Movie(TMDb):
332300 :param page: int
333301 :return:
334302 """
335 warnings.warn("search method is deprecated use tmdbv3api.Search().movies(term)",
336 DeprecationWarning)
303 warnings.warn(
304 "search method is deprecated use tmdbv3api.Search().movies(term)",
305 DeprecationWarning,
306 )
337307 return Search().movies(term, page=page)
338308
339309 def external(self, external_id, external_source):
……@@ -343,6 +313,8 @@ class Movie(TMDb):
343313 :param external_source str
344314 :return:
345315 """
346 warnings.warn("external method is deprecated use tmdbv3api.Find().find(external_id, external_source)",
347 DeprecationWarning)
316 warnings.warn(
317 "external method is deprecated use tmdbv3api.Find().find(external_id, external_source)",
318 DeprecationWarning,
319 )
348320 return Find().find(external_id, external_source)
plugin.video.jacktook/lib/api/tmdbv3api/objs/network.py +3 -9
……@@ -5,7 +5,7 @@ class Network(TMDb):
55 _urls = {
66 "details": "/network/%s",
77 "alternative_names": "/network/%s/alternative_names",
8 "images": "/network/%s/images"
8 "images": "/network/%s/images",
99 }
1010
1111 def details(self, network_id):
……@@ -22,10 +22,7 @@ class Network(TMDb):
2222 :param network_id: int
2323 :return:
2424 """
25 return self._request_obj(
26 self._urls["alternative_names"] % network_id,
27 key="results"
28 )
25 return self._request_obj(self._urls["alternative_names"] % network_id, key="results")
2926
3027 def images(self, network_id):
3128 """
……@@ -33,7 +30,4 @@ class Network(TMDb):
3330 :param network_id: int
3431 :return:
3532 """
36 return self._request_obj(
37 self._urls["images"] % network_id,
38 key="logos"
39 )
33 return self._request_obj(self._urls["images"] % network_id, key="logos")
plugin.video.jacktook/lib/api/tmdbv3api/objs/person.py +16 -24
……@@ -1,5 +1,7 @@
11import warnings
2
23from lib.api.tmdbv3api.tmdb import TMDb
4
35from .search import Search
46
57
……@@ -28,7 +30,7 @@ class Person(TMDb):
2830 """
2931 return self._request_obj(
3032 self._urls["details"] % person_id,
31 params="append_to_response=%s" % append_to_response
33 params=f"append_to_response={append_to_response}",
3234 )
3335
3436 def changes(self, person_id, start_date=None, end_date=None, page=1):
……@@ -41,16 +43,12 @@ class Person(TMDb):
4143 :param page: int
4244 :return:
4345 """
44 params = "page=%s" % page
46 params = f"page={page}"
4547 if start_date:
46 params += "&start_date=%s" % start_date
48 params += f"&start_date={start_date}"
4749 if end_date:
48 params += "&end_date=%s" % end_date
49 return self._request_obj(
50 self._urls["changes"] % person_id,
51 params=params,
52 key="changes"
53 )
50 params += f"&end_date={end_date}"
51 return self._request_obj(self._urls["changes"] % person_id, params=params, key="changes")
5452
5553 def movie_credits(self, person_id):
5654 """
……@@ -91,10 +89,7 @@ class Person(TMDb):
9189 :param person_id: int
9290 :return:
9391 """
94 return self._request_obj(
95 self._urls["images"] % person_id,
96 key="profiles"
97 )
92 return self._request_obj(self._urls["images"] % person_id, key="profiles")
9893
9994 def tagged_images(self, person_id, page=1):
10095 """
……@@ -105,8 +100,8 @@ class Person(TMDb):
105100 """
106101 return self._request_obj(
107102 self._urls["tagged_images"] % person_id,
108 params="page=%s" % page,
109 key="results"
103 params=f"page={page}",
104 key="results",
110105 )
111106
112107 def translations(self, person_id):
……@@ -115,10 +110,7 @@ class Person(TMDb):
115110 :param person_id: int
116111 :return:
117112 """
118 return self._request_obj(
119 self._urls["translations"] % person_id,
120 key="translations"
121 )
113 return self._request_obj(self._urls["translations"] % person_id, key="translations")
122114
123115 def latest(self):
124116 """
……@@ -134,9 +126,7 @@ class Person(TMDb):
134126 :return:
135127 """
136128 return self._request_obj(
137 self._urls["popular"],
138 params="page=%s" % page,
139 key="results"
129 self._urls["popular"], params=f"page={page}", key="results"
140130 )
141131
142132 def search(self, term, page=1):
……@@ -146,6 +136,8 @@ class Person(TMDb):
146136 :param page: int
147137 :return:
148138 """
149 warnings.warn("search method is deprecated use tmdbv3api.Search().people(params)",
150 DeprecationWarning)
139 warnings.warn(
140 "search method is deprecated use tmdbv3api.Search().people(params)",
141 DeprecationWarning,
142 )
151143 return Search().people(term, page=page)
plugin.video.jacktook/lib/api/tmdbv3api/objs/provider.py +5 -8
……@@ -13,10 +13,7 @@ class Provider(TMDb):
1313 Returns a list of all of the countries we have watch provider (OTT/streaming) data for.
1414 :return:
1515 """
16 return self._request_obj(
17 self._urls["regions"],
18 key="results"
19 )
16 return self._request_obj(self._urls["regions"], key="results")
2017
2118 def movie_providers(self, region=None):
2219 """
……@@ -25,8 +22,8 @@ class Provider(TMDb):
2522 """
2623 return self._request_obj(
2724 self._urls["movie"],
28 params="watch_region=%s" % region if region else "",
29 key="results"
25 params=f"watch_region={region}" if region else "",
26 key="results",
3027 )
3128
3229 def tv_providers(self, region=None):
……@@ -36,6 +33,6 @@ class Provider(TMDb):
3633 """
3734 return self._request_obj(
3835 self._urls["tv"],
39 params="watch_region=%s" % region if region else "",
40 key="results"
36 params=f"watch_region={region}" if region else "",
37 key="results",
4138 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/search.py +45 -45
……@@ -26,8 +26,8 @@ class Search(TMDb):
2626 """
2727 return self._request_obj(
2828 self._urls["companies"],
29 params="query=%s&page=%s" % (quote(term), page),
30 key="results"
29 params=f"query={quote(term)}&page={page}",
30 key="results",
3131 )
3232
3333 def collections(self, term, page=1):
……@@ -39,8 +39,8 @@ class Search(TMDb):
3939 """
4040 return self._request_obj(
4141 self._urls["collections"],
42 params="query=%s&page=%s" % (quote(term), page),
43 key="results"
42 params=f"query={quote(term)}&page={page}",
43 key="results",
4444 )
4545
4646 def keywords(self, term, page=1):
……@@ -52,11 +52,20 @@ class Search(TMDb):
5252 """
5353 return self._request_obj(
5454 self._urls["keywords"],
55 params="query=%s&page=%s" % (quote(term), page),
56 key="results"
55 params=f"query={quote(term)}&page={page}",
56 key="results",
5757 )
58
59 def movies(self, term, adult=None, region=None, year=None, release_year=None, page=1, append_to_response="external_ids"):
58
59 def movies(
60 self,
61 term,
62 adult=None,
63 region=None,
64 year=None,
65 release_year=None,
66 page=1,
67 append_to_response="external_ids",
68 ):
6069 """
6170 Search for movies.
6271 :param term: str
……@@ -67,21 +76,17 @@ class Search(TMDb):
6776 :param page: int
6877 :return:
6978 """
70 params = "query=%s&page=%s" % (quote(term), page)
79 params = f"query={quote(term)}&page={page}"
7180 if adult is not None:
72 params += "&include_adult=%s" % "true" if adult else "false"
81 params += "&include_adult={}".format("true") if adult else "false"
7382 if region is not None:
74 params += "&region=%s" % quote(region)
83 params += f"&region={quote(region)}"
7584 if year is not None:
76 params += "&year=%s" % year
85 params += f"&year={year}"
7786 if release_year is not None:
78 params += "&primary_release_year=%s" % release_year
79 params +="&append_to_response=%s" % append_to_response
80 return self._request_obj(
81 self._urls["movies"],
82 params=params,
83 key="results"
84 )
87 params += f"&primary_release_year={release_year}"
88 params += f"&append_to_response={append_to_response}"
89 return self._request_obj(self._urls["movies"], params=params, key="results")
8590
8691 def multi(self, term, adult=None, region=None, page=1):
8792 """
……@@ -93,16 +98,12 @@ class Search(TMDb):
9398 :param page: int
9499 :return:
95100 """
96 params = "query=%s&page=%s" % (quote(term), page)
101 params = f"query={quote(term)}&page={page}"
97102 if adult is not None:
98 params += "&include_adult=%s" % "true" if adult else "false"
103 params += "&include_adult={}".format("true") if adult else "false"
99104 if region is not None:
100 params += "&region=%s" % quote(region)
101 return self._request_obj(
102 self._urls["multi"],
103 params=params,
104 key="results"
105 )
105 params += f"&region={quote(region)}"
106 return self._request_obj(self._urls["multi"], params=params, key="results")
106107
107108 def people(self, term, adult=None, region=None, page=1):
108109 """
……@@ -113,18 +114,21 @@ class Search(TMDb):
113114 :param page: int
114115 :return:
115116 """
116 params = "query=%s&page=%s" % (quote(term), page)
117 params = f"query={quote(term)}&page={page}"
117118 if adult is not None:
118 params += "&include_adult=%s" % "true" if adult else "false"
119 params += "&include_adult={}".format("true") if adult else "false"
119120 if region is not None:
120 params += "&region=%s" % quote(region)
121 return self._request_obj(
122 self._urls["people"],
123 params=params,
124 key="results"
125 )
121 params += f"&region={quote(region)}"
122 return self._request_obj(self._urls["people"], params=params, key="results")
126123
127 def tv_shows(self, term, adult=None, release_year=None, page=1, append_to_response="external_ids"):
124 def tv_shows(
125 self,
126 term,
127 adult=None,
128 release_year=None,
129 page=1,
130 append_to_response="external_ids",
131 ):
128132 """
129133 Search for a TV show.
130134 :param term: str
……@@ -133,14 +137,10 @@ class Search(TMDb):
133137 :param page: int
134138 :return:
135139 """
136 params = "query=%s&page=%s" % (quote(term), page)
140 params = f"query={quote(term)}&page={page}"
137141 if adult is not None:
138 params += "&include_adult=%s" % "true" if adult else "false"
142 params += "&include_adult={}".format("true") if adult else "false"
139143 if release_year is not None:
140 params += "&first_air_date_year=%s" % release_year
141 params +="&append_to_response=%s" % append_to_response
142 return self._request_obj(
143 self._urls["tv_shows"],
144 params=params,
145 key="results"
146 )
144 params += f"&first_air_date_year={release_year}"
145 params += f"&append_to_response={append_to_response}"
146 return self._request_obj(self._urls["tv_shows"], params=params, key="results")
plugin.video.jacktook/lib/api/tmdbv3api/objs/season.py +21 -22
……@@ -14,7 +14,12 @@ class Season(TMDb):
1414 "videos": "/tv/%s/season/%s/videos",
1515 }
1616
17 def details(self, tv_id, season_num, append_to_response="videos,trailers,images,credits,translations,runtime"):
17 def details(
18 self,
19 tv_id,
20 season_num,
21 append_to_response="videos,trailers,images,credits,translations,runtime",
22 ):
1823 """
1924 Get the TV season details by id.
2025 :param tv_id: int
……@@ -24,7 +29,7 @@ class Season(TMDb):
2429 """
2530 return self._request_obj(
2631 self._urls["details"] % (tv_id, season_num),
27 params="append_to_response=%s" % append_to_response
32 params=f"append_to_response={append_to_response}",
2833 )
2934
3035 def account_states(self, tv_id, season_num):
……@@ -36,8 +41,8 @@ class Season(TMDb):
3641 """
3742 return self._request_obj(
3843 self._urls["account_states"] % (tv_id, season_num),
39 params="session_id=%s" % self.session_id,
40 key="results"
44 params=f"session_id={self.session_id}",
45 key="results",
4146 )
4247
4348 def aggregate_credits(self, tv_id, season_num):
……@@ -61,16 +66,12 @@ class Season(TMDb):
6166 :param page: int
6267 :return:
6368 """
64 params = "page=%s" % page
69 params = f"page={page}"
6570 if start_date:
66 params += "&start_date=%s" % start_date
71 params += f"&start_date={start_date}"
6772 if end_date:
68 params += "&end_date=%s" % end_date
69 return self._request_obj(
70 self._urls["changes"] % season_id,
71 params=params,
72 key="changes"
73 )
73 params += f"&end_date={end_date}"
74 return self._request_obj(self._urls["changes"] % season_id, params=params, key="changes")
7475
7576 def credits(self, tv_id, season_num):
7677 """
……@@ -100,8 +101,10 @@ class Season(TMDb):
100101 """
101102 return self._request_obj(
102103 self._urls["images"] % (tv_id, season_num),
103 params="include_image_language=%s" % include_image_language if include_image_language else "",
104 key="posters"
104 params=f"include_image_language={include_image_language}"
105 if include_image_language
106 else "",
107 key="posters",
105108 )
106109
107110 def translations(self, tv_id, season_num):
……@@ -111,8 +114,7 @@ class Season(TMDb):
111114 :param season_num: int
112115 """
113116 return self._request_obj(
114 self._urls["translations"] % (tv_id, season_num),
115 key="translations"
117 self._urls["translations"] % (tv_id, season_num), key="translations"
116118 )
117119
118120 def videos(self, tv_id, season_num, include_video_language=None, page=1):
……@@ -124,10 +126,7 @@ class Season(TMDb):
124126 :param page: int
125127 :return:
126128 """
127 params = "page=%s" % page
129 params = f"page={page}"
128130 if include_video_language:
129 params += "&include_video_language=%s" % include_video_language
130 return self._request_obj(
131 self._urls["videos"] % (tv_id, season_num),
132 params=params
133 )
131 params += f"&include_video_language={include_video_language}"
132 return self._request_obj(self._urls["videos"] % (tv_id, season_num), params=params)
plugin.video.jacktook/lib/api/tmdbv3api/objs/trending.py +1 -2
……@@ -6,8 +6,7 @@ class Trending(TMDb):
66
77 def _trending(self, media_type="all", time_window="day", page=1):
88 return self._request_obj(
9 self._urls["trending"] % (media_type, time_window),
10 params="page=%s" % page
9 self._urls["trending"] % (media_type, time_window), params=f"page={page}"
1110 )
1211
1312 def all_day(self, page=1):
plugin.video.jacktook/lib/api/tmdbv3api/objs/tv.py +43 -73
……@@ -1,11 +1,13 @@
11import warnings
2
23from lib.api.tmdbv3api.tmdb import TMDb
4
35from .search import Search
46
57try:
68 from urllib import quote
79except ImportError:
8 from urllib.parse import quote
10 pass
911
1012
1113class TV(TMDb):
……@@ -37,7 +39,11 @@ class TV(TMDb):
3739 "top_rated": "/tv/top_rated",
3840 }
3941
40 def details(self, tv_id, append_to_response="videos,trailers,images,credits,translations,external_ids"):
42 def details(
43 self,
44 tv_id,
45 append_to_response="videos,trailers,images,credits,translations,external_ids",
46 ):
4147 """
4248 Get the primary TV show details by id.
4349 :param tv_id: int
……@@ -46,7 +52,7 @@ class TV(TMDb):
4652 """
4753 return self._request_obj(
4854 self._urls["details"] % tv_id,
49 params="append_to_response=%s" % append_to_response,
55 params=f"append_to_response={append_to_response}",
5056 )
5157
5258 def account_states(self, tv_id):
……@@ -58,7 +64,7 @@ class TV(TMDb):
5864 """
5965 return self._request_obj(
6066 self._urls["account_states"] % tv_id,
61 params="session_id=%s" % self.session_id
67 params=f"session_id={self.session_id}",
6268 )
6369
6470 def aggregate_credits(self, tv_id):
……@@ -77,10 +83,7 @@ class TV(TMDb):
7783 :param tv_id: int
7884 :return:
7985 """
80 return self._request_obj(
81 self._urls["alternative_titles"] % tv_id,
82 key="results"
83 )
86 return self._request_obj(self._urls["alternative_titles"] % tv_id, key="results")
8487
8588 def changes(self, tv_id, start_date=None, end_date=None, page=1):
8689 """
……@@ -91,16 +94,12 @@ class TV(TMDb):
9194 :param end_date: str
9295 :param page: int
9396 """
94 params = "page=%s" % page
97 params = f"page={page}"
9598 if start_date:
96 params += "&start_date=%s" % start_date
99 params += f"&start_date={start_date}"
97100 if end_date:
98 params += "&end_date=%s" % end_date
99 return self._request_obj(
100 self._urls["changes"] % tv_id,
101 params=params,
102 key="changes"
103 )
101 params += f"&end_date={end_date}"
102 return self._request_obj(self._urls["changes"] % tv_id, params=params, key="changes")
104103
105104 def content_ratings(self, tv_id):
106105 """
……@@ -108,10 +107,7 @@ class TV(TMDb):
108107 :param tv_id: int
109108 :return:
110109 """
111 return self._request_obj(
112 self._urls["content_ratings"] % tv_id,
113 key="results"
114 )
110 return self._request_obj(self._urls["content_ratings"] % tv_id, key="results")
115111
116112 def credits(self, tv_id):
117113 """
……@@ -127,10 +123,7 @@ class TV(TMDb):
127123 :param tv_id: int
128124 :return:
129125 """
130 return self._request_obj(
131 self._urls["episode_groups"] % tv_id,
132 key="results"
133 )
126 return self._request_obj(self._urls["episode_groups"] % tv_id, key="results")
134127
135128 def external_ids(self, tv_id):
136129 """
……@@ -153,7 +146,9 @@ class TV(TMDb):
153146 """
154147 return self._request_obj(
155148 self._urls["images"] % tv_id,
156 params="include_image_language=%s" % include_image_language if include_image_language else ""
149 params=f"include_image_language={include_image_language}"
150 if include_image_language
151 else "",
157152 )
158153
159154 def keywords(self, tv_id):
……@@ -162,10 +157,7 @@ class TV(TMDb):
162157 :param tv_id: int
163158 :return:
164159 """
165 return self._request_obj(
166 self._urls["keywords"] % tv_id,
167 key="results"
168 )
160 return self._request_obj(self._urls["keywords"] % tv_id, key="results")
169161
170162 def recommendations(self, tv_id, page=1):
171163 """
……@@ -176,8 +168,8 @@ class TV(TMDb):
176168 """
177169 return self._request_obj(
178170 self._urls["recommendations"] % tv_id,
179 params="page=%s" % page,
180 key="results"
171 params=f"page={page}",
172 key="results",
181173 )
182174
183175 def reviews(self, tv_id, page=1):
……@@ -188,9 +180,7 @@ class TV(TMDb):
188180 :return:
189181 """
190182 return self._request_obj(
191 self._urls["reviews"] % tv_id,
192 params="page=%s" % page,
193 key="results"
183 self._urls["reviews"] % tv_id, params=f"page={page}", key="results"
194184 )
195185
196186 def screened_theatrically(self, tv_id):
……@@ -199,10 +189,7 @@ class TV(TMDb):
199189 :param tv_id: int
200190 :return:
201191 """
202 return self._request_obj(
203 self._urls["screened_theatrically"] % tv_id,
204 key="results"
205 )
192 return self._request_obj(self._urls["screened_theatrically"] % tv_id, key="results")
206193
207194 def similar(self, tv_id, page=1):
208195 """
……@@ -212,9 +199,7 @@ class TV(TMDb):
212199 :return:
213200 """
214201 return self._request_obj(
215 self._urls["similar"] % tv_id,
216 params="page=%s" % page,
217 key="results"
202 self._urls["similar"] % tv_id, params=f"page={page}", key="results"
218203 )
219204
220205 def translations(self, tv_id):
……@@ -223,10 +208,7 @@ class TV(TMDb):
223208 :param tv_id: int
224209 :return:
225210 """
226 return self._request_obj(
227 self._urls["translations"] % tv_id,
228 key="translations"
229 )
211 return self._request_obj(self._urls["translations"] % tv_id, key="translations")
230212
231213 def videos(self, tv_id, include_video_language=None, page=1):
232214 """
……@@ -236,13 +218,10 @@ class TV(TMDb):
236218 :param page: int
237219 :return:
238220 """
239 params = "page=%s" % page
221 params = f"page={page}"
240222 if include_video_language:
241 params += "&include_video_language=%s" % include_video_language
242 return self._request_obj(
243 self._urls["videos"] % tv_id,
244 params=params
245 )
223 params += f"&include_video_language={include_video_language}"
224 return self._request_obj(self._urls["videos"] % tv_id, params=params)
246225
247226 def watch_providers(self, tv_id):
248227 """
……@@ -250,10 +229,7 @@ class TV(TMDb):
250229 :param tv_id: int
251230 :return:
252231 """
253 return self._request_obj(
254 self._urls["watch_providers"] % tv_id,
255 key="results"
256 )
232 return self._request_obj(self._urls["watch_providers"] % tv_id, key="results")
257233
258234 def rate_tv_show(self, tv_id, rating):
259235 """
……@@ -263,9 +239,9 @@ class TV(TMDb):
263239 """
264240 self._request_obj(
265241 self._urls["rate_tv_show"] % tv_id,
266 params="session_id=%s" % self.session_id,
242 params=f"session_id={self.session_id}",
267243 method="POST",
268 json={"value": rating}
244 json={"value": rating},
269245 )
270246
271247 def delete_rating(self, tv_id):
……@@ -275,8 +251,8 @@ class TV(TMDb):
275251 """
276252 self._request_obj(
277253 self._urls["delete_rating"] % tv_id,
278 params="session_id=%s" % self.session_id,
279 method="DELETE"
254 params=f"session_id={self.session_id}",
255 method="DELETE",
280256 )
281257
282258 def latest(self):
……@@ -294,9 +270,7 @@ class TV(TMDb):
294270 :return:
295271 """
296272 return self._request_obj(
297 self._urls["airing_today"],
298 params="page=%s" % page,
299 key="results"
273 self._urls["airing_today"], params=f"page={page}", key="results"
300274 )
301275
302276 def on_the_air(self, page=1):
……@@ -306,9 +280,7 @@ class TV(TMDb):
306280 :return:
307281 """
308282 return self._request_obj(
309 self._urls["on_the_air"],
310 params="page=%s" % page,
311 key="results"
283 self._urls["on_the_air"], params=f"page={page}", key="results"
312284 )
313285
314286 def popular(self, page=1):
……@@ -318,9 +290,7 @@ class TV(TMDb):
318290 :return:
319291 """
320292 return self._request_obj(
321 self._urls["popular"],
322 params="page=%s" % page,
323 key="results"
293 self._urls["popular"], params=f"page={page}", key="results"
324294 )
325295
326296 def top_rated(self, page=1):
……@@ -330,9 +300,7 @@ class TV(TMDb):
330300 :return:
331301 """
332302 return self._request_obj(
333 self._urls["top_rated"],
334 params="page=%s" % page,
335 key="results"
303 self._urls["top_rated"], params=f"page={page}", key="results"
336304 )
337305
338306 def search(self, term, page=1):
……@@ -342,6 +310,8 @@ class TV(TMDb):
342310 :param page:
343311 :return:
344312 """
345 warnings.warn("search method is deprecated use tmdbv3api.Search().tv_shows(term)",
346 DeprecationWarning)
313 warnings.warn(
314 "search method is deprecated use tmdbv3api.Search().tv_shows(term)",
315 DeprecationWarning,
316 )
347317 return Search().tv_shows(term, page=page)
plugin.video.jacktook/lib/api/tmdbv3api/tmdb.py +3 -11
……@@ -1,13 +1,9 @@
1# -*- coding: utf-8 -*-
2
31import logging
42import os
53import time
64
75import requests
86
9from lib.utils.kodi.utils import kodilog
10
117from .as_obj import AsObj
128from .exceptions import TMDbException
139
……@@ -19,7 +15,7 @@ except ImportError:
1915logger = logging.getLogger(__name__)
2016
2117
22class TMDb(object):
18class TMDb:
2319 _session = None
2420 TMDB_API_KEY = "TMDB_API_KEY"
2521 TMDB_LANGUAGE = "TMDB_LANGUAGE"
……@@ -178,13 +174,9 @@ class TMDb(object):
178174 if self.wait_on_rate_limit:
179175 logger.warning("Rate limit reached. Sleeping for: %d" % sleep_time)
180176 time.sleep(abs(sleep_time))
181 return self._request_obj(
182 action, params, call_cached, method, data, json, key
183 )
177 return self._request_obj(action, params, call_cached, method, data, json, key)
184178 else:
185 raise TMDbException(
186 "Rate limit reached. Try again in %d seconds." % sleep_time
187 )
179 raise TMDbException("Rate limit reached. Try again in %d seconds." % sleep_time)
188180
189181 json = req.json()
190182
plugin.video.jacktook/lib/api/tmdbv3api/utils.py +11 -7
……@@ -1,15 +1,19 @@
11import datetime
22
3years_tvshows = [{'name': '2024', 'id': 2024}]
3years_tvshows = [{"name": "2024", "id": 2024}]
4
45
56def get_dates(days, reverse=True):
67 current_date = get_current_date(return_str=False)
7 if reverse:
8 new_date = (current_date - datetime.timedelta(days=days)).strftime('%Y-%m-%d')
9 else:
10 new_date = (current_date + datetime.timedelta(days=days)).strftime('%Y-%m-%d')
8 if reverse:
9 new_date = (current_date - datetime.timedelta(days=days)).strftime("%Y-%m-%d")
10 else:
11 new_date = (current_date + datetime.timedelta(days=days)).strftime("%Y-%m-%d")
1112 return str(current_date), new_date
1213
14
1315def get_current_date(return_str=True):
14 if return_str: return str(datetime.date.today())
15 else: return datetime.date.today()
16 if return_str:
17 return str(datetime.date.today())
18 else:
19 return datetime.date.today()
plugin.video.jacktook/lib/api/trakt/base_cache.py +159 -166
……@@ -1,106 +1,104 @@
1# -*- coding: utf-8 -*-
21import ast
2import contextlib
33import os
4import time
54import sqlite3 as database
6from lib.utils.kodi.utils import (
7 delete_file,
8 dialog_ok,
9 get_property,
10 notification,
11 set_property,
12 clear_property,
13 translation,
14)
15
16import xbmcaddon
17from xbmcvfs import translatePath
18
19
20userdata_path = translatePath(xbmcaddon.Addon().getAddonInfo("profile"))
21databases_path = os.path.join(userdata_path, "databases/")
22database_path_raw = os.path.join(userdata_path, "databases")
23trakt_db = translatePath(os.path.join(database_path_raw, "traktcache.db"))
24lists_db = translatePath(os.path.join(database_path_raw, "lists.db"))
25maincache_db = translatePath(os.path.join(database_path_raw, "maincache.db"))
26paginator_db = translatePath(os.path.join(database_path_raw, "paginator.db"))
27database_timeout = 20
28
29
30database_locations = {
31 "trakt_db": trakt_db,
32 "lists_db": lists_db,
33 "maincache_db": maincache_db,
34 "paginator_db": paginator_db,
35}
36
37integrity_check = {
38 "trakt_db": ("trakt_data", "watched_status", "progress"),
39 "maincache_db": ("maincache",),
40 "lists_db": ("lists",),
41}
42
43table_creators = {
44 "trakt_db": (
45 "CREATE TABLE IF NOT EXISTS trakt_data (id text unique, data text)",
46 "CREATE TABLE IF NOT EXISTS watched \
47(db_type text not null, media_id text not null, season integer, episode integer, last_played text, title text, unique (db_type, media_id, season, episode))",
48 "CREATE TABLE IF NOT EXISTS progress \
49(db_type text not null, media_id text not null, season integer, episode integer, resume_point text, curr_time text, \
50last_played text, resume_id integer, title text, unique (db_type, media_id, season, episode))",
51 "CREATE TABLE IF NOT EXISTS watched_status (db_type text not null, media_id text not null, status text, unique (db_type, media_id))",
52 ),
53 "lists_db": (
54 "CREATE TABLE IF NOT EXISTS lists (id text unique, data text, expires integer)",
55 ),
56 "maincache_db": (
57 "CREATE TABLE IF NOT EXISTS maincache (id text unique, data text, expires integer)",
58 ),
59 "paginator_db": (
60 "CREATE TABLE IF NOT EXISTS paginated_data (id text unique, page_number integer, data text, total_pages integer)",
61 ),
62}
63
64media_prop = "jacktook.%s"
65BASE_GET = "SELECT expires, data FROM %s WHERE id = ?"
66BASE_SET = "INSERT OR REPLACE INTO %s(id, data, expires) VALUES (?, ?, ?)"
67BASE_DELETE = "DELETE FROM %s WHERE id = ?"
68
69
70def make_database(database_name):
71 dbcon = database.connect(database_locations[database_name])
72 for command in table_creators[database_name]:
73 dbcon.execute(command)
74 dbcon.close()
75
76
77def setup_databases():
78 if not os.path.exists(databases_path):
79 os.makedirs(databases_path)
80 for database_name, database_location in database_locations.items():
81 dbcon = database.connect(database_location)
82 for command in table_creators[database_name]:
83 dbcon.execute(command)
84
85
86def connect_database(database_name):
87 dbcon = database.connect(
88 database_locations[database_name],
89 timeout=database_timeout,
90 isolation_level=None,
91 check_same_thread=False,
92 )
93 dbcon.execute("PRAGMA synchronous = OFF")
94 dbcon.execute("PRAGMA journal_mode = OFF")
95 return dbcon
96
97
98def get_timestamp(offset=0):
99 # Offset is in HOURS multiply by 3600 to get seconds
100 return int(time.time()) + (offset * 3600)
101
102
103def check_databases_integrity():
5import time
6
7import xbmcaddon
8from xbmcvfs import translatePath
9
10from lib.utils.kodi.utils import (
11 clear_property,
12 delete_file,
13 dialog_ok,
14 get_property,
15 notification,
16 set_property,
17 translation,
18)
19
20userdata_path = translatePath(xbmcaddon.Addon().getAddonInfo("profile"))
21databases_path = os.path.join(userdata_path, "databases/")
22database_path_raw = os.path.join(userdata_path, "databases")
23trakt_db = translatePath(os.path.join(database_path_raw, "traktcache.db"))
24lists_db = translatePath(os.path.join(database_path_raw, "lists.db"))
25maincache_db = translatePath(os.path.join(database_path_raw, "maincache.db"))
26paginator_db = translatePath(os.path.join(database_path_raw, "paginator.db"))
27database_timeout = 20
28
29
30database_locations = {
31 "trakt_db": trakt_db,
32 "lists_db": lists_db,
33 "maincache_db": maincache_db,
34 "paginator_db": paginator_db,
35}
36
37integrity_check = {
38 "trakt_db": ("trakt_data", "watched_status", "progress"),
39 "maincache_db": ("maincache",),
40 "lists_db": ("lists",),
41}
42
43table_creators = {
44 "trakt_db": (
45 "CREATE TABLE IF NOT EXISTS trakt_data (id text unique, data text)",
46 "CREATE TABLE IF NOT EXISTS watched \
47(db_type text not null, media_id text not null, season integer, episode integer, last_played text, title text, unique (db_type, media_id, season, episode))",
48 "CREATE TABLE IF NOT EXISTS progress \
49(db_type text not null, media_id text not null, season integer, episode integer, resume_point text, curr_time text, \
50last_played text, resume_id integer, title text, unique (db_type, media_id, season, episode))",
51 "CREATE TABLE IF NOT EXISTS watched_status (db_type text not null, media_id text not null, status text, unique (db_type, media_id))",
52 ),
53 "lists_db": ("CREATE TABLE IF NOT EXISTS lists (id text unique, data text, expires integer)",),
54 "maincache_db": (
55 "CREATE TABLE IF NOT EXISTS maincache (id text unique, data text, expires integer)",
56 ),
57 "paginator_db": (
58 "CREATE TABLE IF NOT EXISTS paginated_data (id text unique, page_number integer, data text, total_pages integer)",
59 ),
60}
61
62media_prop = "jacktook.%s"
63BASE_GET = "SELECT expires, data FROM %s WHERE id = ?"
64BASE_SET = "INSERT OR REPLACE INTO %s(id, data, expires) VALUES (?, ?, ?)"
65BASE_DELETE = "DELETE FROM %s WHERE id = ?"
66
67
68def make_database(database_name):
69 dbcon = database.connect(database_locations[database_name])
70 for command in table_creators[database_name]:
71 dbcon.execute(command)
72 dbcon.close()
73
74
75def setup_databases():
76 if not os.path.exists(databases_path):
77 os.makedirs(databases_path)
78 for database_name, database_location in database_locations.items():
79 dbcon = database.connect(database_location)
80 for command in table_creators[database_name]:
81 dbcon.execute(command)
82
83
84def connect_database(database_name):
85 dbcon = database.connect(
86 database_locations[database_name],
87 timeout=database_timeout,
88 isolation_level=None,
89 check_same_thread=False,
90 )
91 dbcon.execute("PRAGMA synchronous = OFF")
92 dbcon.execute("PRAGMA journal_mode = OFF")
93 return dbcon
94
95
96def get_timestamp(offset=0):
97 # Offset is in HOURS multiply by 3600 to get seconds
98 return int(time.time()) + (offset * 3600)
99
100
101def check_databases_integrity():
104102 def _process(database_name, tables):
105103 database_location = database_locations[database_name]
106104 dbcon = None
……@@ -108,42 +106,39 @@ def check_databases_integrity():
108106 dbcon = database.connect(database_location)
109107 for db_table in tables:
110108 dbcon.execute(command_base % db_table)
111 except:
112 database_errors.append(database_name)
113 if os.path.exists(database_location):
114 try:
115 dbcon.close()
116 except:
117 pass
118 delete_file(database_location)
119
120 command_base = "SELECT * FROM %s LIMIT 1"
121 database_errors = []
122 for database_name, tables in integrity_check.items():
123 _process(database_name, tables)
124 setup_databases()
125 if database_errors:
126 dialog_ok(
127 heading=translation(90432),
128 line1=translation(90433)
129 % ", ".join(database_errors),
130 )
131 else:
132 notification(translation(90426), time=3000)
133
134
135class BaseCache(object):
136 def __init__(self, dbfile, table):
137 self.table = table
138 self.dbfile = dbfile
139
109 except Exception:
110 database_errors.append(database_name)
111 if os.path.exists(database_location):
112 with contextlib.suppress(BaseException):
113 dbcon.close()
114 delete_file(database_location)
115
116 command_base = "SELECT * FROM %s LIMIT 1"
117 database_errors = []
118 for database_name, tables in integrity_check.items():
119 _process(database_name, tables)
120 setup_databases()
121 if database_errors:
122 dialog_ok(
123 heading=translation(90432),
124 line1=translation(90433) % ", ".join(database_errors),
125 )
126 else:
127 notification(translation(90426), time=3000)
128
129
130class BaseCache:
131 def __init__(self, dbfile, table):
132 self.table = table
133 self.dbfile = dbfile
134
140135 def get(self, string):
141136 result = None
142137 try:
143 current_time = get_timestamp()
144 result = self.get_memory_cache(string, current_time)
145 if result is None:
146 dbcon = connect_database(self.dbfile)
138 current_time = get_timestamp()
139 result = self.get_memory_cache(string, current_time)
140 if result is None:
141 dbcon = connect_database(self.dbfile)
147142 cache_data = dbcon.execute(BASE_GET % self.table, (string,)).fetchone()
148143 if cache_data:
149144 if cache_data[0] > current_time:
……@@ -151,39 +146,37 @@ class BaseCache(object):
151146 self.set_memory_cache(result, string, cache_data[0])
152147 else:
153148 self.delete(string)
154 except:
155 pass
156 return result
157
158 def set(self, string, data, expiration=720):
159 try:
149 except Exception:
150 pass
151 return result
152
153 def set(self, string, data, expiration=720):
154 try:
160155 dbcon = connect_database(self.dbfile)
161156 expires = get_timestamp(expiration)
162 dbcon.execute(
163 BASE_SET % self.table, (string, self._serialize(data), int(expires))
164 )
157 dbcon.execute(BASE_SET % self.table, (string, self._serialize(data), int(expires)))
165158 self.set_memory_cache(data, string, int(expires))
166 except:
159 except Exception:
167160 return None
168
169 def get_memory_cache(self, string, current_time):
170 result = None
161
162 def get_memory_cache(self, string, current_time):
163 result = None
171164 try:
172165 cachedata = get_property(media_prop % string)
173166 if cachedata:
174167 cachedata = self._deserialize(cachedata)
175168 if cachedata[0] > current_time:
176169 result = cachedata[1]
177 except:
178 pass
179 return result
180
181 def set_memory_cache(self, data, string, expires):
170 except Exception:
171 pass
172 return result
173
174 def set_memory_cache(self, data, string, expires):
182175 try:
183176 cachedata = (expires, data)
184177 cachedata_repr = self._serialize(cachedata)
185178 set_property(media_prop % string, cachedata_repr)
186 except:
179 except Exception:
187180 pass
188181
189182 @staticmethod
……@@ -193,17 +186,17 @@ class BaseCache(object):
193186 @staticmethod
194187 def _deserialize(data):
195188 return ast.literal_eval(data)
196
197 def delete(self, string):
198 try:
199 dbcon = connect_database(self.dbfile)
200 dbcon.execute(BASE_DELETE % self.table, (string,))
201 self.delete_memory_cache(string)
202 except:
203 pass
204
205 def delete_memory_cache(self, string):
206 clear_property(media_prop % string)
207
208 def manual_connect(self, dbfile):
209 return connect_database(dbfile)
189
190 def delete(self, string):
191 try:
192 dbcon = connect_database(self.dbfile)
193 dbcon.execute(BASE_DELETE % self.table, (string,))
194 self.delete_memory_cache(string)
195 except Exception:
196 pass
197
198 def delete_memory_cache(self, string):
199 clear_property(media_prop % string)
200
201 def manual_connect(self, dbfile):
202 return connect_database(dbfile)
plugin.video.jacktook/lib/api/trakt/lists_cache.py +3 -4
……@@ -1,6 +1,5 @@
11from lib.api.trakt.base_cache import BaseCache, get_timestamp
22
3
43GET_ALL = "SELECT id FROM lists"
54DELETE_ALL = "DELETE FROM lists"
65DELETE_PREFIX = 'DELETE FROM lists WHERE id LIKE "%s%%"'
……@@ -19,7 +18,7 @@ class ListsCache(BaseCache):
1918 dbcon.execute(DELETE_ALL)
2019 dbcon.execute("VACUUM")
2120 return True
22 except:
21 except Exception:
2322 return False
2423
2524 def delete_prefix(self, prefix):
……@@ -32,7 +31,7 @@ class ListsCache(BaseCache):
3231 dbcon.execute(DELETE_PREFIX % prefix)
3332 dbcon.execute("VACUUM")
3433 return True
35 except:
34 except Exception:
3635 return False
3736
3837 def clean_database(self):
……@@ -41,7 +40,7 @@ class ListsCache(BaseCache):
4140 dbcon.execute(CLEAN, (get_timestamp(),))
4241 dbcon.execute("VACUUM")
4342 return True
44 except:
43 except Exception:
4544 return False
4645
4746
plugin.video.jacktook/lib/api/trakt/main_cache.py +4 -6
……@@ -1,6 +1,5 @@
11from lib.api.trakt.base_cache import BaseCache, get_timestamp
22
3
43GET_ALL = "SELECT id FROM maincache"
54DELETE_ALL = "DELETE FROM maincache"
65LIKE_SELECT = "SELECT id from maincache where id LIKE %s"
……@@ -20,14 +19,13 @@ class MainCache(BaseCache):
2019 dbcon.execute(DELETE_ALL)
2120 dbcon.execute("VACUUM")
2221 return True
23 except:
22 except Exception:
2423 return False
2524
2625 def delete_all_folderscrapers(self):
2726 dbcon = self.manual_connect("maincache_db")
2827 remove_list = [
29 str(i[0])
30 for i in dbcon.execute(LIKE_SELECT % "'FOLDERSCRAPER_%'").fetchall()
28 str(i[0]) for i in dbcon.execute(LIKE_SELECT % "'FOLDERSCRAPER_%'").fetchall()
3129 ]
3230 if not remove_list:
3331 return True
……@@ -37,7 +35,7 @@ class MainCache(BaseCache):
3735 for item in remove_list:
3836 self.delete_memory_cache(str(item))
3937 return True
40 except:
38 except Exception:
4139 return False
4240
4341 def clean_database(self):
……@@ -46,7 +44,7 @@ class MainCache(BaseCache):
4644 dbcon.execute(CLEAN, (get_timestamp(),))
4745 dbcon.execute("VACUUM")
4846 return True
49 except:
47 except Exception:
5048 return False
5149
5250
plugin.video.jacktook/lib/api/trakt/trakt.py +115 -138
……@@ -1,16 +1,15 @@
1import json
1import contextlib
22import datetime
3import json
34import random
45import time
56from typing import Any, Dict
7
68import requests
7from lib.utils.general.utils import set_pluging_category
89import xbmc
910
10from lib.api.trakt.lists_cache import lists_cache
1111from lib.api.trakt.base_cache import BASE_DELETE, connect_database
12from lib.api.trakt.lists_cache import lists_cache_object
13from lib.api.trakt.main_cache import cache_object
12from lib.api.trakt.lists_cache import lists_cache, lists_cache_object
1413from lib.api.trakt.main_cache import cache_object
1514from lib.api.trakt.trakt_cache import (
1615 cache_trakt_object,
……@@ -24,6 +23,13 @@ from lib.api.trakt.trakt_utils import clean_ids, sort_for_article, sort_list
2423from lib.gui.qr_progress_dialog import QRProgressDialog
2524from lib.jacktook.utils import ADDON_PATH
2625from lib.utils.debrid.qrcode_utils import make_qrcode
26from lib.utils.general.utils import set_pluging_category
27from lib.utils.kodi.settings import (
28 EMPTY_USER,
29 trakt_client,
30 trakt_lists_sort_order,
31 trakt_secret,
32)
2733from lib.utils.kodi.utils import (
2834 copy2clip,
2935 get_datetime,
……@@ -33,15 +39,8 @@ from lib.utils.kodi.utils import (
3339 set_property,
3440 set_setting,
3541 sleep,
36 progressDialog,
3742 translation,
3843)
39from lib.utils.kodi.settings import (
40 EMPTY_USER,
41 trakt_client,
42 trakt_lists_sort_order,
43 trakt_secret,
44)
4544
4645
4746class TraktBase:
……@@ -81,10 +80,8 @@ class TraktBase:
8180 set_setting("trakt_user", EMPTY_USER)
8281 set_setting("is_trakt_auth", "false")
8382 notification(translation(90408), time=5000)
84 try:
83 with contextlib.suppress(BaseException):
8584 TraktCache().clear_all_trakt_cache_data()
86 except:
87 pass
8885
8986 def call_trakt(
9087 self,
……@@ -124,11 +121,9 @@ class TraktBase:
124121 retry_after = int(response.headers.get("Retry-After", 1))
125122 kodilog(f"Trakt Rate Limit Exceeded. Retrying in {retry_after} seconds.")
126123 sleep(retry_after * 1000)
127 response = self._send_request(
128 path, params, data, headers, is_delete, method
129 )
124 response = self._send_request(path, params, data, headers, is_delete, method)
130125
131 kodilog("Trakt response status code: %s" % response.status_code)
126 kodilog(f"Trakt response status code: {response.status_code}")
132127 try:
133128 response.raise_for_status()
134129 except requests.HTTPError as error:
……@@ -149,36 +144,30 @@ class TraktBase:
149144 503: "Service Unavailable",
150145 504: "Gateway Timeout",
151146 }
152 error_message = error_messages.get(
153 status_code, f"HTTP Error: {status_code}"
154 )
147 error_message = error_messages.get(status_code, f"HTTP Error: {status_code}")
155148 kodilog(f"Trakt API error: {response.text}")
156 raise ProviderException(f"Trakt API error: {error_message}")
149 raise ProviderException(f"Trakt API error: {error_message}") from error
157150 except requests.RequestException as error:
158151 error_message = f"Trakt API error: {error}"
159 raise ProviderException(error_message)
152 raise ProviderException(error_message) from error
160153
161154 return self._process_response(response, method, pagination)
162155
163156 def _send_request(self, path, params, data, headers, is_delete, method):
164157 url = self.api_endpoint % path
165 kodilog("Trakt URL: %s" % url)
158 kodilog(f"Trakt URL: {url}")
166159 if method == "post":
167160 return requests.post(url, json=data, headers=headers, timeout=self.timeout)
168161 elif method == "delete":
169162 return requests.delete(url, json=data, headers=headers, timeout=self.timeout)
170163 elif method == "sort_by_headers":
171 return requests.get(
172 url, params=params, headers=headers, timeout=self.timeout
173 )
164 return requests.get(url, params=params, headers=headers, timeout=self.timeout)
174165 elif data is not None:
175166 return requests.post(url, json=data, headers=headers, timeout=self.timeout)
176167 elif is_delete:
177168 return requests.delete(url, headers=headers, timeout=self.timeout)
178169 else:
179 return requests.get(
180 url, params=params, headers=headers, timeout=self.timeout
181 )
170 return requests.get(url, params=params, headers=headers, timeout=self.timeout)
182171
183172 def _process_response(self, response, method, pagination):
184173 response.encoding = "utf-8"
……@@ -190,12 +179,8 @@ class TraktBase:
190179 if method == "sort_by_headers":
191180 headers = response.headers
192181 if "X-Sort-By" in headers and "X-Sort-How" in headers:
193 try:
194 result = sort_list(
195 headers["X-Sort-By"], headers["X-Sort-How"], result
196 )
197 except Exception:
198 pass
182 with contextlib.suppress(Exception):
183 result = sort_list(headers["X-Sort-By"], headers["X-Sort-How"], result)
199184
200185 if pagination:
201186 return result, response.headers.get("X-Pagination-Page-Count")
……@@ -334,10 +319,8 @@ class TraktAuthentication(TraktBase):
334319
335320 # Generate QR code for the verification URL
336321 qr_code = make_qrcode(verification_url)
337 try:
322 with contextlib.suppress(BaseException):
338323 copy2clip(user_code)
339 except:
340 pass
341324
342325 # Create and display QR code dialog
343326 progressDialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH)
……@@ -372,12 +355,10 @@ class TraktAuthentication(TraktBase):
372355 progressDialog.update_progress(progress)
373356 else:
374357 break
375 except:
358 except Exception:
376359 pass
377 try:
360 with contextlib.suppress(BaseException):
378361 progressDialog.close_dialog()
379 except:
380 pass
381362 return result
382363
383364 def trakt_authenticate(self):
……@@ -397,7 +378,7 @@ class TraktAuthentication(TraktBase):
397378 set_setting("is_trakt_auth", "true")
398379 notification(translation(90385), time=3000)
399380 return True
400 except:
381 except Exception:
401382 kodilog("Trakt user not found, setting to empty user")
402383 set_setting("is_trakt_auth", "false")
403384 notification(translation(90384), time=3000)
……@@ -429,7 +410,7 @@ class TraktAuthentication(TraktBase):
429410class TraktMovies(TraktBase):
430411 def trakt_movies_trending(self, page_no):
431412 set_pluging_category(translation(90028))
432 string = "trakt_movies_trending_%s" % page_no
413 string = f"trakt_movies_trending_{page_no}"
433414 params = {
434415 "path": "movies/trending",
435416 "params": {"limit": 20},
……@@ -440,8 +421,8 @@ class TraktMovies(TraktBase):
440421 def trakt_movies_trending_recent(self, page_no):
441422 set_pluging_category(translation(90024))
442423 current_year = get_datetime().year
443 years = "%s-%s" % (str(current_year - 1), str(current_year))
444 string = "trakt_movies_trending_recent_%s" % page_no
424 years = f"{current_year - 1!s}-{current_year!s}"
425 string = f"trakt_movies_trending_recent_{page_no}"
445426 params = {
446427 "path": "movies/trending",
447428 "params": {"limit": 20, "years": years},
……@@ -457,7 +438,7 @@ class TraktMovies(TraktBase):
457438
458439 def trakt_movies_most_watched(self, page_no):
459440 set_pluging_category(translation(90029))
460 string = "trakt_movies_most_watched_%s" % page_no
441 string = f"trakt_movies_most_watched_{page_no}"
461442 params = {
462443 "path": "movies/watched/daily",
463444 "params": {"limit": 20},
……@@ -467,7 +448,7 @@ class TraktMovies(TraktBase):
467448
468449 def trakt_movies_most_favorited(self, page_no):
469450 set_pluging_category(translation(90030))
470 string = "trakt_movies_most_favorited%s" % page_no
451 string = f"trakt_movies_most_favorited{page_no}"
471452 params = {
472453 "path": "movies/favorited/daily",
473454 "params": {"limit": 20},
……@@ -477,7 +458,7 @@ class TraktMovies(TraktBase):
477458
478459 def trakt_recommendations(self, media_type):
479460 set_pluging_category(translation(90033))
480 string = "trakt_recommendations_%s" % (media_type)
461 string = f"trakt_recommendations_{media_type}"
481462 params = {
482463 "path": "/recommendations/%s",
483464 "path_insert": media_type,
……@@ -521,7 +502,7 @@ class TraktMovies(TraktBase):
521502class TraktTV(TraktBase):
522503 def trakt_tv_trending(self, page_no):
523504 set_pluging_category(translation(90028))
524 string = "trakt_tv_trending_%s" % page_no
505 string = f"trakt_tv_trending_{page_no}"
525506 params = {
526507 "path": "shows/trending",
527508 "params": {"limit": 20},
……@@ -532,8 +513,8 @@ class TraktTV(TraktBase):
532513 def trakt_tv_trending_recent(self, page_no):
533514 set_pluging_category(translation(90024))
534515 current_year = get_datetime().year
535 years = "%s-%s" % (str(current_year - 1), str(current_year))
536 string = "trakt_tv_trending_recent_%s" % page_no
516 years = f"{current_year - 1!s}-{current_year!s}"
517 string = f"trakt_tv_trending_recent_{page_no}"
537518 params = {
538519 "path": "shows/trending",
539520 "params": {"limit": 20, "years": years},
……@@ -543,7 +524,7 @@ class TraktTV(TraktBase):
543524
544525 def trakt_tv_most_watched(self, page_no):
545526 set_pluging_category(translation(90029))
546 string = "trakt_tv_most_watched_%s" % page_no
527 string = f"trakt_tv_most_watched_{page_no}"
547528 params = {
548529 "path": "shows/watched/daily",
549530 "params": {"limit": 20},
……@@ -553,7 +534,7 @@ class TraktTV(TraktBase):
553534
554535 def trakt_tv_most_favorited(self, page_no):
555536 set_pluging_category(translation(90030))
556 string = "trakt_tv_most_favorited_%s" % page_no
537 string = f"trakt_tv_most_favorited_{page_no}"
557538 params = {
558539 "path": "shows/favorited/daily",
559540 "params": {"limit": 20},
……@@ -563,7 +544,7 @@ class TraktTV(TraktBase):
563544
564545 def trakt_recommendations(self, media_type):
565546 set_pluging_category(translation(90033))
566 string = "trakt_recommendations_%s" % (media_type)
547 string = f"trakt_recommendations_{media_type}"
567548 params = {
568549 "path": "/recommendations/%s",
569550 "path_insert": media_type,
……@@ -621,9 +602,7 @@ class TraktTV(TraktBase):
621602 continue
622603
623604 episode_key = (int(season_number), int(episode_number))
624 watched_at = episode.get("last_watched_at") or show_item.get(
625 "last_watched_at"
626 )
605 watched_at = episode.get("last_watched_at") or show_item.get("last_watched_at")
627606 watched_dt = TraktTV._parse_iso_date(watched_at)
628607
629608 if watched_dt:
……@@ -645,15 +624,11 @@ class TraktTV(TraktBase):
645624 start_season, start_episode = starting_point
646625
647626 for season_number in range(max(1, int(start_season)), int(total_seasons) + 1):
648 season_details = fetcher(
649 "season_details", {"id": tmdb_id, "season": season_number}
650 )
627 season_details = fetcher("season_details", {"id": tmdb_id, "season": season_number})
651628 episodes = getattr(season_details, "episodes", []) or []
652629 for episode in episodes:
653630 episode_number = getattr(episode, "episode_number", 0) or 0
654 if season_number == int(start_season) and episode_number <= int(
655 start_episode
656 ):
631 if season_number == int(start_season) and episode_number <= int(start_episode):
657632 continue
658633
659634 air_date = getattr(episode, "air_date", None)
……@@ -670,7 +645,9 @@ class TraktTV(TraktBase):
670645 return None
671646
672647 @classmethod
673 def _build_up_next_entries(cls, watched_shows, progress_items, fetcher=None, now_dt=None, hidden_items=None):
648 def _build_up_next_entries(
649 cls, watched_shows, progress_items, fetcher=None, now_dt=None, hidden_items=None
650 ):
674651 if fetcher is None:
675652 from lib.clients.tmdb.utils.utils import tmdb_get as fetcher
676653
……@@ -772,9 +749,9 @@ class TraktTV(TraktBase):
772749 hidden.append(int(tmdb_id))
773750 return hidden
774751
775 string = "trakt_hidden_items_%s" % section
752 string = f"trakt_hidden_items_{section}"
776753 params = {
777 "path": "users/hidden/%s" % section,
754 "path": f"users/hidden/{section}",
778755 "params": {"limit": 1000},
779756 "with_auth": True,
780757 "pagination": False,
……@@ -804,9 +781,9 @@ class TraktAnime(TraktBase):
804781
805782 def trakt_anime_trending(self, page_no, mode="tv"):
806783 path_prefix = self._get_path_prefix(mode)
807 string = "trakt_anime_trending_%s_%s" % (mode, page_no)
784 string = f"trakt_anime_trending_{mode}_{page_no}"
808785 params = {
809 "path": "%s/trending" % path_prefix,
786 "path": f"{path_prefix}/trending",
810787 "params": {"genres": "anime", "limit": 20},
811788 "page_no": page_no,
812789 }
……@@ -815,10 +792,10 @@ class TraktAnime(TraktBase):
815792 def trakt_anime_trending_recent(self, page_no, mode="tv"):
816793 path_prefix = self._get_path_prefix(mode)
817794 current_year = get_datetime().year
818 years = "%s-%s" % (str(current_year - 1), str(current_year))
819 string = "trakt_anime_trending_recent_%s_%s" % (mode, page_no)
795 years = f"{current_year - 1!s}-{current_year!s}"
796 string = f"trakt_anime_trending_recent_{mode}_{page_no}"
820797 params = {
821 "path": "%s/trending" % path_prefix,
798 "path": f"{path_prefix}/trending",
822799 "params": {"genres": "anime", "limit": 20, "years": years},
823800 "page_no": page_no,
824801 }
……@@ -826,9 +803,9 @@ class TraktAnime(TraktBase):
826803
827804 def trakt_anime_most_watched(self, page_no, mode="tv"):
828805 path_prefix = self._get_path_prefix(mode)
829 string = "trakt_anime_most_watched_%s_%s" % (mode, page_no)
806 string = f"trakt_anime_most_watched_{mode}_{page_no}"
830807 params = {
831 "path": "%s/watched/daily" % path_prefix,
808 "path": f"{path_prefix}/watched/daily",
832809 "params": {"genres": "anime", "limit": 20},
833810 "page_no": page_no,
834811 }
……@@ -836,9 +813,9 @@ class TraktAnime(TraktBase):
836813
837814 def trakt_anime_most_favorited(self, page_no, mode="tv"):
838815 path_prefix = self._get_path_prefix(mode)
839 string = "trakt_anime_most_favorited_%s_%s" % (mode, page_no)
816 string = f"trakt_anime_most_favorited_{mode}_{page_no}"
840817 params = {
841 "path": "%s/favorited/daily" % path_prefix,
818 "path": f"{path_prefix}/favorited/daily",
842819 "params": {"genres": "anime", "limit": 20},
843820 "page_no": page_no,
844821 }
……@@ -925,9 +902,7 @@ class TraktLists(TraktBase):
925902 for episode in episodes:
926903 episode_number = episode.get("number")
927904 if episode_number:
928 season_payload["episodes"].append(
929 {"number": int(episode_number)}
930 )
905 season_payload["episodes"].append({"number": int(episode_number)})
931906 payload["shows"][0]["seasons"].append(season_payload)
932907 return payload
933908
……@@ -936,7 +911,7 @@ class TraktLists(TraktBase):
936911 def trakt_watchlist(self, media_type):
937912 kodilog("Fetching trakt watchlist")
938913 result = self.trakt_fetch_sorted_list("watchlist", media_type)
939 kodilog("Watchlist result: %s" % result, level=xbmc.LOGDEBUG)
914 kodilog(f"Watchlist result: {result}", level=xbmc.LOGDEBUG)
940915 return result
941916
942917 def add_to_watchlist(self, media_type, ids):
……@@ -954,7 +929,9 @@ class TraktLists(TraktBase):
954929 with_auth=True,
955930 pagination=False,
956931 )
957 lists_cache.delete_prefix("trakt_%s_watchlist_" % ("movies" if media_type == "movies" else "tv"))
932 lists_cache.delete_prefix(
933 "trakt_%s_watchlist_" % ("movies" if media_type == "movies" else "tv")
934 )
958935 clear_trakt_collection_watchlist_data("watchlist", original_media_type)
959936 return result
960937
……@@ -972,7 +949,9 @@ class TraktLists(TraktBase):
972949 with_auth=True,
973950 pagination=False,
974951 )
975 lists_cache.delete_prefix("trakt_%s_watchlist_" % ("movies" if media_type == "movies" else "tv"))
952 lists_cache.delete_prefix(
953 "trakt_%s_watchlist_" % ("movies" if media_type == "movies" else "tv")
954 )
976955 clear_trakt_collection_watchlist_data("watchlist", original_media_type)
977956 return result
978957
……@@ -1011,7 +990,9 @@ class TraktLists(TraktBase):
1011990 with_auth=True,
1012991 pagination=False,
1013992 )
1014 lists_cache.delete_prefix("trakt_%s_collection_" % ("movies" if media_type == "movies" else "tv"))
993 lists_cache.delete_prefix(
994 "trakt_%s_collection_" % ("movies" if media_type == "movies" else "tv")
995 )
1015996 clear_trakt_collection_watchlist_data("collection", original_media_type)
1016997 return result
1017998
……@@ -1029,7 +1010,9 @@ class TraktLists(TraktBase):
10291010 with_auth=True,
10301011 pagination=False,
10311012 )
1032 lists_cache.delete_prefix("trakt_%s_collection_" % ("movies" if media_type == "movies" else "tv"))
1013 lists_cache.delete_prefix(
1014 "trakt_%s_collection_" % ("movies" if media_type == "movies" else "tv")
1015 )
10331016 clear_trakt_collection_watchlist_data("collection", original_media_type)
10341017 return result
10351018
……@@ -1091,7 +1074,7 @@ class TraktLists(TraktBase):
10911074 def mark_as_watched(self, media_type, season, episode, ids):
10921075 payload = self._history_payload(media_type, season, episode, ids)
10931076
1094 kodilog("Payload: %s" % payload)
1077 kodilog(f"Payload: {payload}")
10951078
10961079 response = self.call_trakt(
10971080 "sync/history",
……@@ -1154,9 +1137,7 @@ class TraktLists(TraktBase):
11541137 },
11551138 "title": item[media_key]["title"],
11561139 "collected_at": item.get(collected_at_key),
1157 "released": item[media_key].get(
1158 release_date_key, default_release_date
1159 ),
1140 "released": item[media_key].get(release_date_key, default_release_date),
11601141 }
11611142 for item in raw_data
11621143 ]
……@@ -1231,7 +1212,7 @@ class TraktLists(TraktBase):
12311212
12321213 return normalized
12331214
1234 string = "trakt_search_lists_%s_%s" % (search_title, page_no)
1215 string = f"trakt_search_lists_{search_title}_{page_no}"
12351216 return cache_object(_process, string, "dummy_arg", False, 4)
12361217
12371218 def trakt_favorites(self, media_type):
……@@ -1253,7 +1234,7 @@ class TraktLists(TraktBase):
12531234 ]
12541235
12551236 media_type = "movies" if media_type in ("movie", "movies") else "shows"
1256 string = "trakt_favorites_%s" % media_type
1237 string = f"trakt_favorites_{media_type}"
12571238 params = {
12581239 "path": "users/me/favorites/%s/%s",
12591240 "path_insert": (media_type, "title"),
……@@ -1278,7 +1259,7 @@ class TraktLists(TraktBase):
12781259 if i["type"] in ("movie", "show")
12791260 ]
12801261
1281 string = "trakt_list_contents_%s_%s_%s_%s" % (list_type, user, slug, trakt_id)
1262 string = f"trakt_list_contents_{list_type}_{user}_{slug}_{trakt_id}"
12821263 if list_type == "my_lists" and trakt_id:
12831264 params = {
12841265 "path": "users/me/lists/%s/items",
……@@ -1307,7 +1288,7 @@ class TraktLists(TraktBase):
13071288
13081289 def trakt_trending_popular_lists(self, list_type, page_no):
13091290 set_pluging_category(translation(90072))
1310 string = "trakt_%s_user_lists_%s" % (list_type, page_no)
1291 string = f"trakt_{list_type}_user_lists_{page_no}"
13111292 params = {
13121293 "path": "lists/%s",
13131294 "path_insert": list_type,
……@@ -1404,7 +1385,7 @@ class TraktLists(TraktBase):
14041385
14051386 def delete_list(self, trakt_id):
14061387 response = self.call_trakt(
1407 "users/me/lists/%s" % trakt_id,
1388 f"users/me/lists/{trakt_id}",
14081389 method="delete",
14091390 with_auth=True,
14101391 pagination=False,
……@@ -1413,25 +1394,33 @@ class TraktLists(TraktBase):
14131394 clear_trakt_list_contents_data("my_lists")
14141395 return response
14151396
1416 def like_list(self, user_slug, trakt_id):
1417 path = f"users/{user_slug}/lists/{trakt_id}/like" if user_slug else "lists/%s/like" % trakt_id
1418 response = self.call_trakt(
1419 path,
1420 data={},
1421 with_auth=True,
1422 pagination=False,
1423 )
1424 clear_trakt_list_data("liked_lists")
1425 return response
1426
1427 def unlike_list(self, user_slug, trakt_id):
1428 path = f"users/{user_slug}/lists/{trakt_id}/like" if user_slug else "lists/%s/like" % trakt_id
1429 response = self.call_trakt(
1430 path,
1431 method="delete",
1432 with_auth=True,
1433 pagination=False,
1434 )
1397 def like_list(self, user_slug, trakt_id):
1398 path = (
1399 f"users/{user_slug}/lists/{trakt_id}/like"
1400 if user_slug
1401 else f"lists/{trakt_id}/like"
1402 )
1403 response = self.call_trakt(
1404 path,
1405 data={},
1406 with_auth=True,
1407 pagination=False,
1408 )
1409 clear_trakt_list_data("liked_lists")
1410 return response
1411
1412 def unlike_list(self, user_slug, trakt_id):
1413 path = (
1414 f"users/{user_slug}/lists/{trakt_id}/like"
1415 if user_slug
1416 else f"lists/{trakt_id}/like"
1417 )
1418 response = self.call_trakt(
1419 path,
1420 method="delete",
1421 with_auth=True,
1422 pagination=False,
1423 )
14351424 clear_trakt_list_data("liked_lists")
14361425 clear_trakt_list_contents_data("liked_lists")
14371426 return response
……@@ -1447,23 +1436,15 @@ class TraktLists(TraktBase):
14471436 )
14481437 for key in ("tmdb", "tvdb"):
14491438 if key in normalized_ids:
1450 try:
1439 with contextlib.suppress(TypeError, ValueError):
14511440 normalized_ids[key] = int(normalized_ids[key])
1452 except (TypeError, ValueError):
1453 pass
14541441
1455 payload = {
1456 media_key: [
1457 {
1458 "ids": normalized_ids
1459 }
1460 ]
1461 }
1442 payload = {media_key: [{"ids": normalized_ids}]}
14621443 return payload
14631444
14641445 def add_item_to_list(self, trakt_id, media_type, ids):
14651446 response = self.call_trakt(
1466 "users/me/lists/%s/items" % trakt_id,
1447 f"users/me/lists/{trakt_id}/items",
14671448 data=self._list_item_payload(media_type, ids),
14681449 with_auth=True,
14691450 pagination=False,
……@@ -1474,7 +1455,7 @@ class TraktLists(TraktBase):
14741455
14751456 def remove_item_from_list(self, trakt_id, media_type, ids):
14761457 response = self.call_trakt(
1477 "users/me/lists/%s/items/remove" % trakt_id,
1458 f"users/me/lists/{trakt_id}/items/remove",
14781459 data=self._list_item_payload(media_type, ids),
14791460 with_auth=True,
14801461 pagination=False,
……@@ -1496,8 +1477,10 @@ class TraktLists(TraktBase):
14961477class TraktScrobble(TraktBase):
14971478 @staticmethod
14981479 def _scrobble_payload(data):
1499 from lib.utils.kodi.utils import ADDON_VERSION
15001480 from datetime import datetime
1481
1482 from lib.utils.kodi.utils import ADDON_VERSION
1483
15011484 progress = data.get("progress", 0)
15021485 if isinstance(progress, str):
15031486 try:
……@@ -1508,7 +1491,7 @@ class TraktScrobble(TraktBase):
15081491 payload: Dict[str, Any] = {
15091492 "progress": progress,
15101493 "app_version": ADDON_VERSION,
1511 "app_date": datetime.now().strftime("%Y-%m-%d")
1494 "app_date": datetime.now().strftime("%Y-%m-%d"),
15121495 }
15131496
15141497 if data["mode"] == "movies":
……@@ -1556,9 +1539,7 @@ class TraktScrobble(TraktBase):
15561539
15571540 # Check local cache first
15581541 if data["mode"] == "movies":
1559 cached_progress = trakt_watched_cache.get_progress(
1560 "movie", str(tmdb_id)
1561 )
1542 cached_progress = trakt_watched_cache.get_progress("movie", str(tmdb_id))
15621543 else:
15631544 cached_progress = trakt_watched_cache.get_progress(
15641545 "episode", str(tmdb_id), season, episode
……@@ -1578,9 +1559,7 @@ class TraktScrobble(TraktBase):
15781559 local_tmdb = int(tmdb_id)
15791560 if trakt_tmdb == local_tmdb:
15801561 progress = item.get("progress", 0)
1581 kodilog(
1582 f"Trakt: Found cloud resume point for movie: {progress}%"
1583 )
1562 kodilog(f"Trakt: Found cloud resume point for movie: {progress}%")
15841563 return progress
15851564 elif item["type"] == "episode":
15861565 trakt_tmdb = int(item["show"]["ids"]["tmdb"])
……@@ -1592,9 +1571,7 @@ class TraktScrobble(TraktBase):
15921571 and item["episode"]["number"] == int(episode)
15931572 ):
15941573 progress = item.get("progress", 0)
1595 kodilog(
1596 f"Trakt: Found cloud resume point for episode: {progress}%"
1597 )
1574 kodilog(f"Trakt: Found cloud resume point for episode: {progress}%")
15981575 return progress
15991576 except Exception:
16001577 continue
……@@ -1630,7 +1607,7 @@ class TraktCache(TraktBase):
16301607 dbcon.execute(BASE_DELETE % table)
16311608 dbcon.execute("VACUUM")
16321609 return True
1633 except:
1610 except Exception:
16341611 return False
16351612
16361613 def clear_cache(self, cache_type):
plugin.video.jacktook/lib/api/trakt/trakt_cache.py +20 -23
……@@ -1,6 +1,5 @@
11from lib.api.trakt.base_cache import connect_database
22
3
43SELECT = "SELECT id FROM trakt_data"
54DELETE = "DELETE FROM trakt_data WHERE id=?"
65DELETE_LIKE = 'DELETE FROM trakt_data WHERE id LIKE "%s"'
……@@ -24,7 +23,7 @@ class TraktCache:
2423 cache_data = dbcon.execute(TC_BASE_GET, (string,)).fetchone()
2524 if cache_data:
2625 result = eval(cache_data[0])
27 except:
26 except Exception:
2827 pass
2928 return result
3029
……@@ -32,14 +31,14 @@ class TraktCache:
3231 try:
3332 dbcon = connect_database("trakt_db")
3433 dbcon.execute(TC_BASE_SET, (string, repr(data)))
35 except:
34 except Exception:
3635 return None
3736
3837 def delete(self, string):
3938 try:
4039 dbcon = connect_database("trakt_db")
4140 dbcon.execute(TC_BASE_DELETE, (string,))
42 except:
41 except Exception:
4342 pass
4443
4544 def clear_all(self):
……@@ -47,7 +46,7 @@ class TraktCache:
4746 dbcon = connect_database("trakt_db")
4847 dbcon.execute("DELETE FROM trakt_data")
4948 dbcon.execute("VACUUM")
50 except:
49 except Exception:
5150 pass
5251
5352
……@@ -90,7 +89,7 @@ class TraktWatched:
9089 self._delete("DELETE FROM watched", ())
9190 self._delete("DELETE FROM progress", ())
9291 self._delete("DELETE FROM watched_status", ())
93 except:
92 except Exception:
9493 pass
9594
9695 def _executemany(self, command, insert_list):
……@@ -114,16 +113,14 @@ class TraktWatched:
114113
115114 result = dbcon.execute(command, args).fetchone()
116115 return result is not None
117 except:
116 except Exception:
118117 return False
119118
120119 def get_progress(self, db_type, media_id, season=None, episode=None):
121120 try:
122121 dbcon = connect_database("trakt_db")
123122 if db_type == "movie":
124 command = (
125 "SELECT resume_point FROM progress WHERE db_type=? AND media_id=?"
126 )
123 command = "SELECT resume_point FROM progress WHERE db_type=? AND media_id=?"
127124 args = (db_type, media_id)
128125 else:
129126 command = "SELECT resume_point FROM progress WHERE db_type=? AND media_id=? AND season=? AND episode=?"
……@@ -133,7 +130,7 @@ class TraktWatched:
133130 if result:
134131 return float(result[0])
135132 return 0.0
136 except:
133 except Exception:
137134 return 0.0
138135
139136
……@@ -160,17 +157,17 @@ def reset_activity(latest_activities):
160157 cached_data = default_activities()
161158 dbcon.execute(DELETE, (string,))
162159 trakt_cache.set(string, latest_activities)
163 except:
160 except Exception:
164161 cached_data = default_activities()
165162 return cached_data
166163
167164
168165def clear_trakt_hidden_data(list_type):
169 string = "trakt_hidden_items_%s" % list_type
166 string = f"trakt_hidden_items_{list_type}"
170167 try:
171168 dbcon = connect_database("trakt_db")
172169 dbcon.execute(DELETE, (string,))
173 except:
170 except Exception:
174171 pass
175172
176173
……@@ -179,11 +176,11 @@ def clear_trakt_collection_watchlist_data(list_type, media_type):
179176 media_type = "movie"
180177 if media_type in ("tvshows", "shows"):
181178 media_type = "tvshow"
182 string = "trakt_%s_%s" % (list_type, media_type)
179 string = f"trakt_{list_type}_{media_type}"
183180 try:
184181 dbcon = connect_database("trakt_db")
185182 dbcon.execute(DELETE, (string,))
186 except:
183 except Exception:
187184 pass
188185
189186
……@@ -192,16 +189,16 @@ def clear_trakt_list_contents_data(list_type):
192189 try:
193190 dbcon = connect_database("trakt_db")
194191 dbcon.execute(DELETE_LIKE % string)
195 except:
192 except Exception:
196193 pass
197194
198195
199196def clear_trakt_list_data(list_type):
200 string = "trakt_%s" % list_type
197 string = f"trakt_{list_type}"
201198 try:
202199 dbcon = connect_database("trakt_db")
203200 dbcon.execute(DELETE, (string,))
204 except:
201 except Exception:
205202 pass
206203
207204
……@@ -209,7 +206,7 @@ def clear_trakt_calendar():
209206 try:
210207 dbcon = connect_database("trakt_db")
211208 dbcon.execute(DELETE_LIKE % "trakt_get_my_calendar_%")
212 except:
209 except Exception:
213210 return
214211
215212
……@@ -217,7 +214,7 @@ def clear_trakt_recommendations():
217214 try:
218215 dbcon = connect_database("trakt_db")
219216 dbcon.execute(DELETE_LIKE % "trakt_recommendations_%")
220 except:
217 except Exception:
221218 return
222219
223220
……@@ -225,7 +222,7 @@ def clear_trakt_favorites():
225222 try:
226223 dbcon = connect_database("trakt_db")
227224 dbcon.execute(DELETE_LIKE % "trakt_favorites_%")
228 except:
225 except Exception:
229226 return
230227
231228
……@@ -236,7 +233,7 @@ def clear_trakt_watchlist():
236233 try:
237234 dbcon = connect_database("trakt_db")
238235 dbcon.execute(DELETE_LIKE % "trakt_watchlist_%")
239 except:
236 except Exception:
240237 pass
241238
242239
plugin.video.jacktook/lib/api/trakt/trakt_utils.py +130 -116
……@@ -1,11 +1,13 @@
1from datetime import datetime
1import json
22import random
33import re
44import time
5import json
5from datetime import datetime
6
67import xbmc
7from lib.utils.kodi.utils import action_url_run, kodilog
8
89from lib.utils.kodi.settings import get_setting
10from lib.utils.kodi.utils import action_url_run, kodilog
911
1012
1113def is_trakt_auth():
……@@ -35,31 +37,37 @@ def add_trakt_watchlist_context_menu(media_type, ids, include_add=True, include_
3537 "imdb": ids.get("imdb_id") or ids.get("imdb"),
3638 }
3739 )
38 return _filter_trakt_actions([
39 (
40 "Add to Trakt Watchlist",
41 action_url_run(
42 "trakt_add_to_watchlist",
43 media_type=media_type,
44 ids=json.dumps(filtered_ids),
40 return _filter_trakt_actions(
41 [
42 (
43 "Add to Trakt Watchlist",
44 action_url_run(
45 "trakt_add_to_watchlist",
46 media_type=media_type,
47 ids=json.dumps(filtered_ids),
48 ),
49 "add",
4550 ),
46 "add",
47 ),
48 (
49 "Remove from Trakt Watchlist",
50 action_url_run(
51 "trakt_remove_from_watchlist",
52 media_type=media_type,
53 ids=json.dumps(filtered_ids),
51 (
52 "Remove from Trakt Watchlist",
53 action_url_run(
54 "trakt_remove_from_watchlist",
55 media_type=media_type,
56 ids=json.dumps(filtered_ids),
57 ),
58 "remove",
5459 ),
55 "remove",
56 ),
57 ], include_add, include_remove)
60 ],
61 include_add,
62 include_remove,
63 )
5864
5965
6066def add_trakt_watched_context_menu(
61 media_type, season=None, episode=None, ids={}, include_add=True, include_remove=True
67 media_type, season=None, episode=None, ids=None, include_add=True, include_remove=True
6268):
69 if ids is None:
70 ids = {}
6371 filtered_ids = clean_ids(
6472 {
6573 "tmdb": ids.get("tmdb_id") or ids.get("tmdb"),
……@@ -67,30 +75,34 @@ def add_trakt_watched_context_menu(
6775 "imdb": ids.get("imdb_id") or ids.get("imdb"),
6876 }
6977 )
70 return _filter_trakt_actions([
71 (
72 "Mark as Watched on Trakt",
73 action_url_run(
74 "trakt_mark_as_watched",
75 media_type=media_type,
76 ids=json.dumps(filtered_ids),
77 season=json.dumps(season),
78 episode=json.dumps(episode),
78 return _filter_trakt_actions(
79 [
80 (
81 "Mark as Watched on Trakt",
82 action_url_run(
83 "trakt_mark_as_watched",
84 media_type=media_type,
85 ids=json.dumps(filtered_ids),
86 season=json.dumps(season),
87 episode=json.dumps(episode),
88 ),
89 "add",
7990 ),
80 "add",
81 ),
82 (
83 "Mark as Unwatched on Trakt",
84 action_url_run(
85 "trakt_mark_as_unwatched",
86 media_type=media_type,
87 ids=json.dumps(filtered_ids),
88 season=json.dumps(season),
89 episode=json.dumps(episode),
91 (
92 "Mark as Unwatched on Trakt",
93 action_url_run(
94 "trakt_mark_as_unwatched",
95 media_type=media_type,
96 ids=json.dumps(filtered_ids),
97 season=json.dumps(season),
98 episode=json.dumps(episode),
99 ),
100 "remove",
90101 ),
91 "remove",
92 ),
93 ], include_add, include_remove)
102 ],
103 include_add,
104 include_remove,
105 )
94106
95107
96108def add_trakt_collection_context_menu(media_type, ids, include_add=True, include_remove=True):
……@@ -101,26 +113,30 @@ def add_trakt_collection_context_menu(media_type, ids, include_add=True, include
101113 "imdb": ids.get("imdb_id") or ids.get("imdb"),
102114 }
103115 )
104 return _filter_trakt_actions([
105 (
106 "Add to Collection",
107 action_url_run(
108 "add_to_collection",
109 media_type=media_type,
110 ids=json.dumps(filtered_ids),
116 return _filter_trakt_actions(
117 [
118 (
119 "Add to Collection",
120 action_url_run(
121 "add_to_collection",
122 media_type=media_type,
123 ids=json.dumps(filtered_ids),
124 ),
125 "add",
111126 ),
112 "add",
113 ),
114 (
115 "Remove from Collection",
116 action_url_run(
117 "remove_from_collection",
118 media_type=media_type,
119 ids=json.dumps(filtered_ids),
127 (
128 "Remove from Collection",
129 action_url_run(
130 "remove_from_collection",
131 media_type=media_type,
132 ids=json.dumps(filtered_ids),
133 ),
134 "remove",
120135 ),
121 "remove",
122 ),
123 ], include_add, include_remove)
136 ],
137 include_add,
138 include_remove,
139 )
124140
125141
126142def add_trakt_favorites_context_menu(media_type, ids, include_add=True, include_remove=True):
……@@ -131,26 +147,30 @@ def add_trakt_favorites_context_menu(media_type, ids, include_add=True, include_
131147 "imdb": ids.get("imdb_id") or ids.get("imdb"),
132148 }
133149 )
134 return _filter_trakt_actions([
135 (
136 "Add to Trakt Favorites",
137 action_url_run(
138 "trakt_add_to_favorites",
139 media_type=media_type,
140 ids=json.dumps(filtered_ids),
150 return _filter_trakt_actions(
151 [
152 (
153 "Add to Trakt Favorites",
154 action_url_run(
155 "trakt_add_to_favorites",
156 media_type=media_type,
157 ids=json.dumps(filtered_ids),
158 ),
159 "add",
141160 ),
142 "add",
143 ),
144 (
145 "Remove from Trakt Favorites",
146 action_url_run(
147 "trakt_remove_from_favorites",
148 media_type=media_type,
149 ids=json.dumps(filtered_ids),
161 (
162 "Remove from Trakt Favorites",
163 action_url_run(
164 "trakt_remove_from_favorites",
165 media_type=media_type,
166 ids=json.dumps(filtered_ids),
167 ),
168 "remove",
150169 ),
151 "remove",
152 ),
153 ], include_add, include_remove)
170 ],
171 include_add,
172 include_remove,
173 )
154174
155175
156176def add_trakt_custom_list_context_menu(media_type, ids, include_add=True, include_remove=True):
……@@ -161,26 +181,30 @@ def add_trakt_custom_list_context_menu(media_type, ids, include_add=True, includ
161181 "imdb": ids.get("imdb_id") or ids.get("imdb"),
162182 }
163183 )
164 return _filter_trakt_actions([
165 (
166 "Add to Trakt List",
167 action_url_run(
168 "trakt_add_item_to_list",
169 media_type=media_type,
170 ids=json.dumps(filtered_ids),
184 return _filter_trakt_actions(
185 [
186 (
187 "Add to Trakt List",
188 action_url_run(
189 "trakt_add_item_to_list",
190 media_type=media_type,
191 ids=json.dumps(filtered_ids),
192 ),
193 "add",
171194 ),
172 "add",
173 ),
174 (
175 "Remove from Trakt List",
176 action_url_run(
177 "trakt_remove_item_from_list",
178 media_type=media_type,
179 ids=json.dumps(filtered_ids),
195 (
196 "Remove from Trakt List",
197 action_url_run(
198 "trakt_remove_item_from_list",
199 media_type=media_type,
200 ids=json.dumps(filtered_ids),
201 ),
202 "remove",
180203 ),
181 "remove",
182 ),
183 ], include_add, include_remove)
204 ],
205 include_add,
206 include_remove,
207 )
184208
185209
186210def clean_ids(ids_dict):
……@@ -200,7 +224,7 @@ def jsondate_to_datetime(jsondate_object, resformat, remove_time=False):
200224def datetime_workaround(data, str_format):
201225 try:
202226 datetime_object = datetime.strptime(data, str_format)
203 except:
227 except Exception:
204228 datetime_object = datetime(*(time.strptime(data, str_format)[0:6]))
205229 return datetime_object
206230
……@@ -219,29 +243,19 @@ def sort_list(sort_key, sort_direction, list_data):
219243 reverse=reverse,
220244 )
221245 if sort_key == "released":
222 return sorted(
223 list_data, key=lambda x: released_key(x[x["type"]]), reverse=reverse
224 )
246 return sorted(list_data, key=lambda x: released_key(x[x["type"]]), reverse=reverse)
225247 if sort_key == "runtime":
226 return sorted(
227 list_data, key=lambda x: x[x["type"]].get("runtime", 0), reverse=reverse
228 )
248 return sorted(list_data, key=lambda x: x[x["type"]].get("runtime", 0), reverse=reverse)
229249 if sort_key == "popularity":
230 return sorted(
231 list_data, key=lambda x: x[x["type"]].get("votes", 0), reverse=reverse
232 )
250 return sorted(list_data, key=lambda x: x[x["type"]].get("votes", 0), reverse=reverse)
233251 if sort_key == "percentage":
234 return sorted(
235 list_data, key=lambda x: x[x["type"]].get("rating", 0), reverse=reverse
236 )
252 return sorted(list_data, key=lambda x: x[x["type"]].get("rating", 0), reverse=reverse)
237253 if sort_key == "votes":
238 return sorted(
239 list_data, key=lambda x: x[x["type"]].get("votes", 0), reverse=reverse
240 )
254 return sorted(list_data, key=lambda x: x[x["type"]].get("votes", 0), reverse=reverse)
241255 if sort_key == "random":
242256 return sorted(list_data, key=lambda k: random.random())
243257 return list_data
244 except:
258 except Exception:
245259 return list_data
246260
247261
……@@ -264,7 +278,7 @@ def title_key(title):
264278 else:
265279 offset = 0
266280 return title[offset:]
267 except:
281 except Exception:
268282 return title
269283
270284
plugin.video.jacktook/lib/api/tvdbapi/tvdbapi.py +9 -11
……@@ -1,7 +1,10 @@
1import requests
21from datetime import timedelta
2
3import requests
4
35from lib.db.cached import cache
4from lib.utils.kodi.settings import is_cache_enabled, get_cache_expiration
6from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled
7
58
69class TVDBAPI:
710 def __init__(self):
……@@ -15,7 +18,7 @@ class TVDBAPI:
1518 def get_token(self):
1619 identifier = "tvdb_token"
1720 token = cache.get(identifier)
18 if token:
21 if token:
1922 return token
2023 else:
2124 res = requests.post(self.baseUrl + "login", json=self.apiKey, headers=self.headers)
……@@ -30,12 +33,7 @@ class TVDBAPI:
3033
3134 def get_request(self, url):
3235 token = self.get_token()
33 self.headers.update(
34 {
35 "Authorization": "Bearer {0}".format(token),
36 "Accept": "application/json"
37 }
38 )
36 self.headers.update({"Authorization": f"Bearer {token}", "Accept": "application/json"})
3937 url = self.baseUrl + url
4038 response = requests.get(url, headers=self.headers)
4139 if response:
……@@ -47,13 +45,13 @@ class TVDBAPI:
4745
4846 def get_imdb_id(self, tvdb_id):
4947 imdb_id = None
50 url = "series/{}/extended".format(tvdb_id)
48 url = f"series/{tvdb_id}/extended"
5149 data = self.get_request(url)
5250 if data:
5351 imdb_id = [x.get("id") for x in data["remoteIds"] if x.get("type") == 2]
5452 return imdb_id[0] if imdb_id else None
5553
5654 def get_seasons(self, tvdb_id):
57 url = "seasons/{}/extended".format(tvdb_id)
55 url = f"seasons/{tvdb_id}/extended"
5856 data = self.get_request(url)
5957 return data
plugin.video.jacktook/lib/api/webdav/webdav.py +10 -21
……@@ -1,10 +1,11 @@
11import os
2import xml.etree.ElementTree as ET
23from urllib.parse import unquote, urlparse
4
35import requests
46from requests.auth import HTTPBasicAuth
5import xml.etree.ElementTree as ET
6from lib.utils.kodi.utils import kodilog
77
8from lib.utils.kodi.utils import kodilog
89
910VIDEO_EXTS = {
1011 ".mkv",
……@@ -24,9 +25,7 @@ TEXT_EXTS = {".txt", ".log", ".nfo", ".xml", ".json", ".md", ".srt"}
2425
2526
2627class WebDAVClient:
27 def __init__(
28 self, hostname, username=None, password=None, port=None, remote_path=None
29 ):
28 def __init__(self, hostname, username=None, password=None, port=None, remote_path=None):
3029 self.username = username
3130 self.password = password
3231 kodilog(f"WebDAV Client initialized with hostname: {hostname}")
……@@ -89,9 +88,7 @@ class WebDAVClient:
8988
9089 headers = {"Depth": "1"}
9190 try:
92 r = requests.request(
93 "PROPFIND", url, headers=headers, auth=self.auth, timeout=15
94 )
91 r = requests.request("PROPFIND", url, headers=headers, auth=self.auth, timeout=15)
9592 r.raise_for_status()
9693 except requests.exceptions.RequestException as e:
9794 # Check if it's an HTTP error with a response
……@@ -136,20 +133,14 @@ class WebDAVClient:
136133
137134 # Build Auth URL
138135 creds = (
139 f"{self.username}:{self.password}@"
140 if self.username and self.password
141 else ""
142 )
143 clean_host = self.server_root.replace("http://", "").replace(
144 "https://", ""
136 f"{self.username}:{self.password}@" if self.username and self.password else ""
145137 )
138 clean_host = self.server_root.replace("http://", "").replace("https://", "")
146139
147140 # Extract only the path from href to avoid issues with absolute URLs
148141 # (e.g. server returning http://localhost:8080/...)
149142 href_path = urlparse(href).path
150 file_url = (
151 f"{self.scheme}://{creds}{clean_host}/{href_path.lstrip('/')}"
152 )
143 file_url = f"{self.scheme}://{creds}{clean_host}/{href_path.lstrip('/')}"
153144
154145 items.append(
155146 {
……@@ -171,9 +162,7 @@ class WebDAVClient:
171162 try:
172163 headers = {"Depth": "1"}
173164 kodilog(f"Testing connection to {url}")
174 r = requests.request(
175 "PROPFIND", url, headers=headers, auth=self.auth, timeout=10
176 )
165 r = requests.request("PROPFIND", url, headers=headers, auth=self.auth, timeout=10)
177166 r.raise_for_status()
178167
179168 # Parse XML to make sure server responded properly
……@@ -181,7 +170,7 @@ class WebDAVClient:
181170
182171 return {
183172 "success": True,
184 "message": f"WebDAV server reachable",
173 "message": "WebDAV server reachable",
185174 }
186175
187176 except requests.exceptions.RequestException as e:
plugin.video.jacktook/lib/clients/anilist.py +10 -10
……@@ -1,5 +1,7 @@
11import requests
22
3from lib.utils.kodi.utils import get_setting
4
35
46class AniList:
57 def __init__(self, client_id, client_secret):
……@@ -78,8 +80,8 @@ class AniList:
7880# GraphQL Anilist
7981BASE_GRAPH = """
8082 query (
81 $page: Int,
82 $perPage: Int,
83 $page: Int,
84 $perPage: Int,
8385 $sort: [MediaSort],
8486 $format:[MediaFormat],
8587 $season: MediaSeason,
……@@ -88,7 +90,7 @@ BASE_GRAPH = """
8890 ) {
8991 Page (page: $page, perPage: $perPage) {
9092 ANIME: media (
91 type: $type,
93 type: $type,
9294 format_in: $format,
9395 season: $season,
9496 status: $status,
……@@ -118,14 +120,14 @@ BASE_GRAPH = """
118120
119121SEARCH = """
120122 query (
121 $query: String,
122 $page: Int,
123 $query: String,
124 $page: Int,
123125 $perPage: Int,
124126 $format:[MediaFormat]
125127 ) {
126128 Page (page: $page, perPage: $perPage) {
127129 ANIME: media (
128 search: $query,
130 search: $query,
129131 type: ANIME,
130132 format_in: $format,
131133 ) {
……@@ -149,7 +151,7 @@ SEARCH = """
149151SEARCH_ID = """
150152 query ($id: Int) {
151153 Media (
152 id: $id,
154 id: $id,
153155 type: ANIME
154156 ) {
155157 id
……@@ -172,7 +174,5 @@ SEARCH_ID = """
172174def anilist_client():
173175 return AniList(
174176 get_setting("anilist_client_id", "14375"),
175 get_setting(
176 "anilist_client_secret", "tOJ5CJA9JM2pmJrHM8XaZgnM9XgL7HaLTM3krdML"
177 ),
177 get_setting("anilist_client_secret", "tOJ5CJA9JM2pmJrHM8XaZgnM9XgL7HaLTM3krdML"),
178178 )
plugin.video.jacktook/lib/clients/anizip.py +1 -1
……@@ -9,7 +9,7 @@ class AniZipApi:
99 res = self.make_request(url="mappings", params=params)
1010 if res:
1111 return res["mappings"]
12
12
1313 def episodes(self, anilist_id):
1414 params = {"anilist_id": anilist_id}
1515 res = self.make_request(url="mappings", params=params)
plugin.video.jacktook/lib/clients/base.py +3 -1
……@@ -1,6 +1,8 @@
11from abc import ABC, abstractmethod
2from typing import Any, Callable, List, Optional
3
24from requests import Session
3from typing import Any, List, Optional, Callable
5
46from lib.domain.torrent import TorrentStream
57from lib.utils.kodi.utils import notification
68
plugin.video.jacktook/lib/clients/debrid/alldebrid.py +17 -19
……@@ -1,10 +1,11 @@
1import threading
12from datetime import datetime
2from typing import Dict, List, Any, Optional
3from typing import Any, Dict, List, Optional
34
45from lib.api.debrid.alldebrid import AllDebrid
56from lib.api.debrid.base import ProviderException
67from lib.clients.debrid.common import get_file_name, get_packed_release_message
7from lib.utils.kodi.utils import dialog_text, get_setting, kodilog, notification, translation
8from lib.domain.torrent import TorrentStream
89from lib.utils.general.utils import (
910 DebridType,
1011 IndexerType,
……@@ -13,8 +14,13 @@ from lib.utils.general.utils import (
1314 info_hash_to_magnet,
1415 supported_video_extensions,
1516)
16from lib.domain.torrent import TorrentStream
17import threading
17from lib.utils.kodi.utils import (
18 dialog_text,
19 get_setting,
20 kodilog,
21 notification,
22 translation,
23)
1824
1925
2026class AllDebridHelper:
……@@ -33,9 +39,7 @@ class AllDebridHelper:
3339 # Checks if torrents are cached in AllDebrid.
3440 torr_available = self.client.get_user_torrent_list()
3541 magnets = (
36 torr_available.get("data", {}).get("magnets")
37 or torr_available.get("magnets")
38 or []
42 torr_available.get("data", {}).get("magnets") or torr_available.get("magnets") or []
3943 )
4044 torr_available_hashes = [
4145 m.get("hash") for m in magnets if isinstance(m, dict) and m.get("hash")
……@@ -49,7 +53,7 @@ class AllDebridHelper:
4953 if res.infoHash in torr_available_hashes:
5054 res.isCached = True
5155 cached_results.append(res)
52 elif res.isCached == True:
56 elif res.isCached:
5357 cached_results.append(res)
5458 else:
5559 res.isCached = False
……@@ -58,9 +62,7 @@ class AllDebridHelper:
5862 if get_setting("show_uncached"):
5963 cached_results.extend(uncached_results)
6064
61 def get_link(
62 self, info_hash: str, data: Dict[str, Any]
63 ) -> Optional[Dict[str, Any]]:
65 def get_link(self, info_hash: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
6466
6567 result = self.client.add_magnet(info_hash_to_magnet(info_hash))
6668 magnet = result.get("data", {}).get("magnets", [])[0]
……@@ -98,9 +100,7 @@ class AllDebridHelper:
98100 season = data["tv_data"].get("season", "")
99101 episode = data["tv_data"].get("episode", "")
100102
101 matched = filter_debrid_episode(
102 flat_files, episode_num=episode, season_num=season
103 )
103 matched = filter_debrid_episode(flat_files, episode_num=episode, season_num=season)
104104 if not matched:
105105 raise ProviderException("No matching episode found in torrent.")
106106
……@@ -141,7 +141,6 @@ class AllDebridHelper:
141141
142142 def get_pack_link(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
143143 """Gets a direct download link for a file inside a Real-Debrid torrent pack."""
144
145144 pack_info = data.get("pack_info", {})
146145 link = pack_info.get("url", "")
147146
……@@ -170,8 +169,7 @@ class AllDebridHelper:
170169 (f.get("l"), get_file_name(f))
171170 for f in flat_files
172171 if any(
173 get_file_name(f).lower().endswith(ext)
174 for ext in supported_video_extensions()[:-1]
172 get_file_name(f).lower().endswith(ext) for ext in supported_video_extensions()[:-1]
175173 )
176174 ]
177175 if not files:
……@@ -194,8 +192,8 @@ class AllDebridHelper:
194192 try:
195193 response = self.client.get_user_info()
196194 user = response["user"]
197 except Exception as e:
198 raise ProviderException("Failed to retrieve All-Debrid user info.")
195 except Exception as exc:
196 raise ProviderException("Failed to retrieve All-Debrid user info.") from exc
199197 status = "Premium" if user["isPremium"] else "Not Active"
200198 expires = datetime.fromtimestamp(user["premiumUntil"])
201199 days_remaining = (expires - datetime.today()).days
plugin.video.jacktook/lib/clients/debrid/common.py +2 -5
……@@ -1,9 +1,8 @@
11from typing import Any, Dict, Optional
22
33from lib.api.debrid.base import ProviderException
4from lib.utils.kodi.utils import translation
54from lib.utils.general.utils import supported_video_extensions
6
5from lib.utils.kodi.utils import translation
76
87PACKED_RELEASE_FALLBACK_TEMPLATE = (
98 "{provider} cannot directly play packed releases (.rar/.zip). "
……@@ -50,9 +49,7 @@ def ensure_direct_playable_file(filename: Optional[str]) -> None:
5049 raise ProviderException(get_packed_release_message("This provider"))
5150
5251
53def ensure_direct_playable_file_for_provider(
54 filename: Optional[str], provider_name: str
55) -> None:
52def ensure_direct_playable_file_for_provider(filename: Optional[str], provider_name: str) -> None:
5653 if not filename:
5754 return
5855
plugin.video.jacktook/lib/clients/debrid/debrider.py +6 -9
……@@ -1,9 +1,10 @@
11from datetime import datetime, timezone
2from typing import Dict, List, Any, Optional
2from typing import Any, Dict, List, Optional
3
34from lib.api.debrid.base import ProviderException
45from lib.api.debrid.debrider import Debrider
56from lib.clients.debrid.common import get_file_name, get_packed_release_message
6from lib.utils.kodi.utils import dialog_text, get_setting, notification
7from lib.domain.torrent import TorrentStream
78from lib.utils.general.utils import (
89 DebridType,
910 IndexerType,
……@@ -15,14 +16,12 @@ from lib.utils.general.utils import (
1516 set_cached,
1617 supported_video_extensions,
1718)
18from lib.domain.torrent import TorrentStream
19from lib.utils.kodi.utils import dialog_text, get_setting, notification
1920
2021
2122class DebriderHelper:
2223 def __init__(self):
23 self.client = Debrider(
24 token=get_setting("debrider_token"), user_ip=get_public_ip()
25 )
24 self.client = Debrider(token=get_setting("debrider_token"), user_ip=get_public_ip())
2625
2726 def check_cached(
2827 self,
……@@ -125,9 +124,7 @@ class DebriderHelper:
125124 not response.get("message", "")
126125 or "task added successfully" not in response.get("message", "").lower()
127126 ):
128 raise ProviderException(
129 f"Failed to add magnet link to Debrider {response}"
130 )
127 raise ProviderException(f"Failed to add magnet link to Debrider {response}")
131128 except Exception as e:
132129 notification(str(e))
133130 raise
plugin.video.jacktook/lib/clients/debrid/easydebrid.py +6 -9
……@@ -1,8 +1,9 @@
11from datetime import datetime
2from typing import Dict, List, Any, Optional
2from typing import Any, Dict, List, Optional
3
34from lib.api.debrid.easydebrid import EasyDebrid
45from lib.clients.debrid.common import get_file_name, get_packed_release_message
5from lib.utils.kodi.utils import dialog_text, get_setting, notification, translation
6from lib.domain.torrent import TorrentStream
67from lib.utils.general.utils import (
78 DebridType,
89 IndexerType,
……@@ -14,14 +15,12 @@ from lib.utils.general.utils import (
1415 set_cached,
1516 supported_video_extensions,
1617)
17from lib.domain.torrent import TorrentStream
18from lib.utils.kodi.utils import dialog_text, get_setting, notification, translation
1819
1920
2021class EasyDebridHelper:
2122 def __init__(self):
22 self.client = EasyDebrid(
23 token=get_setting("easydebrid_token"), user_ip=get_public_ip()
24 )
23 self.client = EasyDebrid(token=get_setting("easydebrid_token"), user_ip=get_public_ip())
2524
2625 def check_cached(
2726 self,
……@@ -49,9 +48,7 @@ class EasyDebridHelper:
4948 if res in filtered_results:
5049 # Get index in filtered results for matching cached response
5150 index = filtered_results.index(res)
52 is_cached = (
53 cached_response[index] if index < len(cached_response) else False
54 )
51 is_cached = cached_response[index] if index < len(cached_response) else False
5552 res.isCached = bool(is_cached)
5653 if res.isCached:
5754 cached_results.append(res)
plugin.video.jacktook/lib/clients/debrid/offcloud.py +168 -0
……@@ -0,0 +1,168 @@
1import copy
2from typing import Any, Dict, List, Optional
3
4from lib.api.debrid.offcloud import Offcloud
5from lib.clients.debrid.common import get_file_name, get_packed_release_message
6from lib.domain.torrent import TorrentStream
7from lib.utils.general.utils import (
8 DebridType,
9 IndexerType,
10 debrid_dialog_update,
11 filter_debrid_episode,
12 get_cached,
13 info_hash_to_magnet,
14 set_cached,
15 supported_video_extensions,
16)
17from lib.utils.kodi.utils import dialog_text, get_setting, kodilog, notification
18
19EXTENSIONS = supported_video_extensions()[:-1]
20
21
22class OffcloudHelper:
23 def __init__(self):
24 self.client = Offcloud(token=str(get_setting("offcloud_token") or ""))
25
26 def _get_cache_download_files(self, info_hash: str) -> List[Dict[str, Any]]:
27 magnet = info_hash_to_magnet(info_hash)
28 response = self.client.create_cache_download(magnet)
29 return response if isinstance(response, list) else response.get("files", [])
30
31 def _video_files(self, files: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
32 return [
33 item
34 for item in files
35 if any(get_file_name(item).lower().endswith(ext) for ext in EXTENSIONS)
36 ]
37
38 def check_cached(
39 self,
40 results: List[TorrentStream],
41 cached_results: List[TorrentStream],
42 uncached_results: List[TorrentStream],
43 total: int,
44 dialog: Any,
45 lock: Any,
46 ) -> None:
47 filtered_results = [res for res in results if res.infoHash]
48 cached_by_hash = {}
49
50 if filtered_results:
51 magnets = [info_hash_to_magnet(res.infoHash) for res in filtered_results]
52 response = self.client.get_cache_info(magnets, include_files=True)
53 if isinstance(response, list):
54 for index, item in enumerate(response):
55 if index < len(filtered_results) and isinstance(item, dict):
56 cached_by_hash[filtered_results[index].infoHash] = bool(item.get("cached"))
57
58 for res in copy.deepcopy(results):
59 debrid_dialog_update("OC", total, dialog, lock)
60 res.type = IndexerType.DEBRID
61 res.debridType = DebridType.OC
62 res.isCached = bool(cached_by_hash.get(res.infoHash))
63
64 with lock:
65 if res.isCached:
66 cached_results.append(res)
67 else:
68 uncached_results.append(res)
69
70 def add_cloud_download(self, info_hash: str):
71 if not info_hash:
72 return None
73 return self.client.add_cloud_download(info_hash_to_magnet(info_hash))
74
75 def get_cloud_downloads(self) -> List[Dict[str, Any]]:
76 history = self.client.get_cloud_history()
77 downloads = []
78
79 for item in history if isinstance(history, list) else []:
80 if not isinstance(item, dict) or item.get("status") != "downloaded":
81 continue
82
83 request_id = item.get("requestId", "")
84 if not request_id:
85 continue
86
87 explored = self.client.explore_cloud_download(request_id, detailed=True)
88 if isinstance(explored, dict) and explored.get("error") == "Bad archive":
89 kodilog(
90 "Offcloud cloud listing skipped single-file transfer that cannot be explored"
91 )
92 continue
93
94 files = explored.get("files", []) if isinstance(explored, dict) else []
95 video_files = self._video_files(files)
96 if not video_files:
97 continue
98
99 file_item = max(video_files, key=lambda file_data: file_data.get("size", 0))
100 downloads.append(
101 {
102 "name": get_file_name(file_item) or item.get("fileName") or "Unknown",
103 "request_id": request_id,
104 "url": file_item.get("url", ""),
105 "created_at": item.get("createdOn", ""),
106 }
107 )
108
109 return downloads
110
111 def get_link(self, info_hash, data) -> Optional[Dict[str, Any]]:
112 if data.get("url"):
113 return data
114
115 files = self._video_files(self._get_cache_download_files(info_hash))
116 if not files:
117 notification(get_packed_release_message("Offcloud"))
118 return None
119
120 if len(files) > 1:
121 if data.get("tv_data"):
122 tv_data = data.get("tv_data") or {}
123 files = filter_debrid_episode(
124 files,
125 episode_num=tv_data.get("episode", ""),
126 season_num=tv_data.get("season", ""),
127 )
128 if not files:
129 return None
130 else:
131 data["is_pack"] = True
132 return data
133
134 data["url"] = files[0].get("url")
135 return data
136
137 def get_pack_link(self, data) -> Optional[Dict[str, Any]]:
138 pack_info = data.get("pack_info", {})
139 url = pack_info.get("url", "")
140 if not url:
141 return None
142 data["url"] = url
143 return data
144
145 def get_pack_info(self, info_hash):
146 info = get_cached(info_hash)
147 if info:
148 return info
149
150 files = self._video_files(self._get_cache_download_files(info_hash))
151 if len(files) <= 1:
152 notification("No files on the current source")
153 return None
154
155 info = {"files": [(item.get("url"), get_file_name(item)) for item in files]}
156 set_cached(info, info_hash)
157 return info
158
159 def get_info(self) -> None:
160 user = self.client.get_account_info()
161 body = [
162 f"[B]Account:[/B] {user.get('email', '')}",
163 f"[B]Premium:[/B] {'Yes' if user.get('is_premium') else 'No'}",
164 f"[B]Can Download:[/B] {'Yes' if user.get('can_download') else 'No'}",
165 ]
166 if user.get("expiration_date"):
167 body.append(f"[B]Expires:[/B] {user.get('expiration_date')}")
168 dialog_text("Offcloud", "\n".join(body))
plugin.video.jacktook/lib/clients/debrid/premiumize.py +22 -13
……@@ -1,11 +1,12 @@
11import copy
2from typing import Dict, List, Any, Optional
2from typing import Any, Dict, List, Optional
3
34from lib.api.debrid.premiumize import Premiumize
45from lib.clients.debrid.common import (
56 ensure_direct_playable_file_for_provider,
67 get_file_name,
78)
8from lib.utils.kodi.utils import get_setting, kodilog, notification
9from lib.domain.torrent import TorrentStream
910from lib.utils.general.utils import (
1011 DebridType,
1112 IndexerType,
……@@ -16,7 +17,7 @@ from lib.utils.general.utils import (
1617 set_cached,
1718 supported_video_extensions,
1819)
19from lib.domain.torrent import TorrentStream
20from lib.utils.kodi.utils import get_setting, kodilog, notification
2021
2122
2223class PremiumizeHelper:
……@@ -59,9 +60,15 @@ class PremiumizeHelper:
5960 error_msg = response_data.get("message", "Unknown error")
6061 kodilog(f"Failed to get link from Premiumize: {error_msg}")
6162 if "not premium" in error_msg.lower():
62 notification("Premiumize: Account does not have an active premium subscription", "Premiumize Error")
63 notification(
64 "Premiumize: Account does not have an active premium subscription",
65 "Premiumize Error",
66 )
6367 elif "not logged in" in error_msg.lower():
64 notification("Premiumize: Not logged in. Please authorize the addon.", "Premiumize Error")
68 notification(
69 "Premiumize: Not logged in. Please authorize the addon.",
70 "Premiumize Error",
71 )
6572 else:
6673 notification(f"Premiumize: {error_msg}", "Premiumize Error")
6774 return None
……@@ -71,9 +78,7 @@ class PremiumizeHelper:
7178 if data["tv_data"]:
7279 season = data["tv_data"].get("season", "")
7380 episode = data["tv_data"].get("episode", "")
74 content = filter_debrid_episode(
75 content, episode_num=episode, season_num=season
76 )
81 content = filter_debrid_episode(content, episode_num=episode, season_num=season)
7782 if not content:
7883 return
7984 data["url"] = content[0].get("stream_link")
……@@ -82,9 +87,7 @@ class PremiumizeHelper:
8287 data["is_pack"] = True
8388 return data
8489 else:
85 ensure_direct_playable_file_for_provider(
86 get_file_name(content[0]), "Premiumize"
87 )
90 ensure_direct_playable_file_for_provider(get_file_name(content[0]), "Premiumize")
8891 data["url"] = content[0].get("stream_link")
8992 return data
9093
……@@ -102,9 +105,15 @@ class PremiumizeHelper:
102105 error_msg = response_data.get("message", "Unknown error")
103106 kodilog(f"Failed to get pack info from Premiumize: {error_msg}")
104107 if "not premium" in error_msg.lower():
105 notification("Premiumize: Account does not have an active premium subscription", "Premiumize Error")
108 notification(
109 "Premiumize: Account does not have an active premium subscription",
110 "Premiumize Error",
111 )
106112 elif "not logged in" in error_msg.lower():
107 notification("Premiumize: Not logged in. Please authorize the addon.", "Premiumize Error")
113 notification(
114 "Premiumize: Not logged in. Please authorize the addon.",
115 "Premiumize Error",
116 )
108117 else:
109118 notification(f"Premiumize: {error_msg}", "Premiumize Error")
110119 return None
plugin.video.jacktook/lib/clients/debrid/realdebrid.py +28 -49
……@@ -2,7 +2,7 @@ import copy
22import threading
33import time
44from datetime import datetime
5from typing import Any, List, Dict, Optional, cast
5from typing import Any, Dict, List, Optional, cast
66
77from lib.api.debrid.base import ProviderException
88from lib.api.debrid.realdebrid import RealDebrid
……@@ -10,12 +10,7 @@ from lib.clients.debrid.common import (
1010 ensure_direct_playable_file_for_provider,
1111 get_file_name,
1212)
13from lib.utils.kodi.logging import kodilog
14from lib.utils.kodi.utils import (
15 get_setting,
16 dialog_text,
17 translation,
18)
13from lib.domain.torrent import TorrentStream
1914from lib.utils.general.utils import (
2015 DebridType,
2116 IndexerType,
……@@ -26,7 +21,12 @@ from lib.utils.general.utils import (
2621 set_cached,
2722 supported_video_extensions,
2823)
29from lib.domain.torrent import TorrentStream
24from lib.utils.kodi.logging import kodilog
25from lib.utils.kodi.utils import (
26 dialog_text,
27 get_setting,
28 translation,
29)
3030
3131
3232class LinkNotFoundError(Exception):
……@@ -49,9 +49,7 @@ class RealDebridHelper:
4949 # Checks if torrents are cached in Real-Debrid.
5050 torr_available = self.client.get_user_torrent_list()
5151 torr_available_hashes = [
52 t.get("hash")
53 for t in torr_available
54 if isinstance(t, dict) and t.get("hash")
52 t.get("hash") for t in torr_available if isinstance(t, dict) and t.get("hash")
5553 ]
5654
5755 for res in copy.deepcopy(results):
……@@ -62,7 +60,7 @@ class RealDebridHelper:
6260 if res.infoHash in torr_available_hashes:
6361 res.isCached = True
6462 cached_results.append(res)
65 elif res.isCached == True:
63 elif res.isCached:
6664 cached_results.append(res)
6765 else:
6866 res.isCached = False
……@@ -75,9 +73,7 @@ class RealDebridHelper:
7573 """Adds a magnet link to Real-Debrid and returns the torrent ID."""
7674 try:
7775 kodilog(
78 "RealDebridHelper.add_magnet: checking existing torrent for hash={!r}".format(
79 str(info_hash).lower()[:12]
80 )
76 f"RealDebridHelper.add_magnet: checking existing torrent for hash={str(info_hash).lower()[:12]!r}"
8177 )
8278 torrent_info = self.client.get_available_torrent(info_hash)
8379 if not torrent_info:
……@@ -91,7 +87,7 @@ class RealDebridHelper:
9187 self._handle_torrent_status(torrent_info, is_pack)
9288 return torrent_info.get("id")
9389 except Exception as e:
94 raise ProviderException(str(e))
90 raise ProviderException(str(e)) from e
9591
9692 def add_torrent_file(
9793 self,
……@@ -116,11 +112,9 @@ class RealDebridHelper:
116112 self._handle_torrent_status(torrent_info, is_pack)
117113 return torrent_info.get("id")
118114 except Exception as e:
119 raise ProviderException(str(e))
115 raise ProviderException(str(e)) from e
120116
121 def _handle_torrent_status(
122 self, torrent_info: Dict, is_pack: bool = False
123 ) -> Optional[str]:
117 def _handle_torrent_status(self, torrent_info: Dict, is_pack: bool = False) -> Optional[str]:
124118 """Processes torrent_info status and handles errors or file selection."""
125119 status = torrent_info["status"]
126120 torrent_id = torrent_info.get("id", "unknown")
……@@ -128,10 +122,12 @@ class RealDebridHelper:
128122 self.client.delete_torrent(torrent_info["id"])
129123 raise ProviderException(f"Torrent cannot be downloaded: {status}")
130124 elif status in ["queued", "downloading", "magnet_conversion"]:
131 kodilog(f"RealDebridHelper: Torrent {torrent_id} is still being processed (status: {status}). It has been added to your cloud but is not ready yet.")
125 kodilog(
126 f"RealDebridHelper: Torrent {torrent_id} is still being processed (status: {status}). It has been added to your cloud but is not ready yet."
127 )
132128 return torrent_id
133129 elif status == "waiting_files_selection":
134 if "files" in torrent_info and torrent_info["files"]:
130 if torrent_info.get("files"):
135131 self.handle_file_selection(torrent_info, is_pack)
136132 else:
137133 raise ProviderException("No files available for this torrent yet.")
……@@ -142,9 +138,7 @@ class RealDebridHelper:
142138 extensions = supported_video_extensions()[:-1]
143139
144140 video_files = [
145 item
146 for item in files
147 if any(item["path"].lower().endswith(ext) for ext in extensions)
141 item for item in files if any(item["path"].lower().endswith(ext) for ext in extensions)
148142 ]
149143
150144 if video_files:
……@@ -156,9 +150,7 @@ class RealDebridHelper:
156150 if torrents_ids:
157151 self.client.select_files(torrent_info["id"], ",".join(torrents_ids))
158152
159 def get_link(
160 self, info_hash: str, data: Dict[str, Any]
161 ) -> Optional[Dict[str, Any]]:
153 def get_link(self, info_hash: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
162154 """Gets a direct download link for a Real-Debrid torrent."""
163155 torrent_id = self.add_magnet(info_hash)
164156 torrent_info = self.client.get_torrent_info(torrent_id)
……@@ -178,9 +170,7 @@ class RealDebridHelper:
178170 # --- Single-file torrent ---
179171 if len(links) == 1:
180172 single_file = files[0] if files else {}
181 ensure_direct_playable_file_for_provider(
182 get_file_name(single_file), "Real-Debrid"
183 )
173 ensure_direct_playable_file_for_provider(get_file_name(single_file), "Real-Debrid")
184174 data["url"] = create_download_for_link(links[0])
185175 return data
186176
……@@ -190,27 +180,21 @@ class RealDebridHelper:
190180 season = tv_data.get("season")
191181 episode = tv_data.get("episode")
192182
193 possible_matches = filter_debrid_episode(
194 files, episode_num=episode, season_num=season
195 )
183 possible_matches = filter_debrid_episode(files, episode_num=episode, season_num=season)
196184 if not possible_matches:
197185 raise ProviderException("No matching episode found in torrent.")
198186
199 match_file = next(
200 (f for f in possible_matches if f.get("selected") == 1), None
201 )
187 match_file = next((f for f in possible_matches if f.get("selected") == 1), None)
202188 if not match_file:
203189 raise ValueError("File is not cached")
204190
205 ensure_direct_playable_file_for_provider(
206 get_file_name(match_file), "Real-Debrid"
207 )
191 ensure_direct_playable_file_for_provider(get_file_name(match_file), "Real-Debrid")
208192
209193 selected_files = [f for f in files if f.get("selected") == 1]
210194 try:
211195 file_index = selected_files.index(match_file)
212 except ValueError:
213 raise ProviderException("Could not map episode to Real-Debrid link.")
196 except ValueError as exc:
197 raise ProviderException("Could not map episode to Real-Debrid link.") from exc
214198
215199 data["url"] = create_download_for_link(links[file_index])
216200 return data
……@@ -218,9 +202,7 @@ class RealDebridHelper:
218202 selected_files = [f for f in files if f.get("selected") == 1]
219203 if selected_files:
220204 largest_file = max(selected_files, key=lambda f: f.get("bytes", 0))
221 ensure_direct_playable_file_for_provider(
222 get_file_name(largest_file), "Real-Debrid"
223 )
205 ensure_direct_playable_file_for_provider(get_file_name(largest_file), "Real-Debrid")
224206 try:
225207 file_index = selected_files.index(largest_file)
226208 except ValueError:
……@@ -235,7 +217,6 @@ class RealDebridHelper:
235217
236218 def get_pack_link(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
237219 """Gets a direct download link for a file inside a Real-Debrid torrent pack."""
238
239220 pack_info = cast(Dict[str, Any], data.get("pack_info", {}) or {})
240221 file_position = pack_info.get("file_position", "")
241222 torrent_id = pack_info.get("torrent_id", "")
……@@ -281,9 +262,7 @@ class RealDebridHelper:
281262 try:
282263 expires = datetime.strptime(expiration, "%Y-%m-%dT%H:%M:%S.%fZ")
283264 except ValueError:
284 expires = datetime(
285 *(time.strptime(expiration, "%Y-%m-%dT%H:%M:%S.%fZ")[0:6])
286 )
265 expires = datetime(*(time.strptime(expiration, "%Y-%m-%dT%H:%M:%S.%fZ")[0:6]))
287266
288267 days_remaining = (expires - datetime.today()).days
289268 body = [
plugin.video.jacktook/lib/clients/debrid/torbox.py +47 -37
……@@ -1,8 +1,10 @@
11import copy
2from typing import Dict, List, Any, Optional
2import time
3from typing import Any, Dict, List, Optional
4
35from lib.api.debrid.torbox import Torbox
46from lib.clients.debrid.common import get_file_name, get_packed_release_message
5from lib.utils.kodi.utils import get_setting, notification, dialog_text, kodilog, translation
7from lib.domain.torrent import TorrentStream
68from lib.utils.general.utils import (
79 DebridType,
810 IndexerType,
……@@ -13,7 +15,13 @@ from lib.utils.general.utils import (
1315 set_cached,
1416 supported_video_extensions,
1517)
16from lib.domain.torrent import TorrentStream
18from lib.utils.kodi.utils import (
19 dialog_text,
20 get_setting,
21 kodilog,
22 notification,
23 translation,
24)
1725
1826EXTENSIONS = supported_video_extensions()[:-1]
1927
……@@ -42,9 +50,7 @@ class TorboxHelper:
4250
4351 if hashes:
4452 kodilog(
45 "TorboxHelper.check_cached: checking {} hashes against TorBox cache".format(
46 len(hashes)
47 )
53 f"TorboxHelper.check_cached: checking {len(hashes)} hashes against TorBox cache"
4854 )
4955 response = self.client.get_torrent_instant_availability(hashes)
5056 raw_data = response.get("data", [])
……@@ -58,9 +64,7 @@ class TorboxHelper:
5864 missing_hashes = len(results) - len(hashes)
5965 if missing_hashes:
6066 kodilog(
61 "TorboxHelper.check_cached: skipping {} results without info_hash".format(
62 missing_hashes
63 )
67 f"TorboxHelper.check_cached: skipping {missing_hashes} results without info_hash"
6468 )
6569
6670 for res in copy.deepcopy(results):
……@@ -78,9 +82,7 @@ class TorboxHelper:
7882
7983 def add_torbox_torrent(self, info_hash):
8084 kodilog(
81 "TorboxHelper.add_torbox_torrent: checking existing torrent for hash={!r}".format(
82 str(info_hash).lower()[:12]
83 )
85 f"TorboxHelper.add_torbox_torrent: checking existing torrent for hash={str(info_hash).lower()[:12]!r}"
8486 )
8587 torrent_info = self.client.get_available_torrent(info_hash)
8688 if (
……@@ -99,8 +101,10 @@ class TorboxHelper:
99101
100102 if "Found Cached" in response.get("detail", ""):
101103 return self.client.get_available_torrent(info_hash)
102
103 kodilog(f"TorboxHelper: Magnet added successfully, waiting for download. Detail: {response.get('detail', 'N/A')}")
104
105 kodilog(
106 f"TorboxHelper: Magnet added successfully, waiting for download. Detail: {response.get('detail', 'N/A')}"
107 )
104108 return response
105109
106110 def add_torrent_file(self, torrent_data: bytes, torrent_name: str = "torrent.torrent"):
……@@ -164,13 +168,7 @@ class TorboxHelper:
164168 )
165169
166170 kodilog(
167 "Torbox cloud listing: total={}, playable={}, skipped_not_dict={}, skipped_not_present={}, skipped_no_playable={}".format(
168 len(torrents),
169 len(downloads),
170 skipped_not_dict,
171 skipped_not_present,
172 skipped_no_playable,
173 )
171 f"Torbox cloud listing: total={len(torrents)}, playable={len(downloads)}, skipped_not_dict={skipped_not_dict}, skipped_not_present={skipped_not_present}, skipped_no_playable={skipped_no_playable}"
174172 )
175173
176174 return downloads
……@@ -180,16 +178,12 @@ class TorboxHelper:
180178 file_id = data.get("file_id", "")
181179
182180 if torrent_id and file_id:
183 response_data = self.client.create_download_link(
184 torrent_id, file_id, get_public_ip()
185 )
181 response_data = self.client.create_download_link(torrent_id, file_id, get_public_ip())
186182 if response_data:
187183 data["url"] = response_data.get("data", {})
188184 return data
189185 kodilog(
190 "Torbox cloud playback failed to create link for torrent_id={!r}, file_id={!r}".format(
191 torrent_id, file_id
192 )
186 f"Torbox cloud playback failed to create link for torrent_id={torrent_id!r}, file_id={file_id!r}"
193187 )
194188 return None
195189
……@@ -197,9 +191,30 @@ class TorboxHelper:
197191 if not torrent_info:
198192 return None
199193
194 torrent_files = torrent_info.get("files", []) or []
195 if not torrent_files and info_hash:
196 kodilog(
197 f"TorboxHelper.get_link: torrent has no files yet, retrying metadata fetch for hash={str(info_hash).lower()[:12]!r}"
198 )
199 for _ in range(3):
200 time.sleep(1)
201 refreshed_info = self.client.get_available_torrent(info_hash)
202 if refreshed_info:
203 torrent_info = refreshed_info
204 torrent_files = refreshed_info.get("files", []) or []
205 if torrent_files:
206 break
207
208 if not torrent_files:
209 kodilog(
210 f"TorboxHelper.get_link: no playable files available yet for hash={str(info_hash).lower()[:12]!r}"
211 )
212 notification("Torbox is still processing this torrent. Please try again in a moment.")
213 return None
214
200215 video_files = [
201216 item
202 for item in torrent_info["files"]
217 for item in torrent_files
203218 if any(get_file_name(item).lower().endswith(ext) for ext in EXTENSIONS)
204219 ]
205220 if not video_files:
……@@ -263,16 +278,11 @@ class TorboxHelper:
263278 user = response.get("data", {})
264279 customer_email = user.get("customer_email", "")
265280 plan = user.get("plan", 0)
266
281
267282 # Plan mapping (based on general knowledge of Torbox plans, might need adjustment)
268 plans = {
269 0: "Free",
270 1: "Essential",
271 2: "Pro",
272 3: "Standard"
273 }
283 plans = {0: "Free", 1: "Essential", 2: "Pro", 3: "Standard"}
274284 plan_name = plans.get(plan, f"Plan {plan}")
275
285
276286 body = [
277287 f"[B]Account:[/B] {customer_email}",
278288 f"[B]Plan:[/B] {plan_name}",
……@@ -282,5 +292,5 @@ class TorboxHelper:
282292
283293 days = self.client.days_remaining()
284294 if days is not None:
285 body.append(f"[B]Days Remaining:[/B] {days}")
295 body.append(f"[B]Days Remaining:[/B] {days}")
286296 dialog_text(translation(90656), "\n".join(body))
plugin.video.jacktook/lib/clients/easynews.py +7 -8
……@@ -1,9 +1,10 @@
11import re
2from typing import Any, Callable, List, Optional, Tuple
23from urllib.parse import quote
4
35from lib.clients.base import BaseClient, TorrentStream
6from lib.utils.general.utils import Indexer, IndexerType
47from lib.utils.kodi.utils import kodilog
5from lib.utils.general.utils import IndexerType, Indexer
6from typing import List, Optional, Any, Callable, Tuple
78
89VIDEO_EXTENSIONS = (
910 "m4v,3g2,3gp,nsv,tp,ts,ty,pls,rm,rmvb,mpd,ifo,mov,qt,divx,xvid,bivx,vob,nrg,img,iso,udf,pva,wmv,asf,asx,ogm,m2v,avi,bin,dat,mpg,mpeg,mp4,mkv,"
……@@ -31,9 +32,7 @@ SEARCH_PARAMS = {
3132
3233
3334class Easynews(BaseClient):
34 def __init__(
35 self, user: str, password: str, timeout: int, notification: Callable
36 ) -> None:
35 def __init__(self, user: str, password: str, timeout: int, notification: Callable) -> None:
3736 super().__init__("https://members.easynews.com", notification)
3837 self.user = user
3938 self.password = password
……@@ -47,6 +46,7 @@ class Easynews(BaseClient):
4746 media_type: str,
4847 season: Optional[int] = None,
4948 episode: Optional[int] = None,
49 **kwargs: Any,
5050 ) -> List[TorrentStream]:
5151 search_query = query
5252 if mode == "tv" and season:
……@@ -107,9 +107,7 @@ class Easynews(BaseClient):
107107 if re.match(r"^\d+s", duration) or re.match(r"^[0-5]m", duration):
108108 continue
109109
110 url = down_url + quote(
111 f"/{dl_farm}/{dl_port}/{post_hash}{ext}/{post_title}{ext}"
112 )
110 url = down_url + quote(f"/{dl_farm}/{dl_port}/{post_hash}{ext}/{post_title}{ext}")
113111 title = post_title
114112
115113 results.append(
……@@ -182,6 +180,7 @@ class Easynews(BaseClient):
182180
183181 def get_info(self) -> None:
184182 from datetime import datetime
183
185184 from lib.utils.kodi.utils import dialog_text, kodilog, translation
186185
187186 try:
plugin.video.jacktook/lib/clients/elfhosted.py +4 -3
……@@ -1,5 +1,6 @@
11import re
2from typing import List, Dict, Any, Optional, Callable
2from typing import Any, Callable, Dict, List, Optional
3
34from lib.clients.base import BaseClient, TorrentStream
45from lib.utils.kodi.utils import convert_size_to_bytes, translation
56
……@@ -24,14 +25,14 @@ class Elfhosted(BaseClient):
2425 else:
2526 self.handle_exception(translation(30231))
2627 return None
27
28
2829 res = self.session.get(url, timeout=10)
2930 if res.status_code != 200:
3031 return
3132 response = self.parse_response(res)
3233 return response
3334 except Exception as e:
34 self.handle_exception(f"{translation(30231)}: {str(e)}")
35 self.handle_exception(f"{translation(30231)}: {e!s}")
3536
3637 def parse_response(self, res: Any) -> List[TorrentStream]:
3738 res = res.json()
plugin.video.jacktook/lib/clients/external_scraper.py +311 -0
……@@ -0,0 +1,311 @@
1import json
2import os
3import sys
4from typing import Any, Callable, List, Optional
5
6from lib.clients.base import BaseClient
7from lib.domain.torrent import TorrentStream
8from lib.utils.general.utils import Indexer
9from lib.utils.kodi.utils import kodilog
10
11_BYTES_PER_GB = 1073741824
12
13
14class ExternalScraperClient(BaseClient):
15 """Generic client for external scraper modules.
16
17 Accepts any ``xbmc.python.module`` addon that exposes a ``sources()``
18 function returning provider tuples. The module is a soft dependency:
19 if the addon is not installed or fails to import, ``initialized`` is
20 ``False`` and searches return an empty list.
21
22 The data dict passed to providers:
23
24 * Movies: ``{imdb, title, aliases, year}``
25 * TV: ``{imdb, tvdb, tvshowtitle, title, aliases, year, season, episode}``
26 """
27
28 def __init__(
29 self,
30 module_id: str,
31 module_display_name: str,
32 notification: Optional[Callable],
33 ) -> None:
34 super().__init__(None, notification)
35 self.module_id = module_id
36 self.module_name = module_id.split(".")[-1]
37 self.module_display_name = module_display_name
38 self._sources_func = None
39 self._providers = None
40 self.initialized = self._init_providers()
41
42 # ------------------------------------------------------------------
43 # Initialisation
44 # ------------------------------------------------------------------
45
46 def _init_providers(self) -> bool:
47 """Locate the addon and import its provider list.
48
49 .. note::
50 ``sys.path`` is **not** restored after import. Providers
51 perform runtime imports inside their ``sources()`` methods,
52 so the library path must remain on ``sys.path`` for the
53 lifetime of the client.
54 """
55 addon_path = self._resolve_addon_path()
56 if not addon_path:
57 kodilog(f"ExternalScraper ({self.module_id}): addon not found")
58 return False
59
60 lib_path = os.path.join(addon_path, "lib")
61 if not os.path.isdir(lib_path):
62 kodilog(f"ExternalScraper ({self.module_id}): lib dir not found at {lib_path}")
63 return False
64
65 if lib_path not in sys.path:
66 sys.path.insert(0, lib_path)
67
68 try:
69 # Import the top-level package
70 module = __import__(self.module_name, fromlist=["sources"])
71 self._sources_func = module.sources
72 self._providers = self._sources_func(specified_folders=["torrents"])
73 provider_count = len(self._providers) if self._providers else 0
74 kodilog(f"ExternalScraper ({self.module_id}): loaded {provider_count} providers")
75 return bool(self._providers)
76 except Exception as exc:
77 kodilog(f"ExternalScraper ({self.module_id}): failed to import providers: {exc}")
78 return False
79
80 def _resolve_addon_path(self) -> Optional[str]:
81 """Locate the addon path via xbmcaddon or JSON-RPC."""
82 # 1) xbmcaddon (fastest)
83 try:
84 import xbmcaddon
85
86 addon = xbmcaddon.Addon(self.module_id)
87 return addon.getAddonInfo("path")
88 except Exception:
89 pass
90
91 # 2) JSON-RPC fallback
92 try:
93 import xbmc
94
95 response = xbmc.executeJSONRPC(
96 json.dumps(
97 {
98 "jsonrpc": "2.0",
99 "method": "Addons.GetAddonDetails",
100 "params": {
101 "addonid": self.module_id,
102 "properties": ["path"],
103 },
104 "id": 1,
105 }
106 )
107 )
108 data = json.loads(response)
109 return data.get("result", {}).get("addon", {}).get("path")
110 except Exception as exc:
111 kodilog(f"ExternalScraper ({self.module_id}): JSON-RPC fallback failed: {exc}")
112 return None
113
114 # ------------------------------------------------------------------
115 # Search
116 # ------------------------------------------------------------------
117
118 def search(
119 self,
120 tmdb_id: str,
121 query: str,
122 mode: str,
123 media_type: str,
124 season: Optional[int],
125 episode: Optional[int],
126 imdb_id: str = "",
127 tvdb_id: str = "",
128 year: str = "",
129 aliases: Optional[List[str]] = None,
130 **kwargs: Any,
131 ) -> List[TorrentStream]:
132 kodilog(
133 f"[ExternalScraper ({self.module_id})] search() called: "
134 f"query={query}, mode={mode}, imdb={imdb_id}, year={year}, aliases={aliases}, "
135 f"providers={len(self._providers) if self._providers else 0}"
136 )
137 if not self.initialized or not self._providers:
138 kodilog(f"[ExternalScraper ({self.module_id})] not initialised, returning empty")
139 return []
140
141 is_tv = mode == "tv" or media_type == "tv"
142
143 data = self._build_data(
144 query=query,
145 mode=mode,
146 season=season,
147 episode=episode,
148 imdb_id=imdb_id,
149 tvdb_id=tvdb_id,
150 year=year,
151 aliases=aliases,
152 )
153
154 all_results: List[TorrentStream] = []
155
156 for provider_name, source_class in self._providers:
157 # Filter providers by media-type capability
158 if is_tv:
159 if hasattr(source_class, "hasEpisodes") and not source_class.hasEpisodes:
160 continue
161 else:
162 if hasattr(source_class, "hasMovies") and not source_class.hasMovies:
163 continue
164
165 try:
166 provider_instance = source_class()
167
168 # Single episode / movie results
169 results = provider_instance.sources(data, {})
170 if results:
171 mapped = self._map_results(results, provider_name)
172 all_results.extend(mapped)
173
174 # Pack results for TV
175 if is_tv and hasattr(source_class, "pack_capable") and source_class.pack_capable:
176 try:
177 pack_results = provider_instance.sources_packs(data, {})
178 if pack_results:
179 mapped_packs = self._map_results(
180 pack_results, provider_name, is_pack=True
181 )
182 all_results.extend(mapped_packs)
183 except Exception as exc:
184 kodilog(
185 f"ExternalScraper ({self.module_id}): "
186 f"provider '{provider_name}' packs error: {exc}"
187 )
188 except Exception as exc:
189 kodilog(
190 f"ExternalScraper ({self.module_id}): provider '{provider_name}' error: {exc}"
191 )
192
193 kodilog(f"[ExternalScraper ({self.module_id})] returning {len(all_results)} results")
194 return all_results
195
196 # ------------------------------------------------------------------
197 # Data mapping helpers
198 # ------------------------------------------------------------------
199
200 @staticmethod
201 def _build_data(
202 query: str,
203 mode: str,
204 season: Optional[int],
205 episode: Optional[int],
206 imdb_id: str = "",
207 tvdb_id: str = "",
208 year: str = "",
209 aliases: Optional[List[str]] = None,
210 ) -> dict:
211 """Build the data dict that external providers expect.
212
213 Matches the convention:
214
215 * Movies: ``{imdb, title, aliases, year}``
216 * TV: ``{imdb, tvdb, tvshowtitle, title, aliases, year, season, episode}``
217 """
218 is_tv = mode in ("tv", "episode")
219
220 data: dict = {
221 "aliases": aliases or [],
222 "year": str(year) if year else "",
223 }
224
225 if imdb_id:
226 data["imdb"] = imdb_id
227
228 if is_tv:
229 data["tvshowtitle"] = query
230 data["title"] = ""
231 data["season"] = str(season) if season else ""
232 data["episode"] = str(episode) if episode else ""
233 if tvdb_id:
234 data["tvdb"] = tvdb_id
235 else:
236 data["title"] = query
237
238 return data
239
240 def _map_results(
241 self,
242 results: list,
243 provider_name: str,
244 is_pack: bool = False,
245 ) -> List[TorrentStream]:
246 """Map external provider result dicts to Jacktook TorrentStream."""
247 mapped: List[TorrentStream] = []
248
249 for item in results:
250 try:
251 info_hash = (item.get("hash") or "").lower()
252 url = item.get("url", "") or ""
253
254 # Some legacy providers put the hash in url as "magnet:HASH"
255 # without the ?xt=urn:btih: prefix and leave hash empty.
256 if url.startswith("magnet:") and not url.startswith("magnet:?"):
257 legacy_hash = url[7:].strip()
258 if legacy_hash:
259 info_hash = legacy_hash.lower()
260 url = f"magnet:?xt=urn:btih:{info_hash}"
261
262 if info_hash and not url.startswith("magnet:?"):
263 url = f"magnet:?xt=urn:btih:{info_hash}"
264
265 raw_size = item.get("size", 0)
266 try:
267 size_bytes = int(float(raw_size) * _BYTES_PER_GB) if raw_size else 0
268 except (ValueError, TypeError):
269 size_bytes = 0
270
271 package = item.get("package")
272 result_is_pack = is_pack or package in ("season", "show")
273
274 try:
275 seeders = int(item.get("seeders", 0) or 0)
276 except (ValueError, TypeError):
277 seeders = 0
278
279 quality = item.get("quality", "") or "N/A"
280 title = item.get("name", "") or ""
281
282 mapped.append(
283 TorrentStream(
284 title=title,
285 type="torrent",
286 indexer=Indexer.EXTERNAL_SCRAPER,
287 subindexer=provider_name,
288 addonInstanceLabel=self.module_display_name,
289 infoHash=info_hash,
290 url=url,
291 size=size_bytes,
292 seeders=seeders,
293 quality=quality,
294 provider=provider_name,
295 isPack=result_is_pack,
296 publishDate="",
297 )
298 )
299 except Exception as exc:
300 kodilog(f"ExternalScraper: failed to map result: {exc}")
301 continue
302
303 return mapped
304
305 # ------------------------------------------------------------------
306 # BaseClient contract
307 # ------------------------------------------------------------------
308
309 def parse_response(self, res) -> List[TorrentStream]:
310 """Not used — ExternalScraperClient does not make HTTP requests."""
311 return []
plugin.video.jacktook/lib/clients/fma.py +8 -21
……@@ -1,4 +1,5 @@
11import re
2
23import requests
34
45
……@@ -37,13 +38,9 @@ def extract_season(res):
3738 if name is not None
3839 ]
3940 else:
40 s_ids += [
41 re.findall(regex, name, re.IGNORECASE) for name in res.get("title")
42 ]
41 s_ids += [re.findall(regex, name, re.IGNORECASE) for name in res.get("title")]
4342
44 s_ids += [
45 re.findall(regex, name, re.IGNORECASE) for name in res.get("synonyms")
46 ]
43 s_ids += [re.findall(regex, name, re.IGNORECASE) for name in res.get("synonyms")]
4744
4845 s_ids = [s[0] for s in s_ids if s]
4946
……@@ -51,10 +48,8 @@ def extract_season(res):
5148 regex = r"\s(\d+)$"
5249 cour = False
5350 if isinstance(res.get("title"), dict):
54 for lang, name in res.get("title").items():
55 if name is not None and (
56 " part " in name.lower() or " cour " in name.lower()
57 ):
51 for _lang, name in res.get("title").items():
52 if name is not None and (" part " in name.lower() or " cour " in name.lower()):
5853 cour = True
5954 break
6055 if not cour:
……@@ -63,23 +58,15 @@ def extract_season(res):
6358 for lang, name in res.get("title").items()
6459 if name is not None
6560 ]
66 s_ids += [
67 re.findall(regex, name, re.IGNORECASE)
68 for name in res.get("synonyms")
69 ]
61 s_ids += [re.findall(regex, name, re.IGNORECASE) for name in res.get("synonyms")]
7062 else:
7163 for name in res.get("title"):
7264 if " part " in name.lower() or " cour " in name.lower():
7365 cour = True
7466 break
7567 if not cour:
76 s_ids += [
77 re.findall(regex, name, re.IGNORECASE) for name in res.get("title")
78 ]
79 s_ids += [
80 re.findall(regex, name, re.IGNORECASE)
81 for name in res.get("synonyms")
82 ]
68 s_ids += [re.findall(regex, name, re.IGNORECASE) for name in res.get("title")]
69 s_ids += [re.findall(regex, name, re.IGNORECASE) for name in res.get("synonyms")]
8370 s_ids = [s[0] for s in s_ids if s and int(s[0]) < 20]
8471
8572 return s_ids
plugin.video.jacktook/lib/clients/introdb.py +7 -7
……@@ -1,5 +1,7 @@
1import requests
21from datetime import timedelta
2
3import requests
4
35from lib.db.cached import MemoryCache
46from lib.utils.kodi.utils import kodilog
57
……@@ -14,6 +16,7 @@ _SENTINEL = "__introdb_none__"
1416def get_segments(imdb_id, season, episode):
1517 """
1618 Fetch intro/recap/outro segment timestamps from IntroDB for a given episode.
19
1720 Results are cached in memory for 24 hours to avoid redundant API calls.
1821
1922 Args:
……@@ -26,6 +29,7 @@ def get_segments(imdb_id, season, episode):
2629 or None if the request fails or no data is available.
2730 """
2831 if not imdb_id or not season or not episode:
32
2933 return None
3034
3135 cache_key = f"{imdb_id}.S{season}E{episode}"
……@@ -43,9 +47,7 @@ def get_segments(imdb_id, season, episode):
4347 if cached == _SENTINEL:
4448 kodilog(f"IntroDB: Cache hit (no data) for {imdb_id} S{season}E{episode}")
4549 return None
46 kodilog(
47 f"IntroDB: Cache hit for {imdb_id} S{season}E{episode} data={cached}"
48 )
50 kodilog(f"IntroDB: Cache hit for {imdb_id} S{season}E{episode} data={cached}")
4951 return cached
5052
5153 try:
……@@ -89,7 +91,5 @@ def get_segments(imdb_id, season, episode):
8991 kodilog(f"IntroDB: Request failed for {imdb_id} S{season}E{episode}: {e}")
9092 return None
9193 except (ValueError, KeyError) as e:
92 kodilog(
93 f"IntroDB: Failed to parse response for {imdb_id} S{season}E{episode}: {e}"
94 )
94 kodilog(f"IntroDB: Failed to parse response for {imdb_id} S{season}E{episode}: {e}")
9595 return None
plugin.video.jacktook/lib/clients/jackett.py +9 -12
……@@ -1,13 +1,14 @@
1from urllib.parse import quote
2from typing import Any, List, Optional
31import concurrent
2from typing import Any, List, Optional
3from urllib.parse import quote
4
45import requests
56
67from lib.clients.base import BaseClient
78from lib.domain.torrent import TorrentStream
89from lib.jacktook.utils import kodilog
9from lib.utils.kodi.logging import summarize_locator_for_log
1010from lib.utils.general.utils import USER_AGENT_HEADER
11from lib.utils.kodi.logging import summarize_locator_for_log
1112from lib.utils.kodi.settings import get_jackett_timeout
1213from lib.utils.kodi.utils import get_setting, notification, translation
1314from lib.utils.parsers import xmltodict
……@@ -97,9 +98,7 @@ class Jackett(BaseClient):
9798 year=year,
9899 )
99100 futures.append(
100 executor.submit(
101 self.session.get, url_ep, timeout=get_jackett_timeout()
102 )
101 executor.submit(self.session.get, url_ep, timeout=get_jackett_timeout())
103102 )
104103 if get_setting("include_season_packs"):
105104 url_season = self._build_url(
……@@ -132,9 +131,7 @@ class Jackett(BaseClient):
132131 year=year,
133132 )
134133 futures.append(
135 executor.submit(
136 self.session.get, url, timeout=get_jackett_timeout()
137 )
134 executor.submit(self.session.get, url, timeout=get_jackett_timeout())
138135 )
139136
140137 for future in concurrent.futures.as_completed(futures):
……@@ -147,7 +144,7 @@ class Jackett(BaseClient):
147144 notification(f"{translation(30229)} ({response.status_code})")
148145 return results if results else None
149146 except Exception as e:
150 self.handle_exception(f"{translation(30229)}: {str(e)}")
147 self.handle_exception(f"{translation(30229)}: {e!s}")
151148 return None
152149
153150 def search(
……@@ -173,7 +170,7 @@ class Jackett(BaseClient):
173170 year=year,
174171 )
175172 except Exception as e:
176 self.handle_exception(f"{translation(30229)}: {str(e)}")
173 self.handle_exception(f"{translation(30229)}: {e!s}")
177174 return None
178175
179176 def parse_response(self, res: Any) -> Optional[List[TorrentStream]]:
……@@ -190,7 +187,7 @@ class Jackett(BaseClient):
190187 kodilog(f"Parsed {len(results)} results from Jackett")
191188 return results
192189 except Exception as e:
193 self.handle_exception(f"Error parsing Jackett response: {str(e)}")
190 self.handle_exception(f"Error parsing Jackett response: {e!s}")
194191 return None
195192
196193 def extract_result(self, results: List[TorrentStream], item: dict) -> None:
plugin.video.jacktook/lib/clients/jackgram/client.py +4 -4
……@@ -1,12 +1,11 @@
1from typing import List, Dict, Optional, Any, Callable
1from typing import Any, Callable, Dict, List, Optional
2
23from lib.clients.base import BaseClient, TorrentStream
34from lib.utils.kodi.utils import kodilog, translation
45
56
67class Jackgram(BaseClient):
7 def __init__(
8 self, host: str, notification: Callable, token: Optional[str] = None
9 ) -> None:
8 def __init__(self, host: str, notification: Callable, token: Optional[str] = None) -> None:
109 super().__init__(host, notification)
1110 self.token = token
1211 if token:
……@@ -20,6 +19,7 @@ class Jackgram(BaseClient):
2019 media_type: str,
2120 season: Optional[int],
2221 episode: Optional[int],
22 **kwargs: Any,
2323 ) -> Optional[List[TorrentStream]]:
2424 try:
2525 if mode == "tv" or media_type == "tv":
plugin.video.jacktook/lib/clients/jackgram/utils.py +9 -15
……@@ -1,3 +1,7 @@
1import json
2
3from xbmcplugin import addDirectoryItem
4
15from lib.clients.tmdb.utils.utils import tmdb_get
26from lib.utils.general.utils import (
37 Indexer,
……@@ -9,7 +13,6 @@ from lib.utils.general.utils import (
913 set_media_infoTag,
1014 set_watched_title,
1115)
12
1316from lib.utils.kodi.utils import (
1417 ADDON_HANDLE,
1518 apply_section_view,
……@@ -22,9 +25,6 @@ from lib.utils.kodi.utils import (
2225 translation,
2326)
2427
25from xbmcplugin import addDirectoryItem
26import json
27
2828
2929def check_jackgram_active():
3030 jackgram_enabled = get_setting("jackgram_enabled")
……@@ -49,9 +49,7 @@ def list_jackgram_latest_movies(query):
4949 end_of_directory(cache=False)
5050 return
5151 results = jackgram_client.get_latest_movies(page=page)
52 process_results(
53 results, add_jackgram_title_item, "list_jackgram_latest_movies", page
54 )
52 process_results(results, add_jackgram_title_item, "list_jackgram_latest_movies", page)
5553
5654
5755def list_jackgram_latest_series(query):
……@@ -61,9 +59,7 @@ def list_jackgram_latest_series(query):
6159 end_of_directory(cache=False)
6260 return
6361 results = jackgram_client.get_latest_series(page=page)
64 process_results(
65 results, add_jackgram_title_item, "list_jackgram_latest_series", page
66 )
62 process_results(results, add_jackgram_title_item, "list_jackgram_latest_series", page)
6763
6864
6965def list_jackgram_raw_files(query):
……@@ -73,9 +69,7 @@ def list_jackgram_raw_files(query):
7369 end_of_directory(cache=False)
7470 return
7571 results = jackgram_client.get_files(page=page)
76 process_results(
77 results, add_jackgram_raw_file_item, "list_jackgram_raw_files", page
78 )
72 process_results(results, add_jackgram_raw_file_item, "list_jackgram_raw_files", page)
7973
8074
8175def add_jackgram_raw_file_item(item):
……@@ -109,8 +103,8 @@ def add_jackgram_title_item(entry):
109103 # Normalize mode
110104 mode = "movies" if api_type == "movie" else api_type
111105
112 imdb_id = getattr(details, "external_ids").get("imdb_id")
113 tvdb_id = getattr(details, "external_ids").get("tvdb_id")
106 imdb_id = details.external_ids.get("imdb_id")
107 tvdb_id = details.external_ids.get("tvdb_id")
114108 entry["ids"] = {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id}
115109
116110 list_item = make_list_item(label=title)
plugin.video.jacktook/lib/clients/mdblist/mdblist.py +3 -2
……@@ -1,4 +1,7 @@
11from urllib.parse import quote
2
3from xbmcplugin import addDirectoryItem
4
25from lib.clients.tmdb.utils.utils import mdblist_get, tmdb_get
36from lib.utils.general.utils import (
47 build_list_item,
……@@ -15,8 +18,6 @@ from lib.utils.kodi.utils import (
1518 translation,
1619)
1720
18from xbmcplugin import addDirectoryItem
19
2021
2122def search_mdbd_lists(params):
2223 mode = params.get("mode", "movie")
plugin.video.jacktook/lib/clients/medifusion.py +7 -8
……@@ -1,15 +1,16 @@
11import re
2from typing import List, Dict, Any, Optional, Callable
2from typing import Any, Callable, Dict, List, Optional
3
4from requests.utils import urlparse
35
46from lib.clients.base import BaseClient, TorrentStream
5from lib.utils.kodi.utils import convert_size_to_bytes, get_setting, translation
67from lib.utils.general.utils import (
78 MEDIA_FUSION_DEFAULT_KEY,
89 USER_AGENT_HEADER,
910 get_cached,
1011 set_cached,
1112)
12from requests.utils import urlparse
13from lib.utils.kodi.utils import convert_size_to_bytes, get_setting, translation
1314
1415
1516class MediaFusion(BaseClient):
……@@ -31,9 +32,7 @@ class MediaFusion(BaseClient):
3132 }
3233 path = self.session.post(self.encryption_url, json=config_json, timeout=4.0)
3334 path = path.json()["encrypted_str"]
34 api_key = (
35 path.replace(self.host, "").replace("manifest.json", "").strip("/")
36 )
35 api_key = path.replace(self.host, "").replace("manifest.json", "").strip("/")
3736 set_cached(data=api_key, path="md.rd.key")
3837 return api_key
3938 else:
……@@ -69,13 +68,13 @@ class MediaFusion(BaseClient):
6968 else:
7069 self.handle_exception(translation(30233))
7170 return None
72
71
7372 res = self.session.get(url, headers=USER_AGENT_HEADER, timeout=10)
7473 if res.status_code != 200:
7574 return
7675 return self.parse_response(res.json())
7776 except Exception as e:
78 self.handle_exception(f"{translation(30233)}: {str(e)}")
77 self.handle_exception(f"{translation(30233)}: {e!s}")
7978
8079 def parse_response(self, res: Dict[str, Any]) -> List[TorrentStream]:
8180 results = []
plugin.video.jacktook/lib/clients/peerflix.py +3 -2
……@@ -1,4 +1,5 @@
11import re
2
23from lib.clients.base import BaseClient
34from lib.utils.general.utils import USER_AGENT_HEADER
45from lib.utils.kodi.utils import translation
……@@ -20,7 +21,7 @@ class Peerflix(BaseClient):
2021 return
2122 return self.parse_response(res)
2223 except Exception as e:
23 self.handle_exception(f"{translation(30234)}: {str(e)}")
24 self.handle_exception(f"{translation(30234)}: {e!s}")
2425
2526 def parse_response(self, res):
2627 res = res.json()
……@@ -33,7 +34,7 @@ class Peerflix(BaseClient):
3334 "indexer": "Peerflix",
3435 "guid": item["infoHash"],
3536 "infoHash": item["infoHash"],
36 "size":item["sizebytes"] or 0,
37 "size": item["sizebytes"] or 0,
3738 "seeders": item.get("seed", 0) or 0,
3839 "languages": [item["language"]],
3940 "fullLanguages": [item["language"]],
plugin.video.jacktook/lib/clients/prowlarr.py +9 -17
……@@ -1,17 +1,15 @@
1import concurrent.futures
2from typing import Any, Callable, Dict, List, Optional
3
14from lib.clients.base import BaseClient, TorrentStream
25from lib.jacktook.utils import kodilog
36from lib.utils.kodi.logging import summarize_locator_for_log
4from lib.utils.kodi.utils import get_setting, notification, translation
57from lib.utils.kodi.settings import get_prowlarr_timeout
6
7from typing import Dict, List, Optional, Any, Callable
8import concurrent.futures
8from lib.utils.kodi.utils import get_setting, notification, translation
99
1010
1111class Prowlarr(BaseClient):
12 def __init__(
13 self, host: str, apikey: str, port: str, notification: Callable
14 ) -> None:
12 def __init__(self, host: str, apikey: str, port: str, notification: Callable) -> None:
1513 super().__init__(host, notification)
1614 self.base_url = f"{self.host}:{port}/api/v1/search"
1715 self.apikey = apikey
……@@ -52,9 +50,7 @@ class Prowlarr(BaseClient):
5250 }
5351 if indexers:
5452 indexer_ids = [
55 i.strip()
56 for i in indexers.replace(",", " ").split()
57 if i.strip()
53 i.strip() for i in indexers.replace(",", " ").split() if i.strip()
5854 ]
5955 if indexer_ids:
6056 params_ep["indexerIds"] = indexer_ids
……@@ -76,9 +72,7 @@ class Prowlarr(BaseClient):
7672 }
7773 if indexers:
7874 indexer_ids = [
79 i.strip()
80 for i in indexers.replace(",", " ").split()
81 if i.strip()
75 i.strip() for i in indexers.replace(",", " ").split() if i.strip()
8276 ]
8377 if indexer_ids:
8478 params_season["indexerIds"] = indexer_ids
……@@ -102,9 +96,7 @@ class Prowlarr(BaseClient):
10296 params["categories"] = [2000, 5070, 5000, 127720, 140679]
10397 if indexers:
10498 indexer_ids = [
105 i.strip()
106 for i in indexers.replace(",", " ").split()
107 if i.strip()
99 i.strip() for i in indexers.replace(",", " ").split() if i.strip()
108100 ]
109101 if indexer_ids:
110102 params["indexerIds"] = indexer_ids
……@@ -128,7 +120,7 @@ class Prowlarr(BaseClient):
128120 results.extend(res)
129121 return results if results else None
130122 except Exception as e:
131 self.handle_exception(f"{translation(30230)}: {str(e)}")
123 self.handle_exception(f"{translation(30230)}: {e!s}")
132124 return None
133125
134126 @staticmethod
plugin.video.jacktook/lib/clients/simkl.py +4 -4
……@@ -1,18 +1,18 @@
11import json
2
23import requests
34
5
46class SIMKL:
57 def __init__(self):
6 self.ClientID = (
7 "59dfdc579d244e1edf6f89874d521d37a69a95a1abd349910cb056a1872ba2c8"
8 )
8 self.ClientID = "59dfdc579d244e1edf6f89874d521d37a69a95a1abd349910cb056a1872ba2c8"
99 self.base_url = "https://api.simkl.com/"
1010 self.imagePath = "https://wsrv.nl/?url=https://simkl.in/episodes/%s_w.webp"
1111
1212 def _to_url(self, url=""):
1313 if url.startswith("/"):
1414 url = url[1:]
15 return "%s/%s" % (self.base_url[:-1], url)
15 return f"{self.base_url[:-1]}/{url}"
1616
1717 def make_request(self, endpoint, params):
1818 res = requests.get(
plugin.video.jacktook/lib/clients/stremio/addon_client.py +25 -23
……@@ -1,17 +1,16 @@
1import re
2from typing import Any, Dict, List, Optional
3
14from lib.api.stremio.addon_manager import Addon, build_addon_instance_label
2from lib.api.stremio.models import Stream, Meta, MetaPreview
5from lib.api.stremio.models import Meta, MetaPreview, Stream
36from lib.clients.base import BaseClient, TorrentStream
4
7from lib.clients.stremio.helpers import get_addon_display_name
58from lib.utils.debrid.debrid_utils import process_external_cache
69from lib.utils.general.utils import USER_AGENT_HEADER, IndexerType, info_hash_to_magnet
710from lib.utils.kodi.settings import get_int_setting
811from lib.utils.kodi.utils import convert_size_to_bytes, get_setting, kodilog
912from lib.utils.localization.language_detection import find_languages_in_string
1013
11import re
12from typing import List, Dict, Optional, Any
13
14
1514EXCLUDED_RD_ADDONS = ["org.nuvio.streams", "org.mycine.addon"]
1615
1716
……@@ -107,7 +106,15 @@ class StremioAddonClient(BaseClient):
107106 def __init__(self, addon: Addon) -> None:
108107 super().__init__(None, None)
109108 self.addon = addon
110 self.instance_label = build_addon_instance_label(addon)
109 self.display_name = get_addon_display_name(addon)
110 self.instance_label = build_addon_instance_label(
111 {
112 "manifest": {"id": addon.manifest.id, "name": self.display_name},
113 "transportUrl": addon.transport_url,
114 "transportName": addon.transport_name,
115 }
116 )
117 self.indexer_name = (addon.manifest.name or addon.manifest.id).split(" ")[0]
111118
112119 def search(
113120 self,
……@@ -145,17 +152,13 @@ class StremioAddonClient(BaseClient):
145152 if res.status_code != 200:
146153 return []
147154 response = self.parse_response(res)
148 kodilog(
149 f"Stremio addon {self.addon.manifest.name} returned {len(response)} results"
150 )
155 kodilog(f"Stremio addon {self.display_name} returned {len(response)} results")
151156 return response
152157 except Exception as e:
153 self.handle_exception(f"Error in {self.addon.manifest.name}: {str(e)}")
158 self.handle_exception(f"Error in {self.display_name}: {e!s}")
154159 return []
155160
156 def parse_response(
157 self, res: Any, is_external_cache: bool = False
158 ) -> List[TorrentStream]:
161 def parse_response(self, res: Any, is_external_cache: bool = False) -> List[TorrentStream]:
159162 if not is_external_cache:
160163 res_json = res.json()
161164 streams = res_json.get("streams", [])
……@@ -175,9 +178,7 @@ class StremioAddonClient(BaseClient):
175178
176179 for item in streams:
177180 stream = Stream.from_dict(item)
178 parsed = self.parse_torrent_description(
179 stream.description or stream.title or ""
180 )
181 parsed = self.parse_torrent_description(stream.description or stream.title or "")
181182
182183 if is_external_cache:
183184 match = re.search(r"\b\w{40}\b", stream.url or "")
……@@ -187,7 +188,7 @@ class StremioAddonClient(BaseClient):
187188 else:
188189 info_hash = stream.infoHash
189190 url = stream.url
190 is_cached = True if url else False
191 is_cached = bool(url)
191192
192193 stream_type = IndexerType.STREMIO_DEBRID if url else IndexerType.TORRENT
193194 stream_provider = stream.get_provider() or parsed["provider"]
……@@ -199,10 +200,11 @@ class StremioAddonClient(BaseClient):
199200 TorrentStream(
200201 title=stream.get_parsed_title(),
201202 type=stream_type,
202 indexer=self.addon.manifest.name.split(" ")[0],
203 indexer=self.indexer_name,
203204 subindexer=stream_subindexer,
204205 addonKey=self.addon.key(),
205 addonName=self.addon.manifest.name,
206 addonName=self.display_name,
207 addonSourceName=self.addon.manifest.name or self.addon.manifest.id,
206208 addonInstanceLabel=self.instance_label,
207209 guid=info_hash_to_magnet(info_hash) if info_hash else "",
208210 infoHash=info_hash if info_hash else "",
……@@ -229,14 +231,14 @@ class StremioAddonClient(BaseClient):
229231 "languages": [],
230232 }
231233 # Extract size
232 size_pattern = r"💾 ([\d.]+ (?:GB|MB))"
233 size_match = re.search(size_pattern, desc)
234 size_pattern = r"💾 ([\d.,]+ (?:GB|MB|Go|Mo))"
235 size_match = re.search(size_pattern, desc, re.IGNORECASE)
234236 size = size_match.group(1) if size_match else None
235237 if size:
236238 size = convert_size_to_bytes(size)
237239
238240 # Extract seeders
239 seeders_pattern = r"👤 (\d+)"
241 seeders_pattern = r"[👥👤] (\d+)"
240242 seeders_match = re.search(seeders_pattern, desc)
241243 seeders = int(seeders_match.group(1)) if seeders_match else None
242244
plugin.video.jacktook/lib/clients/stremio/addon_selection.py +155 -62
……@@ -1,28 +1,44 @@
1import requests
2from datetime import timedelta
31from collections import Counter
2from datetime import timedelta
3
4import requests
5import xbmcgui
6
7from lib.api.stremio.addon_manager import build_addon_instance_key
8from lib.clients.stremio.constants import (
9 STREMIO_ADDON_ALIASES_KEY,
10 STREMIO_ADDONS_CATALOGS_KEY,
11 STREMIO_ADDONS_KEY,
12 STREMIO_CATALOG_ALIASES_KEY,
13 STREMIO_TV_ADDONS_KEY,
14 STREMIO_USER_ADDONS,
15 decode_selected_ids,
16 encode_selected_ids,
17 excluded_addons,
18)
19from lib.clients.stremio.helpers import (
20 clear_addon_alias,
21 clear_catalog_alias,
22 get_addon_alias,
23 get_addon_display_name,
24 get_addon_merge_key,
25 get_addons,
26 get_catalog_alias,
27 get_catalog_display_name,
28 ping_addons,
29 set_addon_alias,
30 set_catalog_alias,
31)
432from lib.db.cached import cache
533from lib.utils.general.utils import USER_AGENT_HEADER
34from lib.utils.kodi.settings import get_int_setting
635from lib.utils.kodi.utils import (
7 kodilog,
36 ADDON,
837 get_setting,
38 kodilog,
939 set_setting,
10 ADDON,
1140 translation,
1241)
13from lib.utils.kodi.settings import get_int_setting
14from lib.clients.stremio.constants import (
15 STREMIO_ADDONS_KEY,
16 STREMIO_ADDONS_CATALOGS_KEY,
17 STREMIO_TV_ADDONS_KEY,
18 STREMIO_USER_ADDONS,
19 excluded_addons,
20 encode_selected_ids,
21 decode_selected_ids,
22)
23from lib.clients.stremio.helpers import get_addons, ping_addons, get_addon_merge_key
24from lib.api.stremio.addon_manager import build_addon_instance_key
25import xbmcgui
2642
2743
2844def _ping_addons_with_progress(addons):
……@@ -54,13 +70,13 @@ def _ping_addons_with_progress(addons):
5470
5571def _build_addon_options(addons):
5672 """Build list items for addon multiselect with deduplication."""
57 name_counts = Counter(addon.manifest.name for addon in addons)
73 name_counts = Counter(get_addon_display_name(addon) for addon in addons)
5874 options = []
5975
6076 for addon in addons:
61 name = addon.manifest.name
77 name = get_addon_display_name(addon)
6278 if name_counts[name] > 1:
63 label = addon.label()
79 label = addon.label().replace(addon.manifest.name, name, 1)
6480 else:
6581 label = name
6682
……@@ -76,6 +92,50 @@ def _build_addon_options(addons):
7692 return options
7793
7894
95def _select_addon(title, addons):
96 if not addons:
97 _show_no_addons_dialog()
98 return None
99
100 options = _build_addon_options(addons)
101 selected = xbmcgui.Dialog().select(title, options, useDetails=True)
102 if selected < 0:
103 return None
104 return addons[selected]
105
106
107def _get_aliasable_addons():
108 addon_manager = get_addons()
109 return _deduplicate_addons(
110 [addon for addon in addon_manager.addons if addon.manifest.id != "org.stremio.local"]
111 )
112
113
114def _clear_aliases_for_addon_keys(addon_keys):
115 addon_aliases = cache.get(STREMIO_ADDON_ALIASES_KEY) or {}
116 if isinstance(addon_aliases, dict):
117 addon_aliases = {k: v for k, v in addon_aliases.items() if k not in addon_keys}
118 cache.set(STREMIO_ADDON_ALIASES_KEY, addon_aliases, timedelta(days=365 * 20))
119
120 catalog_aliases = cache.get(STREMIO_CATALOG_ALIASES_KEY) or {}
121 if isinstance(catalog_aliases, dict):
122 catalog_aliases = {
123 k: v
124 for k, v in catalog_aliases.items()
125 if not any(k == addon_key or k.startswith(f"{addon_key}|") for addon_key in addon_keys)
126 }
127 cache.set(STREMIO_CATALOG_ALIASES_KEY, catalog_aliases, timedelta(days=365 * 20))
128
129
130def _should_clear_existing_alias():
131 return xbmcgui.Dialog().yesno(
132 translation(90821),
133 translation(90831),
134 nolabel=translation(90829),
135 yeslabel=translation(90830),
136 )
137
138
79139def _show_addon_multiselect(title, addons, selected_ids):
80140 """Show multiselect dialog and return selected addon keys."""
81141 options = _build_addon_options(addons)
……@@ -124,10 +184,7 @@ def _filter_excluded_addons(addons):
124184 addon
125185 for addon in addons
126186 if addon.manifest.id not in excluded_addons
127 and (
128 not addon.manifest.isConfigurationRequired()
129 or addon.transport_name == "custom"
130 )
187 and (not addon.manifest.isConfigurationRequired() or addon.transport_name == "custom")
131188 ]
132189
133190
……@@ -217,10 +274,9 @@ def stremio_toggle_catalogs(params, check_availability=False):
217274 ):
218275 is_tv = True
219276 break
220 elif res.name == "stream":
221 if "tv" in res.types or "channel" in res.types:
222 is_tv = True
223 break
277 elif res.name == "stream" and ("tv" in res.types or "channel" in res.types):
278 is_tv = True
279 break
224280 if not is_tv:
225281 filtered_addons.append(addon)
226282 addons = filtered_addons
……@@ -264,10 +320,11 @@ def stremio_toggle_tv_addons(params, check_availability=False):
264320 ):
265321 addons.append(addon)
266322 break
267 elif resource.name == "stream":
268 if "tv" in resource.types or "channel" in resource.types:
269 addons.append(addon)
270 break
323 elif resource.name == "stream" and (
324 "tv" in resource.types or "channel" in resource.types
325 ):
326 addons.append(addon)
327 break
271328
272329 addons = _deduplicate_addons(addons)
273330 addons = _filter_excluded_addons(addons)
……@@ -299,9 +356,7 @@ def stremio_toggle_tv_addons(params, check_availability=False):
299356
300357def add_custom_stremio_addon(params):
301358 dialog = xbmcgui.Dialog()
302 url = dialog.input(
303 translation(90523), type=xbmcgui.INPUT_ALPHANUM
304 )
359 url = dialog.input(translation(90523), type=xbmcgui.INPUT_ALPHANUM)
305360 if not url:
306361 dialog.ok(translation(90522), translation(90524))
307362 return
……@@ -326,15 +381,11 @@ def add_custom_stremio_addon(params):
326381 if not id_:
327382 dialog.ok(translation(90522), translation(90526))
328383 return
329 addon_key = build_addon_instance_key(
330 {"manifest": manifest, "transportUrl": response.url}
331 )
384 addon_key = build_addon_instance_key({"manifest": manifest, "transportUrl": response.url})
332385
333386 resources = manifest.get("resources", [])
334387 # Normalize resources to list of dicts or strings
335 if isinstance(resources, dict):
336 resources = [resources]
337 elif isinstance(resources, str):
388 if isinstance(resources, (dict, str)):
338389 resources = [resources]
339390
340391 # Determine capabilities
……@@ -357,9 +408,10 @@ def add_custom_stremio_addon(params):
357408 is_catalog = True
358409 elif isinstance(res, str):
359410 if res == "stream":
360 # For string resource, rely on top-elevel types
411 # For string resource, rely on top-level types.
361412 if "movie" in types or "series" in types:
362 is_stream = True # Assumption for now, though checking for 'tt' prefix is safer properly but here we just have string
413 # Checking for 'tt' prefix would be safer, but strings do not expose it.
414 is_stream = True
363415 if "tv" in types or "channel" in types:
364416 is_tv_stream = True
365417 if res == "catalog":
……@@ -389,9 +441,7 @@ def add_custom_stremio_addon(params):
389441
390442 # Add to selected catalogs if catalog
391443 if is_catalog:
392 selected_catalog_keys = decode_selected_ids(
393 cache.get(STREMIO_ADDONS_CATALOGS_KEY)
394 )
444 selected_catalog_keys = decode_selected_ids(cache.get(STREMIO_ADDONS_CATALOGS_KEY))
395445 if addon_key not in selected_catalog_keys:
396446 selected_catalog_keys.append(addon_key)
397447 cache.set(
……@@ -433,11 +483,7 @@ def remove_custom_stremio_addon(params=None):
433483 options = []
434484 for addon in removable_addons:
435485 manifest = addon.get("manifest", {})
436 name = (
437 manifest.get("name")
438 or manifest.get("id")
439 or addon.get("transportUrl", "Unknown")
440 )
486 name = manifest.get("name") or manifest.get("id") or addon.get("transportUrl", "Unknown")
441487 desc = manifest.get("description", "")
442488 item = xbmcgui.ListItem(label=name, label2=desc)
443489
……@@ -467,12 +513,9 @@ def remove_custom_stremio_addon(params=None):
467513 to_remove_keys.add(addon_key)
468514
469515 # Remove from user_addons
470 new_user_addons = [
471 a
472 for a in user_addons
473 if build_addon_instance_key(a) not in to_remove_keys
474 ]
516 new_user_addons = [a for a in user_addons if build_addon_instance_key(a) not in to_remove_keys]
475517 cache.set(STREMIO_USER_ADDONS, new_user_addons, timedelta(days=365 * 20))
518 _clear_aliases_for_addon_keys(to_remove_keys)
476519
477520 # Remove from selected stream/catalogs/tv if present
478521 for cache_key in [
……@@ -483,13 +526,65 @@ def remove_custom_stremio_addon(params=None):
483526 selected_keys = decode_selected_ids(cache.get(cache_key))
484527 if selected_keys:
485528 selected_keys = [k for k in selected_keys if k not in to_remove_keys]
486 cache.set(
487 cache_key, encode_selected_ids(selected_keys), timedelta(days=365 * 20)
488 )
529 cache.set(cache_key, encode_selected_ids(selected_keys), timedelta(days=365 * 20))
489530
490531 xbmcgui.Dialog().ok(translation(90531), translation(90534))
491532
492533
534def rename_stremio_addon(params=None):
535 addons = _get_aliasable_addons()
536 addon = _select_addon(translation(90819), addons)
537 if not addon:
538 return
539
540 current_alias = get_addon_alias(addon)
541 if current_alias and _should_clear_existing_alias():
542 clear_addon_alias(addon)
543 xbmcgui.Dialog().ok(translation(90821), translation(90823))
544 return
545
546 new_alias = xbmcgui.Dialog().input(
547 translation(90820), defaultt=current_alias, type=xbmcgui.INPUT_ALPHANUM
548 )
549 if (new_alias or "").strip():
550 set_addon_alias(addon, new_alias)
551 xbmcgui.Dialog().ok(translation(90821), translation(90822))
552
553
554def rename_stremio_catalog(params=None):
555 addons = [addon for addon in _get_aliasable_addons() if addon.manifest.catalogs]
556 addon = _select_addon(translation(90824), addons)
557 if not addon:
558 return
559
560 catalog_options = []
561 for catalog in addon.manifest.catalogs:
562 item = xbmcgui.ListItem(
563 label=get_catalog_display_name(addon, catalog),
564 label2=f"{get_addon_display_name(addon)} - {catalog.type}/{catalog.id}",
565 )
566 item.setArt({"icon": addon.manifest.logo or "DefaultAddon.png"})
567 catalog_options.append(item)
568
569 selected = xbmcgui.Dialog().select(translation(90825), catalog_options, useDetails=True)
570 if selected < 0:
571 return
572
573 catalog = addon.manifest.catalogs[selected]
574 current_alias = get_catalog_alias(addon, catalog)
575 if current_alias and _should_clear_existing_alias():
576 clear_catalog_alias(addon, catalog)
577 xbmcgui.Dialog().ok(translation(90821), translation(90823))
578 return
579
580 new_alias = xbmcgui.Dialog().input(
581 translation(90826), defaultt=current_alias, type=xbmcgui.INPUT_ALPHANUM
582 )
583 if (new_alias or "").strip():
584 set_catalog_alias(addon, catalog, new_alias)
585 xbmcgui.Dialog().ok(translation(90821), translation(90822))
586
587
493588def stremio_bypass_addons_select(params=None):
494589 addon_manager = get_addons()
495590 addons = addon_manager.get_addons_with_resource("stream")
……@@ -503,9 +598,7 @@ def stremio_bypass_addons_select(params=None):
503598
504599 selected_ids = []
505600 for addon in addons:
506 if addon.key() in selected_values:
507 selected_ids.append(addon.key())
508 elif addon.manifest.name.lower() in selected_values:
601 if addon.key() in selected_values or addon.manifest.name.lower() in selected_values:
509602 selected_ids.append(addon.key())
510603
511604 title = translation(90213)
plugin.video.jacktook/lib/clients/stremio/authentication.py +12 -17
……@@ -1,5 +1,14 @@
11from datetime import timedelta
2
3import xbmcgui
4
25from lib.api.stremio.api_client import Stremio
6from lib.clients.stremio.constants import (
7 STREMIO_ADDONS_CATALOGS_KEY,
8 STREMIO_ADDONS_KEY,
9 STREMIO_USER_ADDONS,
10)
11from lib.clients.stremio.helpers import merge_addons_lists
312from lib.db.cached import cache
413from lib.utils.kodi.utils import (
514 get_setting,
……@@ -7,21 +16,11 @@ from lib.utils.kodi.utils import (
716 set_setting,
817 translation,
918)
10from lib.clients.stremio.constants import (
11 STREMIO_ADDONS_KEY,
12 STREMIO_ADDONS_CATALOGS_KEY,
13 STREMIO_USER_ADDONS,
14)
15from lib.clients.stremio.helpers import merge_addons_lists
16import xbmcgui
1719
1820
1921def stremio_login(params):
2022 dialog = xbmcgui.Dialog()
21 dialog.ok(
22 translation(90612),
23 translation(90613)
24 )
23 dialog.ok(translation(90612), translation(90613))
2524
2625 email = dialog.input(heading=translation(90614), type=xbmcgui.INPUT_ALPHANUM)
2726 if not email:
……@@ -46,9 +45,7 @@ def log_in(email, password, dialog):
4645 # Only merge user account addons with custom addons
4746 user_account_addons = stremio.get_my_addons() or []
4847 all_user_addons = cache.get(STREMIO_USER_ADDONS) or []
49 custom_addons = [
50 a for a in all_user_addons if a.get("transportName") == "custom"
51 ]
48 custom_addons = [a for a in all_user_addons if a.get("transportName") == "custom"]
5249 all_addons = merge_addons_lists(user_account_addons, custom_addons)
5350 cache.set(STREMIO_USER_ADDONS, all_addons, timedelta(days=365 * 20))
5451
……@@ -99,9 +96,7 @@ def stremio_logout(params):
9996 cache.set(STREMIO_ADDONS_CATALOGS_KEY, None, timedelta(seconds=1))
10097 # Do not clear custom addons, only clear login state and user (login) addons
10198 all_user_addons = cache.get(STREMIO_USER_ADDONS) or []
102 custom_addons = [
103 a for a in all_user_addons if a.get("transportName") == "custom"
104 ]
99 custom_addons = [a for a in all_user_addons if a.get("transportName") == "custom"]
105100 cache.set(STREMIO_USER_ADDONS, custom_addons, timedelta(days=365 * 20))
106101
107102 set_setting("stremio_loggedin", "false")
plugin.video.jacktook/lib/clients/stremio/catalog_menus.py +47 -64
……@@ -1,10 +1,16 @@
11import json
22import os
3from datetime import datetime, timedelta
43from dataclasses import asdict
4from datetime import datetime, timedelta
55from lib.api.stremio.models import extract_proxy_headers
6from urllib.parse import quote
7
8from xbmcplugin import addDirectoryItem, setContent
9
610from lib.clients.stremio.helpers import (
711 get_addon_by_base_url,
12 get_addon_display_name,
13 get_catalog_display_name,
814 get_selected_catalogs_addons,
915 get_selected_tv_addons,
1016)
……@@ -12,30 +18,32 @@ from lib.clients.tmdb.utils.utils import (
1218 add_tmdb_episode_context_menu,
1319 add_tmdb_movie_context_menu,
1420 add_tmdb_show_context_menu,
21 tmdb_get,
1522)
16from lib.clients.tmdb.utils.utils import tmdb_get
17from lib.utils.general.utils import add_next_button, truncate_text
1823from lib.db.cached import cache
1924from lib.db.pickle_db import PickleDatabase
20from lib.utils.stremio.catalogs_utils import catalogs_get_cache
25from lib.utils.general.utils import (
26 IndexerType,
27 add_next_button,
28 info_hash_to_magnet,
29 truncate_text,
30)
31from lib.utils.kodi.settings import get_cache_expiration
2132from lib.utils.kodi.utils import (
33 ADDON_HANDLE,
34 ADDON_PATH,
2235 add_directory_items_batch,
2336 build_url,
2437 container_update,
2538 end_of_directory,
2639 kodi_play_media,
40 kodilog,
2741 make_list_item,
2842 notification,
2943 show_keyboard,
30 kodilog,
3144 translation,
3245)
33from lib.utils.general.utils import info_hash_to_magnet, IndexerType
34
35from xbmcplugin import addDirectoryItem, setContent
36from lib.utils.kodi.settings import get_cache_expiration
37from lib.utils.kodi.utils import ADDON_HANDLE, ADDON_PATH
38
46from lib.utils.stremio.catalogs_utils import catalogs_get_cache
3947
4048CATALOG_PAGE_SIZE = 25
4149
……@@ -67,9 +75,7 @@ def _show_search_catalog_history(params):
6775
6876 for (text,) in cache.get_list(key=history_key):
6977 list_item = make_list_item(label=f"[I]{text}[/I]")
70 list_item.setArt(
71 {"icon": os.path.join(ADDON_PATH, "resources", "img", "search.png")}
72 )
78 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "search.png")})
7379 addDirectoryItem(
7480 ADDON_HANDLE,
7581 build_url(
……@@ -225,14 +231,14 @@ def list_stremio_catalogs(menu_type="", sub_menu_type=""):
225231 directory_items = []
226232
227233 for addon in selected_addons:
228 addon_name = addon.manifest.name
234 addon_name = get_addon_display_name(addon)
229235 addon_types = addon.manifest.types
230236
231237 if menu_type not in addon_types:
232238 continue
233239
234240 for catalog in addon.manifest.catalogs:
235 catalog_name = catalog.name
241 catalog_name = get_catalog_display_name(addon, catalog)
236242 catalog_id = catalog.id
237243 catalog_type = catalog.type
238244
……@@ -243,15 +249,10 @@ def list_stremio_catalogs(menu_type="", sub_menu_type=""):
243249 if menu_type == "anime" and target_type == "series":
244250 allowed_types.append("anime")
245251
246 if (
247 target_type in ["movie", "series", "tv"]
248 and catalog_type not in allowed_types
249 ):
252 if target_type in ["movie", "series", "tv"] and catalog_type not in allowed_types:
250253 continue
251254
252 search_capabilities = any(
253 extra.get("name") == "search" for extra in catalog.extra
254 )
255 search_capabilities = any(extra.get("name") == "search" for extra in catalog.extra)
255256
256257 if search_capabilities:
257258 listitem = make_list_item(label=f"{translation(90006)} {catalog_name}")
……@@ -276,8 +277,7 @@ def list_stremio_catalogs(menu_type="", sub_menu_type=""):
276277 )
277278
278279 if catalog_name or catalog_id:
279 addon_name = addon.manifest.name
280 if addon_name == "Cinemeta":
280 if addon.manifest.name == "Cinemeta":
281281 label = f"{addon_name} - {catalog_name or catalog_id}"
282282 else:
283283 label = catalog_name or catalog_id
……@@ -378,9 +378,7 @@ def list_catalog(params):
378378 skip=skip + len(metas),
379379 )
380380 list_item = make_list_item(label=translation(90515))
381 addDirectoryItem(
382 handle=ADDON_HANDLE, url=next_url, listitem=list_item, isFolder=True
383 )
381 addDirectoryItem(handle=ADDON_HANDLE, url=next_url, listitem=list_item, isFolder=True)
384382
385383 end_of_directory()
386384
……@@ -517,9 +515,7 @@ def add_meta_items(metas, params):
517515 meta_id=meta_id,
518516 )
519517 elif tmdb_id or imdb_id:
520 url = build_url(
521 "show_seasons_details", ids=ids, mode="tv", media_type="tv"
522 )
518 url = build_url("show_seasons_details", ids=ids, mode="tv", media_type="tv")
523519 else:
524520 url = build_url(
525521 "list_stremio_seasons",
……@@ -635,18 +631,13 @@ def add_meta_items(metas, params):
635631 elif meta_type in ["series", "anime"]:
636632 context_menu.extend(
637633 _filter_tmdb_context_menu(
638 add_tmdb_show_context_menu(
639 mode="tv", ids=context_menu_ids, title=name
640 )
634 add_tmdb_show_context_menu(mode="tv", ids=context_menu_ids, title=name)
641635 )
642636 )
643637
644638 _append_context_menu_items(list_item, context_menu)
645639
646 addDirectoryItem(
647 handle=ADDON_HANDLE, url=url, listitem=list_item, isFolder=is_folder
648 )
649
640 addDirectoryItem(handle=ADDON_HANDLE, url=url, listitem=list_item, isFolder=is_folder)
650641
651642
652643def list_stremio_seasons(params):
……@@ -666,8 +657,7 @@ def list_stremio_seasons(params):
666657 return
667658
668659 available_seasons = set(
669 int(video.imdbSeason) if video.imdbSeason else int(video.season)
670 for video in videos
660 int(video.imdbSeason) if video.imdbSeason else int(video.season) for video in videos
671661 )
672662 items = []
673663 for season in available_seasons:
……@@ -680,9 +670,7 @@ def list_stremio_seasons(params):
680670 )
681671 list_item = make_list_item(label=f"{translation(90512)} {season}")
682672 info_tag = list_item.getVideoInfoTag()
683 info_tag.setUniqueID(
684 meta_data.id, type="imdb" if meta_data.id.startswith("tt") else "mf"
685 )
673 info_tag.setUniqueID(meta_data.id, type="imdb" if meta_data.id.startswith("tt") else "mf")
686674 info_tag.setTitle(meta_data.name)
687675 info_tag.setPlot(truncate_text(meta_data.description or ""))
688676 info_tag.setRating(float(meta_data.imdbRating or 0))
……@@ -732,14 +720,12 @@ def list_stremio_episodes(params):
732720 imdb_id = meta_data.imdb_id
733721 res = tmdb_get("find_by_imdb_id", imdb_id)
734722 if getattr(res, "tv_results", []):
735 tmdb_id = str(getattr(res, "tv_results")[0]["id"])
723 tmdb_id = str(res.tv_results[0]["id"])
736724
737725 if not tmdb_id and not imdb_id and meta_data.name:
738726 kodilog(f"No IDs found for {meta_data.name}. Searching TMDB...")
739727 year = meta_data.releaseInfo.split("-")[0] if meta_data.releaseInfo else ""
740 results = tmdb_get(
741 "search_tv", {"query": meta_data.name, "page": 1, "year": year}
742 )
728 results = tmdb_get("search_tv", {"query": meta_data.name, "page": 1, "year": year})
743729 if results and results.results:
744730 try:
745731 tmdb_id = str(results.results[0].id)
……@@ -754,16 +740,12 @@ def list_stremio_episodes(params):
754740 kodilog(f"Resolved IMDB ID: {imdb_id} from TMDB ID: {tmdb_id}")
755741
756742 addon = get_addon_by_base_url(params["addon_url"])
757 has_stream_resource = addon_has_stream(
758 params["addon_url"], params["catalog_type"], addon=addon
759 )
743 has_stream_resource = addon_has_stream(params["addon_url"], params["catalog_type"], addon=addon)
760744 items = []
761745 for video in videos:
762746 try:
763747 season = int(video.imdbSeason) if video.imdbSeason else int(video.season)
764 episode = (
765 int(video.imdbEpisode) if video.imdbEpisode else int(video.episode)
766 )
748 episode = int(video.imdbEpisode) if video.imdbEpisode else int(video.episode)
767749 except (ValueError, TypeError):
768750 continue
769751
……@@ -773,7 +755,7 @@ def list_stremio_episodes(params):
773755 title = video.title or getattr(video, "name", "") or meta_data.name
774756
775757 tv_data = {
776 "name": title,
758 "name": quote(title),
777759 "episode": episode,
778760 "season": season,
779761 }
……@@ -802,9 +784,7 @@ def list_stremio_episodes(params):
802784
803785 list_item = make_list_item(label=f"{season}x{episode}. {title}")
804786 info_tag = list_item.getVideoInfoTag()
805 info_tag.setUniqueID(
806 meta_data.id, type="imdb" if meta_data.id.startswith("tt") else "mf"
807 )
787 info_tag.setUniqueID(meta_data.id, type="imdb" if meta_data.id.startswith("tt") else "mf")
808788 info_tag.setTitle(title)
809789 info_tag.setPlot(truncate_text(video.overview or ""))
810790 info_tag.setRating(float(meta_data.imdbRating or 0))
……@@ -837,14 +817,17 @@ def list_stremio_episodes(params):
837817 ]
838818 context_menu_ids = _resolve_tmdb_ids_for_context_menu(ids, "series")
839819 if _has_reliable_tmdb_ids(context_menu_ids):
840 context_menu = _filter_tmdb_context_menu(
841 add_tmdb_episode_context_menu(
842 mode="tv",
843 tv_name=meta_data.name,
844 tv_data=tv_data,
845 ids=context_menu_ids,
820 context_menu = (
821 _filter_tmdb_context_menu(
822 add_tmdb_episode_context_menu(
823 mode="tv",
824 tv_name=meta_data.name,
825 tv_data=tv_data,
826 ids=context_menu_ids,
827 )
846828 )
847 ) + context_menu
829 + context_menu
830 )
848831 else:
849832 context_menu.insert(
850833 0,
plugin.video.jacktook/lib/clients/stremio/constants.py +2 -0
……@@ -27,6 +27,8 @@ def decode_selected_ids(raw):
2727
2828STREMIO_ADDONS_KEY = "stremio_addons"
2929STREMIO_ADDONS_CATALOGS_KEY = "stremio_catalog_addons"
30STREMIO_ADDON_ALIASES_KEY = "stremio_addon_aliases"
31STREMIO_CATALOG_ALIASES_KEY = "stremio_catalog_aliases"
3032STREMIO_TV_ADDONS_KEY = "stremio_tv_addons"
3133STREMIO_USER_ADDONS = "stremio_user_addons"
3234TORRENTIO_PROVIDERS_KEY = "torrentio.providers"
plugin.video.jacktook/lib/clients/stremio/helpers.py +78 -9
……@@ -1,26 +1,97 @@
1from typing import List, Callable, Optional
2import requests
31import concurrent.futures
2from datetime import timedelta
3from typing import Callable, List, Optional
4
5import requests
6
47from lib.api.stremio.addon_manager import (
58 Addon,
69 AddonManager,
710 build_addon_instance_key,
8 normalize_transport_url,
911)
1012from lib.api.stremio.api_client import Stremio
11from lib.db.cached import cache
12from lib.utils.kodi.utils import get_setting, kodilog
1313from lib.clients.stremio.constants import (
14 STREMIO_ADDONS_KEY,
14 STREMIO_ADDON_ALIASES_KEY,
1515 STREMIO_ADDONS_CATALOGS_KEY,
16 STREMIO_ADDONS_KEY,
17 STREMIO_CATALOG_ALIASES_KEY,
1618 STREMIO_TV_ADDONS_KEY,
1719 STREMIO_USER_ADDONS,
1820 decode_selected_ids,
1921)
22from lib.db.cached import cache
23from lib.utils.kodi.utils import get_setting, kodilog
24
25
2026def get_addon_merge_key(addon):
2127 return build_addon_instance_key(addon)
2228
2329
30def _get_addon_alias_key(addon):
31 if hasattr(addon, "key"):
32 return addon.key()
33 return get_addon_merge_key(addon)
34
35
36def _get_aliases(cache_key):
37 aliases = cache.get(cache_key) or {}
38 if not isinstance(aliases, dict):
39 return {}
40 return aliases
41
42
43def _set_alias(cache_key, alias_key, alias):
44 aliases = _get_aliases(cache_key)
45 clean_alias = (alias or "").strip()
46 if clean_alias:
47 aliases[alias_key] = clean_alias
48 else:
49 aliases.pop(alias_key, None)
50 cache.set(cache_key, aliases, timedelta(days=365 * 20))
51
52
53def get_catalog_alias_key(addon, catalog):
54 return f"{_get_addon_alias_key(addon)}|{catalog.type}|{catalog.id}"
55
56
57def get_addon_display_name(addon):
58 alias = _get_aliases(STREMIO_ADDON_ALIASES_KEY).get(_get_addon_alias_key(addon))
59 return alias or addon.manifest.name
60
61
62def get_catalog_display_name(addon, catalog):
63 alias = _get_aliases(STREMIO_CATALOG_ALIASES_KEY).get(
64 get_catalog_alias_key(addon, catalog)
65 )
66 return alias or catalog.name or catalog.id
67
68
69def set_addon_alias(addon, alias):
70 _set_alias(STREMIO_ADDON_ALIASES_KEY, _get_addon_alias_key(addon), alias)
71
72
73def clear_addon_alias(addon):
74 set_addon_alias(addon, "")
75
76
77def get_addon_alias(addon):
78 return _get_aliases(STREMIO_ADDON_ALIASES_KEY).get(_get_addon_alias_key(addon), "")
79
80
81def set_catalog_alias(addon, catalog, alias):
82 _set_alias(STREMIO_CATALOG_ALIASES_KEY, get_catalog_alias_key(addon, catalog), alias)
83
84
85def clear_catalog_alias(addon, catalog):
86 set_catalog_alias(addon, catalog, "")
87
88
89def get_catalog_alias(addon, catalog):
90 return _get_aliases(STREMIO_CATALOG_ALIASES_KEY).get(
91 get_catalog_alias_key(addon, catalog), ""
92 )
93
94
2495def merge_addons_lists(*lists):
2596 seen = set()
2697 merged = []
……@@ -76,9 +147,7 @@ def _resolve_selected_addons(catalog: AddonManager, selected_ids_list: List[str]
76147def get_addons():
77148 all_user_addons = cache.get(STREMIO_USER_ADDONS) or []
78149 custom_addons = [a for a in all_user_addons if a.get("transportName") == "custom"]
79 cached_account_addons = [
80 a for a in all_user_addons if a.get("transportName") != "custom"
81 ]
150 cached_account_addons = [a for a in all_user_addons if a.get("transportName") != "custom"]
82151 user_addons = list(cached_account_addons)
83152
84153 logged_in = get_setting("stremio_loggedin")
plugin.video.jacktook/lib/clients/stremio/manage_phone.py +1 -3
……@@ -1,10 +1,8 @@
1# -*- coding: utf-8 -*-
1from lib.gui.qr_progress_dialog import QRProgressDialog
22from lib.services.webserver import StremioWebServer, get_local_ip
33from lib.utils.debrid.qrcode_utils import make_qrcode
4from lib.gui.qr_progress_dialog import QRProgressDialog
54from lib.utils.kodi.utils import ADDON_PATH, kodilog, translation
65
7
86_SERVER_PORT = 8081
97
108
plugin.video.jacktook/lib/clients/stremio/torrentio.py +10 -12
……@@ -1,15 +1,17 @@
11import os
22from datetime import timedelta
3
4import xbmcgui
5
6from lib.clients.stremio.constants import (
7 TORRENTIO_PROVIDERS_KEY,
8 all_torrentio_providers,
9)
310from lib.db.cached import cache
411from lib.utils.kodi.utils import (
512 ADDON_PATH,
613 translation,
714)
8from lib.clients.stremio.constants import (
9 TORRENTIO_PROVIDERS_KEY,
10 all_torrentio_providers,
11)
12import xbmcgui
1315
1416
1517def torrentio_toggle_providers(params):
……@@ -18,11 +20,9 @@ def torrentio_toggle_providers(params):
1820
1921 options = []
2022 selected_indexes = []
21 for i, (key, name, logo) in enumerate(all_torrentio_providers):
23 for i, (key, name, _logo) in enumerate(all_torrentio_providers):
2224 item = xbmcgui.ListItem(label=name)
23 item.setArt(
24 {"icon": os.path.join(ADDON_PATH, "resources", "img", "torrentio.png")}
25 )
25 item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "torrentio.png")})
2626 options.append(item)
2727 if key in selected_ids:
2828 selected_indexes.append(i)
……@@ -37,6 +37,4 @@ def torrentio_toggle_providers(params):
3737 return
3838
3939 new_selected_ids = [all_torrentio_providers[i][0] for i in selected]
40 cache.set(
41 TORRENTIO_PROVIDERS_KEY, ",".join(new_selected_ids), timedelta(days=365 * 20)
42 )
40 cache.set(TORRENTIO_PROVIDERS_KEY, ",".join(new_selected_ids), timedelta(days=365 * 20))
plugin.video.jacktook/lib/clients/subtitle/deepl.py +29 -23
……@@ -1,5 +1,11 @@
11import traceback
2from os import path as ospath
3from os import stat
4from time import sleep
5
26import requests
7import xbmcgui
8
39from lib.clients.subtitle.utils import get_deepl_language_code, slugify
410from lib.utils.kodi.utils import (
511 ADDON_PROFILE_PATH,
……@@ -7,10 +13,6 @@ from lib.utils.kodi.utils import (
713 kodilog,
814 translation,
915)
10from os import path as ospath, stat
11from time import sleep
12
13import xbmcgui
1416
1517
1618def show_dialog(title, message):
……@@ -29,9 +31,11 @@ class DeepLTranslator:
2931 def _handle_deepl_response(self, response, context=""):
3032 """
3133 Handle DeepL API responses, showing a Kodi dialog and logging errors for non-200 codes.
34
3235 Returns True if response is OK, otherwise False.
3336 """
3437 if response.status_code == 200:
38
3539 return True
3640
3741 status_messages = {
……@@ -48,7 +52,7 @@ class DeepLTranslator:
4852 f"Unexpected error occurred (status code: {response.status_code}).",
4953 )
5054
51 msg = f"DeepL API error during {context}.\n" f"{user_message}\n\n"
55 msg = f"DeepL API error during {context}.\n{user_message}\n\n"
5256 self.notification(heading=translation(90652), message=msg)
5357 return False
5458
……@@ -64,9 +68,11 @@ class DeepLTranslator:
6468 ):
6569 """
6670 Download the translated document from DeepL and save it as an .srt file.
71
6772 The file path is determined based on whether season/episode info is provided.
6873 """
6974 if season and episode:
75
7076 new_subtitle_file_path = (
7177 f"{ADDON_PROFILE_PATH}Subtitles/{imdb_id}/{season}/{episode}/"
7278 f"Translated_Subtitle No.{idx}.S{season}E{episode}.{lang_name}.srt"
……@@ -90,9 +96,7 @@ class DeepLTranslator:
9096 "Content-Type": "application/json",
9197 }
9298
93 response = requests.post(
94 url, json={"document_key": document_key}, headers=headers
95 )
99 response = requests.post(url, json={"document_key": document_key}, headers=headers)
96100 if not self._handle_deepl_response(response, "result download"):
97101 raise Exception("DeepL API error during result download")
98102
……@@ -110,7 +114,7 @@ class DeepLTranslator:
110114 total_character_count = 0
111115 for file_path in subtitle_paths:
112116 try:
113 with open(file_path, "r", encoding="utf-8") as f:
117 with open(file_path, encoding="utf-8") as f:
114118 content = f.read()
115119
116120 lines = content.split("\n")
……@@ -158,9 +162,11 @@ class DeepLTranslator:
158162 def validate_file_limits(self, file_path):
159163 """
160164 Check if a file meets size and character count limits.
165
161166 Raises Exception and notifies the user if not.
162167 """
163168 MAX_FILE_SIZE_KB = 150
169
164170 MAX_CHARACTERS = 1000000
165171
166172 stats = stat(file_path)
……@@ -171,7 +177,7 @@ class DeepLTranslator:
171177 self.notification(msg)
172178 raise Exception(msg)
173179
174 with open(file_path, "r", encoding="utf-8") as f:
180 with open(file_path, encoding="utf-8") as f:
175181 content = f.read()
176182
177183 character_count = len(content)
……@@ -181,16 +187,16 @@ class DeepLTranslator:
181187 self.notification(msg)
182188 raise Exception(msg)
183189
184 kodilog(
185 f"File check passed: Size={file_size_kb:.2f}KB, Characters={character_count}"
186 )
190 kodilog(f"File check passed: Size={file_size_kb:.2f}KB, Characters={character_count}")
187191
188192 def filter_files_within_limits(self, file_paths):
189193 """
190194 Returns a list of file paths that are within the allowed limits.
195
191196 Notifies the user for each file that is skipped.
192197 """
193198 valid_files = []
199
194200 for file_path in file_paths:
195201 try:
196202 self.validate_file_limits(file_path)
……@@ -209,9 +215,7 @@ class DeepLTranslator:
209215 "Authorization": f"DeepL-Auth-Key {self.api_key}",
210216 "Content-Type": "application/json",
211217 }
212 response = requests.get(
213 url, params={"document_key": document_key}, headers=headers
214 )
218 response = requests.get(url, params={"document_key": document_key}, headers=headers)
215219
216220 kodilog("Check status response text: " + response.text)
217221
……@@ -242,13 +246,11 @@ class DeepLTranslator:
242246 total_characters = 0
243247 for file_path in subtitle_paths:
244248 try:
245 with open(file_path, "r", encoding="utf-8") as f:
249 with open(file_path, encoding="utf-8") as f:
246250 content = f.read()
247251 total_characters += len(content)
248252 except Exception as error:
249 kodilog(
250 f"Error reading file for cost calculation: {file_path}: {error}"
251 )
253 kodilog(f"Error reading file for cost calculation: {file_path}: {error}")
252254
253255 estimated_cost = (total_characters / 1_000_000) * PRICE_PER_MILLION
254256 return total_characters, estimated_cost
……@@ -256,9 +258,11 @@ class DeepLTranslator:
256258 def get_free_characters_left(self):
257259 """
258260 Query DeepL API for remaining free characters.
261
259262 Returns the number of free characters left, or 0 if unavailable.
260263 """
261264 url = f"{self.base_url}/usage"
265
262266 headers = {"Authorization": f"DeepL-Auth-Key {self.api_key}"}
263267 try:
264268 response = requests.get(url, headers=headers)
……@@ -275,13 +279,13 @@ class DeepLTranslator:
275279 def prompt_user_for_cost(self, subtitle_paths):
276280 """
277281 Show a dialog to the user with the estimated translation cost and ask for confirmation.
282
278283 Only show cost if free usage is exhausted.
279284 Returns True if user confirms, False otherwise.
280285 """
281286 free_chars_left = self.get_free_characters_left()
282 total_characters, estimated_cost = self.calculate_translation_cost(
283 subtitle_paths
284 )
287
288 total_characters, estimated_cost = self.calculate_translation_cost(subtitle_paths)
285289
286290 if free_chars_left > total_characters:
287291 message = translation(90666) % (free_chars_left, total_characters)
……@@ -342,9 +346,11 @@ class DeepLTranslator:
342346 def translate_multiple_subtitles(self, sub_paths, imdbid, season, episode):
343347 """
344348 Process multiple subtitle files by translating each one.
349
345350 Only files within limits are processed.
346351 """
347352 # Filter files within limits and notify user for skipped files
353
348354 valid_sub_paths = self.filter_files_within_limits(sub_paths)
349355 if not valid_sub_paths:
350356 self.notification("No subtitle files are within the allowed limits.")
plugin.video.jacktook/lib/clients/subtitle/opensubstremio.py +34 -30
……@@ -1,5 +1,10 @@
1import os
2from typing import Any, Callable, Dict, List, Optional
3
14import requests
2from typing import Callable, List, Optional, Dict, Any
5import xbmc
6import xbmcgui
7
38from lib.clients.subtitle.utils import (
49 get_language_code,
510 language_code_to_name,
……@@ -12,8 +17,6 @@ from lib.utils.kodi.utils import (
1217 kodilog,
1318 translation,
1419)
15import xbmcgui
16import xbmc
1720
1821
1922class OpenSubtitleStremioClient:
……@@ -39,9 +42,7 @@ class OpenSubtitleStremioClient:
3942 try:
4043 response = requests.get(url)
4144 if response.status_code != 200:
42 self.notification(
43 f"Failed to fetch subtitles, status code {response.status_code}"
44 )
45 self.notification(f"Failed to fetch subtitles, status code {response.status_code}")
4546 return None
4647 data = response.json()
4748 kodilog(f"OpenSubtitles Subtitles Response: {data}", level=xbmc.LOGDEBUG)
……@@ -56,6 +57,7 @@ class OpenSubtitleStremioClient:
5657 imdb_id: str,
5758 season: Optional[int] = None,
5859 episode: Optional[int] = None,
60 auto_select: bool = False,
5961 ) -> Optional[List[Dict[str, Any]]]:
6062 subtitles = self._fetch_subtitles_data(mode, imdb_id, season, episode)
6163 if not subtitles:
……@@ -64,16 +66,13 @@ class OpenSubtitleStremioClient:
6466 sub_language = get_setting("subtitle_language")
6567 auto_subtitle_download = get_setting("auto_subtitle_download")
6668 if auto_subtitle_download:
67 filtered = [
68 s for s in subtitles if s.get("lang") == get_language_code(sub_language)
69 ]
69 filtered = [s for s in subtitles if s.get("lang") == get_language_code(sub_language)]
7070 if filtered:
7171 return filtered
72 return []
7273
7374 items = [
74 xbmcgui.ListItem(
75 label=translation(90665) % i, label2=language_code_to_name(s["lang"])
76 )
75 xbmcgui.ListItem(label=translation(90665) % i, label2=language_code_to_name(s["lang"]))
7776 for i, s in enumerate(subtitles)
7877 ]
7978
……@@ -96,12 +95,13 @@ class OpenSubtitleStremioClient:
9695 title: str,
9796 season: Optional[int] = None,
9897 episode: Optional[int] = None,
98 folder_path: Optional[str] = None,
9999 ) -> List[str]:
100100 file_paths = []
101101 for idx, subtitle in enumerate(subtitles):
102102 try:
103103 file_path = self.download_subtitle(
104 subtitle, idx, imdb_id, title, season, episode
104 subtitle, idx, imdb_id, title, season, episode, folder_path
105105 )
106106 if file_path:
107107 file_paths.append(file_path)
……@@ -118,6 +118,7 @@ class OpenSubtitleStremioClient:
118118 title: str,
119119 season: Optional[int] = None,
120120 episode: Optional[int] = None,
121 folder_path: Optional[str] = None,
121122 ) -> Optional[str]:
122123 url = subtitle.get("url", "")
123124 lang = subtitle.get("lang")
……@@ -125,27 +126,30 @@ class OpenSubtitleStremioClient:
125126
126127 title = slugify_title(title)
127128
128 if season and episode:
129 file_path = (
130 f"{ADDON_PROFILE_PATH}Subtitles/{imdb_id}/{season}/{episode}/"
131 f"Subtitle No.{index}.{title}.S{season}E{episode}.{lang_name}.srt"
132 )
133 elif season:
134 file_path = (
135 f"{ADDON_PROFILE_PATH}Subtitles/{imdb_id}/{season}/"
136 f"Subtitle No.{index}.{title}.S{season}.{lang_name}.srt"
137 )
129 if folder_path:
130 file_path = os.path.join(folder_path, f"Subtitle No.{index}.{title}.{lang_name}.srt")
138131 else:
139 file_path = f"{ADDON_PROFILE_PATH}Subtitles/{imdb_id}/Subtitle No.{index}.{title}.{lang_name}.srt"
132 base_path = os.path.join(ADDON_PROFILE_PATH, "Subtitles", imdb_id)
133 if season and episode:
134 file_path = os.path.join(
135 base_path,
136 str(season),
137 str(episode),
138 f"Subtitle No.{index}.{title}.S{season}E{episode}.{lang_name}.srt",
139 )
140 elif season:
141 file_path = os.path.join(
142 base_path,
143 str(season),
144 f"Subtitle No.{index}.{title}.S{season}.{lang_name}.srt",
145 )
146 else:
147 file_path = os.path.join(base_path, f"Subtitle No.{index}.{title}.{lang_name}.srt")
140148
141149 try:
142 response = requests.get(
143 url, stream=True, headers=USER_AGENT_HEADER, timeout=15
144 )
150 response = requests.get(url, stream=True, headers=USER_AGENT_HEADER, timeout=15)
145151 if response.status_code != 200:
146 self.notification(
147 f"Failed to download {url}, status code {response.status_code}"
148 )
152 self.notification(f"Failed to download {url}, status code {response.status_code}")
149153 raise Exception(f"HTTP {response.status_code}")
150154 with open(file_path, "wb") as file:
151155 for chunk in response.iter_content(chunk_size=8192):
plugin.video.jacktook/lib/clients/subtitle/submanager.py +41 -24
……@@ -1,7 +1,10 @@
11import json
22import os
3
43from typing import Any, Dict, List, Optional
4
5import xbmc
6import xbmcgui
7
58from lib.clients.subtitle.deepl import DeepLTranslator
69from lib.clients.subtitle.opensubstremio import OpenSubtitleStremioClient
710from lib.utils.kodi.utils import (
……@@ -11,14 +14,9 @@ from lib.utils.kodi.utils import (
1114 translation,
1215)
1316
14import xbmc
15import xbmcgui
16
1717
1818class KodiJsonRpcClient:
19 def json_rpc(
20 self, method: str, params: Optional[Dict[str, Any]] = None
21 ) -> Dict[str, Any]:
19 def json_rpc(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
2220 """Send a JSON-RPC request to Kodi."""
2321 request_data = {
2422 "jsonrpc": "2.0",
……@@ -48,6 +46,7 @@ class SubtitleManager(KodiJsonRpcClient):
4846 def get_kodi_preferred_subtitle_language(self, iso_format: bool = False) -> str:
4947 """
5048 Get the preferred subtitle language from Kodi settings.
49
5150 Returns the language in ISO format if iso_format is True.
5251 """
5352 subtitle_language = self.json_rpc(
……@@ -67,33 +66,40 @@ class SubtitleManager(KodiJsonRpcClient):
6766 subtitle_files.append(os.path.join(root, f))
6867 return subtitle_files
6968
70 def fetch_subtitles(self, auto_select: bool = False) -> Optional[List[str]]:
69 def fetch_subtitles(
70 self,
71 auto_select: bool = False,
72 folder_path: Optional[str] = None,
73 imdb_id: Optional[str] = None,
74 title: Optional[str] = None,
75 ) -> Optional[List[str]]:
7176 self.last_fetch_status = None
72 title = self.data.get("title")
77 title = title or self.data.get("title")
7378 mode = self.data.get("mode")
74 imdb_id = self.data.get("ids", {}).get("imdb_id")
79 imdb_id = imdb_id or self.data.get("ids", {}).get("imdb_id")
7580 tv_data = self.data.get("tv_data", {})
7681 episode = tv_data.get("episode")
7782 season = tv_data.get("season")
7883
7984 if not imdb_id:
8085 kodilog("No IMDb ID found for the current video, skipping subtitles")
86 self.last_fetch_status = "no_imdb"
8187 return None
8288
83 folder_path = (
84 os.path.join(
85 ADDON_PROFILE_PATH, "Subtitles", imdb_id, str(season), str(episode)
89 if folder_path is None:
90 folder_path = (
91 os.path.join(ADDON_PROFILE_PATH, "Subtitles", imdb_id, str(season), str(episode))
92 if mode == "tv"
93 else os.path.join(ADDON_PROFILE_PATH, "Subtitles", imdb_id)
8694 )
87 if mode == "tv"
88 else os.path.join(ADDON_PROFILE_PATH, "Subtitles", imdb_id)
89 )
9095
9196 if not os.path.exists(folder_path):
9297 os.makedirs(folder_path)
9398
9499 subtitle_files = self.get_downloaded_subtitle_paths(folder_path)
95100 if subtitle_files:
96 if auto_select:
101 # Skip "use existing?" dialog when auto-download or auto-select is enabled
102 if auto_select or get_setting("auto_subtitle_download"):
97103 return subtitle_files
98104
99105 dialog = xbmcgui.Dialog()
……@@ -106,7 +112,9 @@ class SubtitleManager(KodiJsonRpcClient):
106112 if use_existing:
107113 return subtitle_files
108114
109 subtitles = self.opensub_client.get_subtitles(mode, imdb_id, season, episode)
115 subtitles = self.opensub_client.get_subtitles(
116 mode, imdb_id, season, episode, auto_select=auto_select
117 )
110118 if subtitles is None:
111119 self.last_fetch_status = "not_found"
112120 self.notification(translation(90252))
……@@ -118,21 +126,30 @@ class SubtitleManager(KodiJsonRpcClient):
118126 return None
119127
120128 subtitle_paths = self.opensub_client.download_subtitles_batch(
121 subtitles, imdb_id, title=title, season=season, episode=episode
129 subtitles,
130 imdb_id,
131 title=title,
132 season=season,
133 episode=episode,
134 folder_path=folder_path,
122135 )
123136
124137 if get_setting("deepl_enabled"):
138 if auto_select:
139 translated_subtitles_paths = self.translator.translate_multiple_subtitles(
140 subtitle_paths, imdb_id, season, episode
141 )
142 return translated_subtitles_paths or subtitle_paths
143
125144 dialog = xbmcgui.Dialog()
126145 yes = dialog.yesno(
127146 translation(90254),
128147 translation(90255),
129148 )
130149 if yes:
131 translated_subtitles_paths = (
132 self.translator.translate_multiple_subtitles(
133 subtitle_paths, imdb_id, season, episode
134 )
150 translated_subtitles_paths = self.translator.translate_multiple_subtitles(
151 subtitle_paths, imdb_id, season, episode
135152 )
136 return translated_subtitles_paths
153 return translated_subtitles_paths or subtitle_paths
137154
138155 return subtitle_paths
plugin.video.jacktook/lib/clients/subtitle/utils.py +2 -2
……@@ -55,7 +55,7 @@ def get_deepl_language_code(language_name):
5555 "Türkçe": "tr",
5656 "Українська": "uk",
5757 }
58 return language_mapping.get(language_name, None)
58 return language_mapping.get(language_name)
5959
6060
6161def get_language_code(language_name):
……@@ -95,7 +95,7 @@ def get_language_code(language_name):
9595 "Chinese": "zho",
9696 }
9797
98 return language_map.get(language_name, None)
98 return language_map.get(language_name)
9999
100100
101101def language_code_to_name(code):
plugin.video.jacktook/lib/clients/tmdb/anime.py +4 -8
……@@ -3,7 +3,6 @@ from lib.clients.tmdb.utils.utils import (
33 get_tmdb_movie_details,
44 get_tmdb_show_details,
55)
6
76from lib.db.pickle_db import PickleDatabase
87from lib.utils.general.utils import (
98 Animation,
……@@ -14,13 +13,12 @@ from lib.utils.general.utils import (
1413 set_media_infoTag,
1514 set_pluging_category,
1615)
17
1816from lib.utils.kodi.utils import (
1917 end_of_directory,
2018 kodilog,
2119 make_list_item,
22 show_keyboard,
2320 notification,
21 show_keyboard,
2422 translation,
2523)
2624
……@@ -57,9 +55,7 @@ class TmdbAnimeClient(BaseTmdbClient):
5755 notification("No results found")
5856 return
5957 execute_thread_pool(data.results, TmdbAnimeClient.show_anime_results, submode)
60 add_next_button(
61 "next_page_anime", page=page, mode=mode, submode=submode, category=category
62 )
58 add_next_button("next_page_anime", page=page, mode=mode, submode=submode, category=category)
6359 end_of_directory()
6460
6561 @staticmethod
……@@ -88,12 +84,12 @@ class TmdbAnimeClient(BaseTmdbClient):
8884 if mode == "movies":
8985 title = res.title
9086 movie_details = get_tmdb_movie_details(tmdb_id)
91 imdb_id = getattr(movie_details, "external_ids").get("imdb_id", "")
87 imdb_id = movie_details.external_ids.get("imdb_id", "")
9288 tvdb_id = ""
9389 elif mode == "tv":
9490 title = res.name
9591 show_details = get_tmdb_show_details(tmdb_id)
96 external_ids = getattr(show_details, "external_ids")
92 external_ids = show_details.external_ids
9793 imdb_id = external_ids.get("imdb_id", "")
9894 tvdb_id = external_ids.get("tvdb_id", "")
9995 else:
plugin.video.jacktook/lib/clients/tmdb/base.py +15 -17
……@@ -1,6 +1,20 @@
11from datetime import datetime
22
33from lib.api.tmdbv3api.as_obj import AsObj
4from lib.api.trakt.trakt_utils import (
5 add_trakt_collection_context_menu,
6 add_trakt_custom_list_context_menu,
7 add_trakt_favorites_context_menu,
8 add_trakt_watched_context_menu,
9 add_trakt_watchlist_context_menu,
10 is_trakt_auth,
11)
12from lib.clients.tmdb.utils.utils import (
13 add_kodi_dir_item,
14 add_tmdb_movie_context_menu,
15 add_tmdb_show_context_menu,
16 tmdb_get,
17)
418from lib.utils.general.utils import set_pluging_category
519from lib.utils.kodi.utils import (
620 add_directory_items_batch,
……@@ -11,29 +25,13 @@ from lib.utils.kodi.utils import (
1125 notification,
1226 translation,
1327)
14from lib.clients.tmdb.utils.utils import (
15 add_kodi_dir_item,
16 add_tmdb_movie_context_menu,
17 add_tmdb_show_context_menu,
18 tmdb_get,
19)
20from lib.api.trakt.trakt_utils import (
21 add_trakt_collection_context_menu,
22 add_trakt_custom_list_context_menu,
23 add_trakt_favorites_context_menu,
24 add_trakt_watchlist_context_menu,
25 add_trakt_watched_context_menu,
26 is_trakt_auth,
27)
2828
2929
3030class BaseTmdbClient:
3131 @staticmethod
3232 def add_media_directory_item(list_item, mode, title, ids, media_type="", batch=False):
3333 if mode == "movies" or (mode == "multi" and media_type == "movie"):
34 context_menu = add_tmdb_movie_context_menu(
35 mode, media_type, title=title, ids=ids
36 )
34 context_menu = add_tmdb_movie_context_menu(mode, media_type, title=title, ids=ids)
3735 if is_trakt_auth():
3836 context_menu += (
3937 add_trakt_watchlist_context_menu("movies", ids)
plugin.video.jacktook/lib/clients/tmdb/collections.py +15 -26
……@@ -1,8 +1,13 @@
1from lib.clients.tmdb.base import BaseTmdbClient
12from lib.clients.tmdb.utils.collections_utils import (
23 POPULAR_COLLECTIONS,
34 TOP_RATED_COLLECTIONS,
45)
5from lib.clients.tmdb.base import BaseTmdbClient
6from lib.clients.tmdb.utils.utils import (
7 add_kodi_dir_item,
8 get_tmdb_movie_details,
9 tmdb_get,
10)
611from lib.db.pickle_db import PickleDatabase
712from lib.utils.general.utils import (
813 add_next_button,
……@@ -10,24 +15,16 @@ from lib.utils.general.utils import (
1015 set_media_infoTag,
1116 set_pluging_category,
1217)
13
1418from lib.utils.kodi.utils import (
1519 add_directory_items_batch,
1620 apply_section_view,
1721 build_url,
1822 end_of_directory,
1923 make_list_item,
20 show_keyboard,
2124 notification,
25 show_keyboard,
2226 translation,
2327)
24from lib.clients.tmdb.utils.utils import (
25 tmdb_get,
26 add_kodi_dir_item,
27 get_tmdb_movie_details,
28)
29
30
3128
3229
3330class TmdbCollections(BaseTmdbClient):
……@@ -51,7 +48,7 @@ class TmdbCollections(BaseTmdbClient):
5148 imdb_id = ""
5249 details = get_tmdb_movie_details(tmdb_id)
5350 if details:
54 imdb_id = getattr(details, "external_ids").get("imdb_id", "")
51 imdb_id = details.external_ids.get("imdb_id", "")
5552
5653 add_kodi_dir_item(
5754 list_item=movie_item,
……@@ -110,15 +107,11 @@ class TmdbCollections(BaseTmdbClient):
110107 end_of_directory()
111108 return
112109
113 execute_thread_pool(
114 current_page_collections, TmdbCollections.fetch_and_add_collection
115 )
110 execute_thread_pool(current_page_collections, TmdbCollections.fetch_and_add_collection)
116111
117112 # Add "Next" button
118113 if end_index < len(POPULAR_COLLECTIONS):
119 add_next_button(
120 "handle_collection_query", submode="popular", page=page + 1, mode=mode
121 )
114 add_next_button("handle_collection_query", submode="popular", page=page + 1, mode=mode)
122115
123116 end_of_directory()
124117 apply_section_view("view.movies", content_type="movies")
……@@ -137,9 +130,7 @@ class TmdbCollections(BaseTmdbClient):
137130 end_of_directory()
138131 return
139132
140 execute_thread_pool(
141 current_page_collections, TmdbCollections.fetch_and_add_collection
142 )
133 execute_thread_pool(current_page_collections, TmdbCollections.fetch_and_add_collection)
143134
144135 # Add "Next" button
145136 if end_index < len(TOP_RATED_COLLECTIONS):
……@@ -162,7 +153,7 @@ class TmdbCollections(BaseTmdbClient):
162153 return
163154
164155 if page == 1:
165 PickleDatabase().set_key("collection_search_query", query)
156 PickleDatabase().set_key("collection_search_query", query)
166157
167158 results = tmdb_get("search_collections", params={"query": query, "page": page})
168159 if not results or getattr(results, "total_results", 0) == 0:
……@@ -170,9 +161,7 @@ class TmdbCollections(BaseTmdbClient):
170161 end_of_directory()
171162 return
172163
173 execute_thread_pool(
174 getattr(results, "results"), TmdbCollections._add_collection_item
175 )
164 execute_thread_pool(results.results, TmdbCollections._add_collection_item)
176165
177166 add_next_button(
178167 "handle_collection_query",
……@@ -192,6 +181,6 @@ class TmdbCollections(BaseTmdbClient):
192181 Helper function to extract collection ID from movie details.
193182 Designed to be used with execute_thread_pool.
194183 """
195 movie_details = get_tmdb_movie_details(getattr(movie, "id"))
196 if movie_details and getattr(movie_details, "belongs_to_collection"):
184 movie_details = get_tmdb_movie_details(movie.id)
185 if movie_details and movie_details.belongs_to_collection:
197186 collection_ids_set.add(movie_details.get("belongs_to_collection").get("id"))
plugin.video.jacktook/lib/clients/tmdb/people_client.py +14 -30
……@@ -1,4 +1,5 @@
11import json
2
23from lib.clients.tmdb.base import BaseTmdbClient
34from lib.clients.tmdb.utils.utils import add_kodi_dir_item, tmdb_get
45from lib.utils.general.utils import (
……@@ -11,10 +12,9 @@ from lib.utils.general.utils import (
1112from lib.utils.kodi.utils import (
1213 build_url,
1314 end_of_directory,
14 kodilog,
1515 make_list_item,
16 show_keyboard,
1716 notification,
17 show_keyboard,
1818 translation,
1919)
2020
……@@ -41,9 +41,7 @@ class PeopleClient(BaseTmdbClient):
4141 end_of_directory(cache=False)
4242 return
4343
44 execute_thread_pool(
45 getattr(data, "results"), PeopleClient.show_people_details, mode
46 )
44 execute_thread_pool(data.results, PeopleClient.show_people_details, mode)
4745
4846 add_next_button(
4947 "handle_tmdb_query",
……@@ -82,7 +80,7 @@ class PeopleClient(BaseTmdbClient):
8280 return
8381 PeopleClient.show_people_details(person, mode)
8482
85 execute_thread_pool(getattr(credits, "cast"), get_media_credits)
83 execute_thread_pool(credits.cast, get_media_credits)
8684
8785 end_of_directory()
8886
……@@ -95,9 +93,7 @@ class PeopleClient(BaseTmdbClient):
9593 notification(translation(90389))
9694 return
9795
98 execute_thread_pool(
99 getattr(data, "results"), PeopleClient.show_people_details, mode
100 )
96 execute_thread_pool(data.results, PeopleClient.show_people_details, mode)
10197
10298 add_next_button(
10399 "handle_tmdb_query",
……@@ -117,9 +113,7 @@ class PeopleClient(BaseTmdbClient):
117113 notification(translation(90389))
118114 return
119115
120 execute_thread_pool(
121 getattr(data, "results"), PeopleClient.show_people_details, mode
122 )
116 execute_thread_pool(data.results, PeopleClient.show_people_details, mode)
123117
124118 add_next_button(
125119 "handle_tmdb_query",
……@@ -150,8 +144,8 @@ class PeopleClient(BaseTmdbClient):
150144
151145 tmdb_id = credit.get("id")
152146 details = tmdb_get(f"{media_type}_details", tmdb_id)
153 imdb_id = getattr(details, "external_ids").get("imdb_id")
154 tvdb_id = getattr(details, "external_ids").get("tvdb_id")
147 imdb_id = details.external_ids.get("imdb_id")
148 tvdb_id = details.external_ids.get("tvdb_id")
155149
156150 ids = {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id}
157151
……@@ -189,9 +183,7 @@ class PeopleClient(BaseTmdbClient):
189183 set_media_infoTag(list_item, data=details, mode=mode)
190184 add_kodi_dir_item(
191185 list_item=list_item,
192 url=build_url(
193 "handle_tmdb_person_info", mode=mode, person_id=person.get("id")
194 ),
186 url=build_url("handle_tmdb_person_info", mode=mode, person_id=person.get("id")),
195187 is_folder=False,
196188 )
197189
……@@ -200,7 +192,7 @@ class PeopleClient(BaseTmdbClient):
200192 from lib.gui.actor_info_window import ActorInfoWindow
201193 from lib.utils.kodi.utils import ADDON_PATH
202194
203 mode = params.get("mode")
195 params.get("mode")
204196 person_id = params.get("person_id")
205197 window = ActorInfoWindow(
206198 "actor_info.xml",
……@@ -218,17 +210,13 @@ class PeopleClient(BaseTmdbClient):
218210
219211 if mode == "movies":
220212 media_type = "movie"
221 person_credits = tmdb_get(
222 "person_movie_credits", params=params.get("person_id")
223 )
213 person_credits = tmdb_get("person_movie_credits", params=params.get("person_id"))
224214 if not person_credits:
225215 notification(translation(90421))
226216 return
227217 elif mode == "tv":
228218 media_type = "tv"
229 person_credits = tmdb_get(
230 "person_tv_credits", params=params.get("person_id")
231 )
219 person_credits = tmdb_get("person_tv_credits", params=params.get("person_id"))
232220 if not person_credits:
233221 notification(translation(90421))
234222 return
……@@ -240,12 +228,8 @@ class PeopleClient(BaseTmdbClient):
240228 def get_date(c):
241229 return c.get("release_date") or c.get("first_air_date") or ""
242230
243 credits = sorted(
244 getattr(person_credits, "cast", []), key=get_date, reverse=True
245 )
231 credits = sorted(getattr(person_credits, "cast", []), key=get_date, reverse=True)
246232
247 execute_thread_pool(
248 credits, PeopleClient.show_credited_people, mode, media_type
249 )
233 execute_thread_pool(credits, PeopleClient.show_credited_people, mode, media_type)
250234
251235 end_of_directory()
plugin.video.jacktook/lib/clients/tmdb/tmdb.py +181 -80
……@@ -1,17 +1,25 @@
1from concurrent.futures import ThreadPoolExecutor
2from datetime import datetime, timedelta
31import json
42import os
3from concurrent.futures import ThreadPoolExecutor
4from datetime import datetime, timedelta
5from urllib.parse import quote
56
67from lib.api.tmdbv3api.as_obj import AsObj
78from lib.api.tmdbv3api.objs.anime import TmdbAnime
9from lib.api.tmdbv3api.objs.search import Search
10from lib.api.tmdbv3api.tmdb import TMDb
811from lib.clients.tmdb.anime import TmdbAnimeClient
912from lib.clients.tmdb.base import BaseTmdbClient
1013from lib.clients.tmdb.people_client import PeopleClient
11from lib.api.tmdbv3api.objs.search import Search
12from lib.clients.tmdb.utils.utils import add_kodi_dir_item, tmdb_get
14from lib.clients.tmdb.utils.utils import (
15 add_kodi_dir_item,
16 filter_excluded_languages,
17 tmdb_get,
18)
1319from lib.db.cached import cache
20from lib.db.pickle_db import PickleDatabase
1421from lib.utils.general.utils import (
22 Anime,
1523 add_next_button,
1624 execute_thread_pool,
1725 set_content_type,
……@@ -19,8 +27,6 @@ from lib.utils.general.utils import (
1927 set_pluging_category,
2028 translate_weekday,
2129)
22
23from lib.db.pickle_db import PickleDatabase
2430from lib.utils.kodi.utils import (
2531 ADDON_PATH,
2632 add_directory_items_batch,
……@@ -30,17 +36,17 @@ from lib.utils.kodi.utils import (
3036 end_of_directory,
3137 kodilog,
3238 make_list_item,
39 notification,
3340 show_busy_dialog,
3441 show_keyboard,
35 notification,
3642 translation,
3743)
38
3944from lib.utils.views.shows import show_episode_info, show_season_info
40from lib.utils.views.weekly_calendar import is_this_week, parse_date_str
41from lib.utils.views.weekly_calendar import get_episodes_for_show
42
43from lib.utils.general.utils import Anime
45from lib.utils.views.weekly_calendar import (
46 get_episodes_for_show,
47 is_this_week,
48 parse_date_str,
49)
4450
4551
4652def _apply_tmdb_view(mode, content_type=""):
……@@ -54,8 +60,9 @@ def _apply_tmdb_view(mode, content_type=""):
5460 return apply_section_view("view.episodes", content_type or "episodes")
5561 return apply_section_view("view.main", content_type=content_type)
5662
57import xbmcgui
63
5864import xbmc
65import xbmcgui
5966
6067
6168class TmdbClient(BaseTmdbClient):
……@@ -108,7 +115,8 @@ class TmdbClient(BaseTmdbClient):
108115
109116 media_type = TmdbClient._get_result_value(item, "media_type", "") or mode
110117 normalized_mode = "movies" if media_type in ("movie", "movies") else "tv"
111 cache_key = f"tmdb_ui_meta|{normalized_mode}|{tmdb_id}"
118 current_lang = TMDb().language
119 cache_key = f"tmdb_ui_meta|{normalized_mode}|{tmdb_id}|{current_lang}"
112120
113121 cached_metadata = cache.get(cache_key)
114122 if cached_metadata:
……@@ -117,6 +125,26 @@ class TmdbClient(BaseTmdbClient):
117125 return merged
118126
119127 metadata = TmdbClient._result_to_metadata(item)
128
129 # Fallback to English if localized title/name or overview is missing
130 has_title = bool(metadata.get("title") or metadata.get("name"))
131 has_overview = bool(metadata.get("overview"))
132 if not has_title or not has_overview:
133 try:
134 TMDb().language = "en-US"
135 path = "movie_details" if normalized_mode == "movies" else "tv_details"
136 en_data = tmdb_get(path, {"id": tmdb_id})
137 if en_data:
138 en_metadata = TmdbClient._result_to_metadata(en_data)
139 if not has_title:
140 for key in ("title", "name"):
141 if not metadata.get(key) and en_metadata.get(key):
142 metadata[key] = en_metadata[key]
143 if not has_overview and en_metadata.get("overview"):
144 metadata["overview"] = en_metadata["overview"]
145 finally:
146 TMDb().language = current_lang
147
120148 path = "movie_images" if normalized_mode == "movies" else "tv_images"
121149 images = tmdb_get(path, {"id": tmdb_id})
122150 if images:
……@@ -228,9 +256,7 @@ class TmdbClient(BaseTmdbClient):
228256 )
229257
230258 def handle_years_or_genres():
231 return TmdbAnimeClient().handle_anime_years_or_genres(
232 category, mode, page, submode
233 )
259 return TmdbAnimeClient().handle_anime_years_or_genres(category, mode, page, submode)
234260
235261 handlers = {
236262 Anime.SEARCH: handle_search,
……@@ -246,9 +272,7 @@ class TmdbClient(BaseTmdbClient):
246272 if handler:
247273 data = handler()
248274 if data:
249 TmdbAnimeClient().process_anime_results(
250 data, submode, page, mode, category
251 )
275 TmdbAnimeClient().process_anime_results(data, submode, page, mode, category)
252276
253277 @staticmethod
254278 def handle_tmdb_search(params):
……@@ -260,9 +284,7 @@ class TmdbClient(BaseTmdbClient):
260284 query = params.get("query")
261285 if not query:
262286 query = (
263 show_keyboard(id=30241)
264 if page == 1
265 else PickleDatabase().get_key("search_query")
287 show_keyboard(id=30241) if page == 1 else PickleDatabase().get_key("search_query")
266288 )
267289 if not query:
268290 end_of_directory(cache=False)
……@@ -273,6 +295,7 @@ class TmdbClient(BaseTmdbClient):
273295 if page == 1:
274296 PickleDatabase().set_key("search_query", query)
275297 from datetime import timedelta
298
276299 from lib.db.cached import cache
277300 from lib.utils.kodi.settings import get_cache_expiration
278301
……@@ -290,21 +313,29 @@ class TmdbClient(BaseTmdbClient):
290313 return
291314
292315 results = getattr(data, "results", [])
316 results = filter_excluded_languages(results)
293317 if results:
294318 directory_items = []
295319 for item in results:
296320 tmdb_id = item.get("id", "")
297 title = item.get("title", "") or item.get("name", "")
298321 media_type = item.get("media_type", "")
299322
323 if media_type not in ("movie", "tv"):
324 continue
325
326 metadata = TmdbClient._get_cached_tmdb_item_metadata(item, media_type)
327 title = (
328 metadata.get("title")
329 or metadata.get("name")
330 or item.get("title", "")
331 or item.get("name", "")
332 )
333
300334 if media_type == "movie":
301335 label_title = f"[B]{translation(90008)} -[/B] {title}"
302336 elif media_type == "tv":
303337 label_title = f"[B]{translation(90007)} -[/B] {title}"
304 else:
305 continue
306338
307 metadata = TmdbClient._get_cached_tmdb_item_metadata(item, media_type)
308339 list_item = make_list_item(label=label_title)
309340 # Use item directly as tmdb_obj for basic info
310341 set_media_infoTag(list_item, data=metadata, mode=media_type)
……@@ -321,7 +352,7 @@ class TmdbClient(BaseTmdbClient):
321352 add_next_button("handle_tmdb_search", page=page + 1, mode=mode)
322353 end_of_directory()
323354 _apply_tmdb_view("main")
324
355
325356 close_busy_dialog()
326357
327358 @staticmethod
……@@ -358,6 +389,8 @@ class TmdbClient(BaseTmdbClient):
358389 return
359390
360391 results = getattr(data, "results", [])
392 if mode != "anime":
393 results = filter_excluded_languages(results)
361394 if results:
362395 display_mode = submode if mode == "anime" and submode else mode
363396 directory_items = []
……@@ -419,6 +452,8 @@ class TmdbClient(BaseTmdbClient):
419452 return
420453
421454 results = getattr(data, "results", [])
455 if mode != "anime":
456 results = filter_excluded_languages(results)
422457 if results:
423458 display_mode = submode if mode == "anime" and submode else mode
424459 directory_items = []
……@@ -440,9 +475,7 @@ class TmdbClient(BaseTmdbClient):
440475 )
441476 directory_items.append(item_tuple)
442477 add_directory_items_batch(directory_items)
443 add_next_button(
444 "search_tmdb_year", page=page, mode=mode, submode=submode, year=year
445 )
478 add_next_button("search_tmdb_year", page=page, mode=mode, submode=submode, year=year)
446479 end_of_directory()
447480 _apply_tmdb_view(display_mode)
448481
……@@ -478,13 +511,19 @@ class TmdbClient(BaseTmdbClient):
478511 return
479512
480513 results = getattr(data, "results", [])
514 results = filter_excluded_languages(results)
481515
482516 directory_items = []
483517 for res in results:
484518 tmdb_id = getattr(res, "id", "")
485 title = getattr(res, "title", "") or getattr(res, "name", "")
486519 result_media_type = getattr(res, "media_type", "") or ""
487520 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
521 title = (
522 metadata.get("title")
523 or metadata.get("name")
524 or getattr(res, "title", "")
525 or getattr(res, "name", "")
526 )
488527 list_item = make_list_item(label=title)
489528 set_media_infoTag(list_item, data=metadata, mode=mode)
490529 item_tuple = BaseTmdbClient.add_media_directory_item(
……@@ -511,13 +550,19 @@ class TmdbClient(BaseTmdbClient):
511550 return
512551
513552 results = getattr(data, "results", [])
553 results = filter_excluded_languages(results)
514554
515555 directory_items = []
516556 for res in results:
517557 tmdb_id = getattr(res, "id", "")
518 title = getattr(res, "title", "") or getattr(res, "name", "")
519558 result_media_type = getattr(res, "media_type", "") or ""
520559 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
560 title = (
561 metadata.get("title")
562 or metadata.get("name")
563 or getattr(res, "title", "")
564 or getattr(res, "name", "")
565 )
521566 list_item = make_list_item(label=title)
522567 set_media_infoTag(list_item, data=metadata, mode=mode)
523568 item_tuple = BaseTmdbClient.add_media_directory_item(
……@@ -530,9 +575,7 @@ class TmdbClient(BaseTmdbClient):
530575 )
531576 directory_items.append(item_tuple)
532577 add_directory_items_batch(directory_items)
533 add_next_button(
534 "handle_tmdb_query", query="tmdb_trending", page=page, mode=mode
535 )
578 add_next_button("handle_tmdb_query", query="tmdb_trending", page=page, mode=mode)
536579 end_of_directory()
537580 _apply_tmdb_view("movies")
538581
……@@ -613,12 +656,18 @@ class TmdbClient(BaseTmdbClient):
613656 return
614657
615658 results = getattr(data, "results", [])
659 results = filter_excluded_languages(results)
616660 directory_items = []
617661 for res in results:
618662 tmdb_id = getattr(res, "id", "")
619 title = getattr(res, "title", "") or getattr(res, "name", "")
620663 result_media_type = getattr(res, "media_type", "") or ""
621664 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
665 title = (
666 metadata.get("title")
667 or metadata.get("name")
668 or getattr(res, "title", "")
669 or getattr(res, "name", "")
670 )
622671 list_item = make_list_item(label=title)
623672 set_media_infoTag(list_item, data=metadata, mode=mode)
624673 item_tuple = BaseTmdbClient.add_media_directory_item(
……@@ -655,6 +704,7 @@ class TmdbClient(BaseTmdbClient):
655704 return
656705
657706 results = getattr(data, "results", [])
707 results = filter_excluded_languages(results)
658708 directory_items = []
659709 for res in results:
660710 tmdb_id = getattr(res, "id", "")
……@@ -673,9 +723,7 @@ class TmdbClient(BaseTmdbClient):
673723 )
674724 directory_items.append(item_tuple)
675725 add_directory_items_batch(directory_items)
676 add_next_button(
677 "handle_tmdb_query", query="tmdb_airing_today", page=page, mode=mode
678 )
726 add_next_button("handle_tmdb_query", query="tmdb_airing_today", page=page, mode=mode)
679727 end_of_directory()
680728 _apply_tmdb_view(mode)
681729
……@@ -734,12 +782,18 @@ class TmdbClient(BaseTmdbClient):
734782 return
735783
736784 results = getattr(data, "results", [])
785 results = filter_excluded_languages(results, force_allow_lang=lang)
737786 directory_items = []
738787 for res in results:
739788 tmdb_id = getattr(res, "id", "")
740 title = getattr(res, "title", "") or getattr(res, "name", "")
741789 result_media_type = getattr(res, "media_type", "") or ""
742790 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
791 title = (
792 metadata.get("title")
793 or metadata.get("name")
794 or getattr(res, "title", "")
795 or getattr(res, "name", "")
796 )
743797 list_item = make_list_item(label=title)
744798 set_media_infoTag(list_item, data=metadata, mode=mode)
745799 item_tuple = BaseTmdbClient.add_media_directory_item(
……@@ -798,9 +852,7 @@ class TmdbClient(BaseTmdbClient):
798852 from lib.clients.tmdb.utils.utils import NETWORKS
799853
800854 network_id = int(params.get("id"))
801 network_name = next(
802 (net["name"] for net in NETWORKS if net["id"] == network_id), ""
803 )
855 network_name = next((net["name"] for net in NETWORKS if net["id"] == network_id), "")
804856 set_pluging_category(network_name)
805857
806858 mode = params.get("mode")
……@@ -830,12 +882,18 @@ class TmdbClient(BaseTmdbClient):
830882 return
831883
832884 results = getattr(data, "results", [])
885 results = filter_excluded_languages(results)
833886 directory_items = []
834887 for res in results:
835888 tmdb_id = getattr(res, "id", "")
836 title = getattr(res, "title", "") or getattr(res, "name", "")
837889 result_media_type = getattr(res, "media_type", "") or ""
838890 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
891 title = (
892 metadata.get("title")
893 or metadata.get("name")
894 or getattr(res, "title", "")
895 or getattr(res, "name", "")
896 )
839897 list_item = make_list_item(label=title)
840898 set_media_infoTag(list_item, data=metadata, mode=mode)
841899 item_tuple = BaseTmdbClient.add_media_directory_item(
……@@ -862,7 +920,7 @@ class TmdbClient(BaseTmdbClient):
862920 def show_calendar_items(query, page, mode):
863921 set_pluging_category(translation(90021))
864922 trending_data = tmdb_get("tv_week", page)
865 if not trending_data or getattr(trending_data, "total_results") == 0:
923 if not trending_data or trending_data.total_results == 0:
866924 notification(translation(90394))
867925 end_of_directory()
868926 return
……@@ -880,9 +938,9 @@ class TmdbClient(BaseTmdbClient):
880938 if air_date and is_this_week(air_date):
881939 results.append((getattr(show, "name", ""), show, ep, details))
882940
883 execute_thread_pool(
884 getattr(trending_data, "results"), fetch_episodes_for_trending_show
885 )
941 trending_results = getattr(trending_data, "results", [])
942 trending_results = filter_excluded_languages(trending_results)
943 execute_thread_pool(trending_results, fetch_episodes_for_trending_show)
886944
887945 directory_items = []
888946
……@@ -891,9 +949,7 @@ class TmdbClient(BaseTmdbClient):
891949 date_item = make_list_item(
892950 label=f"[UPPERCASE][COLOR=orange]Today: {current_date}[/COLOR][/UPPERCASE]"
893951 )
894 date_item.setArt(
895 {"icon": os.path.join(ADDON_PATH, "resources", "img", "history.png")}
896 )
952 date_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "history.png")})
897953 item = add_kodi_dir_item(date_item, "", is_folder=False, batch=True)
898954 directory_items.append(item)
899955
……@@ -902,12 +958,12 @@ class TmdbClient(BaseTmdbClient):
902958 results_today = [r for r in results if r[2].get("air_date") == today_str]
903959 results_other = [r for r in results if r[2].get("air_date") != today_str]
904960
905 results = sorted(
906 results_today, key=lambda x: x[2].get("air_date", "")
907 ) + sorted(results_other, key=lambda x: x[2].get("air_date", ""))
961 results = sorted(results_today, key=lambda x: x[2].get("air_date", "")) + sorted(
962 results_other, key=lambda x: x[2].get("air_date", "")
963 )
908964
909965 for title, show, ep, details in results:
910 tv_data = {"name": title, "episode": ep["number"], "season": ep["season"]}
966 tv_data = {"name": quote(title), "episode": ep["number"], "season": ep["season"]}
911967
912968 air_date_obj = parse_date_str(ep["air_date"])
913969 weekday_name = air_date_obj.strftime("%A")
……@@ -915,20 +971,16 @@ class TmdbClient(BaseTmdbClient):
915971
916972 # Mark if episode is released today
917973 is_today = ep["air_date"] == today_str
918 mark = (
919 f"[UPPERCASE][COLOR=orange]TODAY- [/COLOR][/UPPERCASE]"
920 if is_today
921 else ""
922 )
974 mark = "[UPPERCASE][COLOR=orange]TODAY- [/COLOR][/UPPERCASE]" if is_today else ""
923975
924976 ep_title = f"{mark}{weekday_name_translated} - ({ep['air_date']}) - {title} - S{ep['season']:02}E{ep['number']:02}"
925977
926 tmdb_id = getattr(show, "id")
978 tmdb_id = show.id
927979 if details is None:
928980 kodilog(f"Show details not found for TMDB ID: {tmdb_id}")
929981 continue
930982
931 external_ids = getattr(details, "external_ids")
983 external_ids = details.external_ids
932984 imdb_id = external_ids.get("imdb_id", "")
933985 tvdb_id = external_ids.get("tvdb_id", "")
934986
……@@ -1082,9 +1134,7 @@ class TmdbClient(BaseTmdbClient):
10821134 ) # Using movies content type ensures better poster rendering in many skins
10831135
10841136 def fetch_movies():
1085 return tmdb_get(
1086 "discover_movie", {"with_keywords": keyword_id, "page": page}
1087 )
1137 return tmdb_get("discover_movie", {"with_keywords": keyword_id, "page": page})
10881138
10891139 def fetch_tv():
10901140 return tmdb_get("discover_tv", {"with_keywords": keyword_id, "page": page})
……@@ -1102,7 +1152,7 @@ class TmdbClient(BaseTmdbClient):
11021152 if isinstance(item, dict):
11031153 item["media_type"] = "movie"
11041154 else:
1105 setattr(item, "media_type", "movie")
1155 item.media_type = "movie"
11061156 results.extend(movie_data.results)
11071157 total_pages = max(total_pages, getattr(movie_data, "total_pages", 0))
11081158
……@@ -1112,7 +1162,7 @@ class TmdbClient(BaseTmdbClient):
11121162 if isinstance(item, dict):
11131163 item["media_type"] = "tv"
11141164 else:
1115 setattr(item, "media_type", "tv")
1165 item.media_type = "tv"
11161166 results.extend(tv_data.results)
11171167 total_pages = max(total_pages, getattr(tv_data, "total_pages", 0))
11181168
……@@ -1131,13 +1181,10 @@ class TmdbClient(BaseTmdbClient):
11311181 reverse=True,
11321182 )
11331183
1184 results = filter_excluded_languages(results)
11341185 directory_items = []
11351186 for res in results:
1136 tmdb_id = (
1137 getattr(res, "id", "")
1138 if not isinstance(res, dict)
1139 else res.get("id", "")
1140 )
1187 tmdb_id = getattr(res, "id", "") if not isinstance(res, dict) else res.get("id", "")
11411188 title = (
11421189 getattr(res, "title", "") or getattr(res, "name", "")
11431190 if not isinstance(res, dict)
……@@ -1209,13 +1256,19 @@ class TmdbClient(BaseTmdbClient):
12091256 return
12101257
12111258 results = getattr(data, "results", [])
1259 results = filter_excluded_languages(results)
12121260
12131261 directory_items = []
12141262 for res in results:
12151263 tmdb_id = getattr(res, "id", "")
1216 title = getattr(res, "title", "") or getattr(res, "name", "")
12171264 result_media_type = getattr(res, "media_type", "") or ""
12181265 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
1266 title = (
1267 metadata.get("title")
1268 or metadata.get("name")
1269 or getattr(res, "title", "")
1270 or getattr(res, "name", "")
1271 )
12191272 list_item = make_list_item(label=title)
12201273 set_media_infoTag(list_item, data=metadata, mode=mode)
12211274 item_tuple = BaseTmdbClient.add_media_directory_item(
……@@ -1248,6 +1301,10 @@ class TmdbClient(BaseTmdbClient):
12481301 tmdb_id = ids.get("tmdb_id")
12491302 page = int(params.get("page", 1))
12501303
1304 if not tmdb_id:
1305 notification(translation(90400))
1306 return
1307
12511308 if not tmdb_id:
12521309 notification(translation(90400))
12531310 return
……@@ -1276,13 +1333,19 @@ class TmdbClient(BaseTmdbClient):
12761333 return
12771334
12781335 results = getattr(data, "results", [])
1336 results = filter_excluded_languages(results)
12791337
12801338 directory_items = []
12811339 for res in results:
12821340 tmdb_id = getattr(res, "id", "")
1283 title = getattr(res, "title", "") or getattr(res, "name", "")
12841341 result_media_type = getattr(res, "media_type", "") or ""
12851342 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
1343 title = (
1344 metadata.get("title")
1345 or metadata.get("name")
1346 or getattr(res, "title", "")
1347 or getattr(res, "name", "")
1348 )
12861349 list_item = make_list_item(label=title)
12871350 set_media_infoTag(list_item, data=metadata, mode=mode)
12881351 item_tuple = BaseTmdbClient.add_media_directory_item(
……@@ -1459,12 +1522,21 @@ class TmdbClient(BaseTmdbClient):
14591522
14601523 selected = xbmcgui.Dialog().select(
14611524 translation(90733),
1462 [translation(90740), translation(90741)],
1525 [
1526 translation(90740),
1527 translation(90741),
1528 translation(90773),
1529 ],
14631530 )
14641531 if selected < 0:
14651532 return
14661533
1467 if selected == 0:
1534 full_show_variant_map = {
1535 2: "title_year",
1536 }
1537 full_show_variant = full_show_variant_map.get(selected)
1538
1539 if selected == 0 or full_show_variant == "title_year":
14681540 edited_query = show_keyboard(id=90736, default=query)
14691541 if not edited_query:
14701542 return
……@@ -1482,6 +1554,37 @@ class TmdbClient(BaseTmdbClient):
14821554 if not query:
14831555 return
14841556
1557 if full_show_variant:
1558 payload = {
1559 "query": query,
1560 "mode": mode,
1561 "ids": json.dumps(ids),
1562 "rescrape": True,
1563 "force_select": True,
1564 "search_variant": full_show_variant,
1565 }
1566
1567 if full_show_variant == "title_year":
1568 year_input = xbmcgui.Dialog().numeric(0, translation(90734), "")
1569 if not year_input:
1570 return
1571
1572 year_input = str(year_input).strip()
1573 if not (year_input.isdigit() and len(year_input) == 4):
1574 notification(translation(90735))
1575 return
1576
1577 payload["year"] = year_input
1578
1579 kodilog(
1580 f"TMDB full show search mode selected: query={query}, ids={ids}, variant={full_show_variant}, year={payload.get('year', '')}"
1581 )
1582
1583 from lib.search import run_search_entry
1584
1585 run_search_entry(payload)
1586 return
1587
14851588 season_default = str(tv_data.get("season", ""))
14861589 season_input = xbmcgui.Dialog().numeric(0, translation(90512), season_default)
14871590 if not season_input:
……@@ -1492,9 +1595,7 @@ class TmdbClient(BaseTmdbClient):
14921595 return
14931596
14941597 episode_default = str(tv_data.get("episode", ""))
1495 episode_input = xbmcgui.Dialog().numeric(
1496 0, translation(90738), episode_default
1497 )
1598 episode_input = xbmcgui.Dialog().numeric(0, translation(90738), episode_default)
14981599 if not episode_input:
14991600 return
15001601 episode_input = str(episode_input).strip()
plugin.video.jacktook/lib/clients/tmdb/trailers.py +0 -1
……@@ -5,7 +5,6 @@ from lib.api.tmdbv3api.objs.movie import Movie
55from lib.api.tmdbv3api.objs.tv import TV
66from lib.utils.kodi.utils import kodilog
77
8
98YOUTUBE_WATCH_URL = "https://www.youtube.com/watch?v={}"
109
1110
plugin.video.jacktook/lib/clients/tmdb/utils/excluded_languages_data.py +40 -0
……@@ -0,0 +1,40 @@
1EXCLUDABLE_LANGUAGES = [
2 {"name": "Arabic", "id": "ar"},
3 {"name": "Bengali", "id": "bn"},
4 {"name": "Bulgarian", "id": "bg"},
5 {"name": "Chinese", "id": "zh"},
6 {"name": "Croatian", "id": "hr"},
7 {"name": "Czech", "id": "cs"},
8 {"name": "Danish", "id": "da"},
9 {"name": "Dutch", "id": "nl"},
10 {"name": "English", "id": "en"},
11 {"name": "Finnish", "id": "fi"},
12 {"name": "French", "id": "fr"},
13 {"name": "German", "id": "de"},
14 {"name": "Greek", "id": "el"},
15 {"name": "Hebrew", "id": "he"},
16 {"name": "Hindi", "id": "hi"},
17 {"name": "Hungarian", "id": "hu"},
18 {"name": "Indonesian", "id": "id"},
19 {"name": "Italian", "id": "it"},
20 {"name": "Japanese", "id": "ja"},
21 {"name": "Kannada", "id": "kn"},
22 {"name": "Korean", "id": "ko"},
23 {"name": "Malayalam", "id": "ml"},
24 {"name": "Norwegian", "id": "no"},
25 {"name": "Persian", "id": "fa"},
26 {"name": "Polish", "id": "pl"},
27 {"name": "Portuguese", "id": "pt"},
28 {"name": "Punjabi", "id": "pa"},
29 {"name": "Romanian", "id": "ro"},
30 {"name": "Russian", "id": "ru"},
31 {"name": "Serbian", "id": "sr"},
32 {"name": "Spanish", "id": "es"},
33 {"name": "Swedish", "id": "sv"},
34 {"name": "Tamil", "id": "ta"},
35 {"name": "Telugu", "id": "te"},
36 {"name": "Thai", "id": "th"},
37 {"name": "Turkish", "id": "tr"},
38 {"name": "Ukrainian", "id": "uk"},
39 {"name": "Vietnamese", "id": "vi"},
40]
plugin.video.jacktook/lib/clients/tmdb/utils/utils.py +122 -61
……@@ -1,13 +1,18 @@
11import json
2from datetime import datetime, timedelta
32import os
43import threading
4from datetime import datetime, timedelta
55from typing import List, Optional
6from urllib import parse
7
8import xbmc
9from xbmcplugin import addDirectoryItem
610
711from lib.api.mdblist.mdblist import MDblistAPI
812from lib.api.tmdbv3api.as_obj import AsObj
913from lib.api.tmdbv3api.objs.anime import TmdbAnime
1014from lib.api.tmdbv3api.objs.collection import Collection
15from lib.api.tmdbv3api.objs.discover import Discover
1116from lib.api.tmdbv3api.objs.episode import Episode
1217from lib.api.tmdbv3api.objs.find import Find
1318from lib.api.tmdbv3api.objs.genre import Genre
……@@ -15,27 +20,22 @@ from lib.api.tmdbv3api.objs.movie import Movie
1520from lib.api.tmdbv3api.objs.person import Person
1621from lib.api.tmdbv3api.objs.search import Search
1722from lib.api.tmdbv3api.objs.season import Season
18from lib.api.tmdbv3api.objs.discover import Discover
1923from lib.api.tmdbv3api.objs.trending import Trending
2024from lib.api.tmdbv3api.objs.tv import TV
21
25from lib.api.tmdbv3api.tmdb import TMDb
26from lib.db.cached import cache
27from lib.utils.general.utils import execute_thread_pool
28from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled
2229from lib.utils.kodi.utils import (
2330 ADDON_HANDLE,
2431 ADDON_PATH,
2532 action_url_run,
2633 container_update,
27 execute_builtin,
28 kodilog,
34 get_setting,
2935 kodi_play_media,
36 kodilog,
3037 translation,
3138)
32from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled
33from lib.utils.general.utils import execute_thread_pool
34from urllib import parse
35
36from lib.db.cached import cache
37from xbmcplugin import addDirectoryItem
38
3939
4040LANGUAGES = [
4141 "ar-AE",
……@@ -262,7 +262,7 @@ def add_icon_tmdb(item, icon_path="tmdb.png"):
262262
263263
264264def tmdb_get(path, params=None) -> Optional[AsObj]:
265 identifier = f"{path}|{params}"
265 identifier = f"{path}|{params}|{TMDb().language}"
266266 data = cache.get(key=identifier)
267267 if data:
268268 return data
……@@ -277,14 +277,10 @@ def tmdb_get(path, params=None) -> Optional[AsObj]:
277277 "search_multi": lambda p: Search().multi(p["query"], page=p["page"]),
278278 "search_collections": lambda p: Search().collections(p["query"], p["page"]),
279279 "search_people": lambda p: Search().people(p["query"], p["page"]),
280 "movie_details": lambda p: Movie().details(
281 p["id"] if isinstance(p, dict) else p
282 ),
280 "movie_details": lambda p: Movie().details(p["id"] if isinstance(p, dict) else p),
283281 "tv_details": lambda p: TV().details(p["id"] if isinstance(p, dict) else p),
284282 "season_details": lambda p: Season().details(p["id"], p["season"]),
285 "episode_details": lambda p: Episode().details(
286 p["id"], p["season"], p["episode"]
287 ),
283 "episode_details": lambda p: Episode().details(p["id"], p["season"], p["episode"]),
288284 "movie_genres": lambda _: Genre().movie_list(),
289285 "show_genres": lambda _: Genre().tv_list(),
290286 "discover_movie": lambda p: Discover().discover_movies(p),
……@@ -299,42 +295,24 @@ def tmdb_get(path, params=None) -> Optional[AsObj]:
299295 "popular_people": lambda p: Person().popular(page=p),
300296 "trending_people": lambda p: Trending().person_week(page=p),
301297 "latest_people": lambda p: Person().latest(),
302 "person_details": lambda p: Person().details(
303 p["id"] if isinstance(p, dict) else p
304 ),
298 "person_details": lambda p: Person().details(p["id"] if isinstance(p, dict) else p),
305299 "person_credits": lambda p: Person().combined_credits(
306300 p["id"] if isinstance(p, dict) else p
307301 ),
308 "person_tv_credits": lambda p: Person().tv_credits(
309 p["id"] if isinstance(p, dict) else p
310 ),
302 "person_tv_credits": lambda p: Person().tv_credits(p["id"] if isinstance(p, dict) else p),
311303 "person_movie_credits": lambda p: Person().movie_credits(
312304 p["id"] if isinstance(p, dict) else p
313305 ),
314306 "tv_credits": lambda p: TV().credits(p["id"] if isinstance(p, dict) else p),
315 "movie_credits": lambda p: Movie().credits(
316 p["id"] if isinstance(p, dict) else p
317 ),
318 "person_ids": lambda p: Person().external_ids(
319 p["id"] if isinstance(p, dict) else p
320 ),
321 "find_by_tvdb": lambda p: Find().find_by_tvdb_id(
322 p["id"] if isinstance(p, dict) else p
323 ),
324 "find_by_imdb_id": lambda p: Find().find_by_imdb_id(
325 p["id"] if isinstance(p, dict) else p
326 ),
307 "movie_credits": lambda p: Movie().credits(p["id"] if isinstance(p, dict) else p),
308 "person_ids": lambda p: Person().external_ids(p["id"] if isinstance(p, dict) else p),
309 "find_by_tvdb": lambda p: Find().find_by_tvdb_id(p["id"] if isinstance(p, dict) else p),
310 "find_by_imdb_id": lambda p: Find().find_by_imdb_id(p["id"] if isinstance(p, dict) else p),
327311 "anime_year": lambda p: TmdbAnime().anime_year(p),
328312 "anime_genres": lambda p: TmdbAnime().anime_genres(p),
329 "collection_details": lambda p: Collection().details(
330 p["id"] if isinstance(p, dict) else p
331 ),
332 "collection_images": lambda p: Collection().images(
333 p["id"] if isinstance(p, dict) else p
334 ),
335 "tv_recommendations": lambda p: TV().recommendations(
336 tv_id=p["id"], page=p["page"]
337 ),
313 "collection_details": lambda p: Collection().details(p["id"] if isinstance(p, dict) else p),
314 "collection_images": lambda p: Collection().images(p["id"] if isinstance(p, dict) else p),
315 "tv_recommendations": lambda p: TV().recommendations(tv_id=p["id"], page=p["page"]),
338316 "movie_recommendations": lambda p: Movie().recommendations(
339317 movie_id=p["id"], page=p["page"]
340318 ),
……@@ -344,6 +322,7 @@ def tmdb_get(path, params=None) -> Optional[AsObj]:
344322 "tv_images": lambda p: TV().images(tv_id=p["id"]),
345323 "movie_reviews": lambda p: Movie().reviews(movie_id=p["id"], page=p["page"]),
346324 "tv_reviews": lambda p: TV().reviews(tv_id=p["id"], page=p["page"]),
325 "movie_keywords": lambda p: Movie().keywords(p["id"] if isinstance(p, dict) else p),
347326 }
348327
349328 try:
……@@ -356,9 +335,7 @@ def tmdb_get(path, params=None) -> Optional[AsObj]:
356335 cache.set(
357336 key=identifier,
358337 data=data,
359 expires=timedelta(
360 hours=get_cache_expiration() if is_cache_enabled() else 0
361 ),
338 expires=timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0),
362339 )
363340
364341 return data
……@@ -393,9 +370,7 @@ def mdblist_get(path, params=None) -> Optional[List]:
393370 cache.set(
394371 key=identifier,
395372 data=data,
396 expires=timedelta(
397 hours=get_cache_expiration() if is_cache_enabled() else 0
398 ),
373 expires=timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0),
399374 )
400375
401376 return data
……@@ -416,6 +391,24 @@ def get_tmdb_show_details(id: str) -> Optional[AsObj]:
416391 return tmdb_get("tv_details", id)
417392
418393
394def get_movie_keywords(tmdb_id):
395 """Fetch TMDB keywords for a movie. Returns list of keyword names."""
396 try:
397 res = tmdb_get("movie_keywords", tmdb_id)
398 keywords = []
399 for k in getattr(res, "keywords", []):
400 # Support both AsObj (runtime) and dict (tests)
401 name = getattr(k, "name", None)
402 if not name and isinstance(k, dict):
403 name = k.get("name")
404 if name:
405 keywords.append(name)
406 return keywords
407 except Exception as e:
408 kodilog(f"Error fetching movie keywords for {tmdb_id}: {e}")
409 return []
410
411
419412def filter_anime_by_keyword(results, mode):
420413 filtered_anime = []
421414 anime_api = TmdbAnime()
……@@ -423,9 +416,7 @@ def filter_anime_by_keyword(results, mode):
423416
424417 def check_and_append(item, filtered_anime, list_lock):
425418 keywords_data = anime_api.tmdb_keywords(mode, item["id"])
426 keywords = (
427 keywords_data["results"] if mode == "tv" else keywords_data["keywords"]
428 )
419 keywords = keywords_data["results"] if mode == "tv" else keywords_data["keywords"]
429420 for keyword in keywords:
430421 if keyword["id"] == 210024:
431422 with list_lock:
……@@ -447,9 +438,7 @@ def _build_trailer_ids(ids, title=None, title_key="title"):
447438
448439def build_play_trailer_context_menu_item(ids, media_type, title=None, title_key="title"):
449440 trailer_ids = _build_trailer_ids(ids=ids, title=title, title_key=title_key)
450 if not any(
451 trailer_ids.get(key) for key in ("trailer_yt_id", "trailer_url", "tmdb_id")
452 ):
441 if not any(trailer_ids.get(key) for key in ("trailer_yt_id", "trailer_url", "tmdb_id")):
453442 return None
454443
455444 params = {
……@@ -469,7 +458,9 @@ def build_play_trailer_context_menu_item(ids, media_type, title=None, title_key=
469458 )
470459
471460
472def add_tmdb_movie_context_menu(mode, media_type, title=None, ids={}):
461def add_tmdb_movie_context_menu(mode, media_type, title=None, ids=None):
462 if ids is None:
463 ids = {}
473464 context_menu = [
474465 (
475466 translation(90049),
……@@ -493,6 +484,10 @@ def add_tmdb_movie_context_menu(mode, media_type, title=None, ids={}):
493484 force_select=True,
494485 ),
495486 ),
487 (
488 translation(90755),
489 "RunPlugin(plugin://plugin.video.jacktook/?action=source_manager_toggle)",
490 ),
496491 (
497492 "Extras",
498493 f"RunPlugin(plugin://plugin.video.jacktook/?action=extras&id={ids.get('tmdb_id')}&imdb_id={ids.get('imdb_id', '')}&media_type={media_type}&title={parse.quote(title or '')})",
……@@ -557,6 +552,20 @@ def add_tmdb_movie_context_menu(mode, media_type, title=None, ids={}):
557552 ),
558553 ),
559554 ),
555 (
556 translation(90205),
557 kodi_play_media(
558 name="add_to_library",
559 data=json.dumps(
560 {
561 "title": ids.get("name") or ids.get("title"),
562 "ids": ids,
563 "mode": mode,
564 "timestamp": datetime.now().strftime("%a, %d %b %Y %I:%M %p"),
565 }
566 ),
567 ),
568 ),
560569 (
561570 translation(90116),
562571 container_update(name="settings"),
……@@ -565,7 +574,9 @@ def add_tmdb_movie_context_menu(mode, media_type, title=None, ids={}):
565574 return context_menu
566575
567576
568def add_tmdb_show_context_menu(mode, ids={}, title=None):
577def add_tmdb_show_context_menu(mode, ids=None, title=None):
578 if ids is None:
579 ids = {}
569580 context_menu = [
570581 (
571582 "Extras",
……@@ -619,6 +630,10 @@ def add_tmdb_show_context_menu(mode, ids={}, title=None):
619630 ),
620631 ),
621632 ),
633 (
634 translation(90755),
635 "RunPlugin(plugin://plugin.video.jacktook/?action=source_manager_toggle)",
636 ),
622637 (
623638 translation(90116),
624639 container_update(name="settings"),
……@@ -627,7 +642,9 @@ def add_tmdb_show_context_menu(mode, ids={}, title=None):
627642 return context_menu
628643
629644
630def add_tmdb_episode_context_menu(mode, tv_name=None, tv_data=None, ids={}):
645def add_tmdb_episode_context_menu(mode, tv_name=None, tv_data=None, ids=None):
646 if ids is None:
647 ids = {}
631648 return [
632649 (
633650 translation(90049),
……@@ -651,6 +668,10 @@ def add_tmdb_episode_context_menu(mode, tv_name=None, tv_data=None, ids={}):
651668 force_select=True,
652669 ),
653670 ),
671 (
672 translation(90755),
673 "RunPlugin(plugin://plugin.video.jacktook/?action=source_manager_toggle)",
674 ),
654675 (
655676 translation(90733),
656677 action_url_run(
……@@ -691,3 +712,43 @@ def add_tmdb_episode_context_menu(mode, tv_name=None, tv_data=None, ids={}):
691712 container_update(name="settings"),
692713 ),
693714 ]
715
716
717def get_excluded_languages():
718 raw = get_setting("excluded_languages", "[]")
719 try:
720 return json.loads(raw) if raw else []
721 except (ValueError, TypeError):
722 return []
723
724
725def filter_excluded_languages(results, force_allow_lang=None):
726 excluded = get_excluded_languages()
727 if not excluded:
728 return results
729
730 input_count = len(results)
731 filtered = []
732 excluded_counts = {}
733
734 for r in results:
735 if isinstance(r, dict):
736 lang = r.get("original_language")
737 else:
738 lang = getattr(r, "original_language", None)
739
740 if lang == force_allow_lang or lang not in excluded:
741 filtered.append(r)
742 else:
743 excluded_counts[lang] = excluded_counts.get(lang, 0) + 1
744
745 output_count = len(filtered)
746 if input_count != output_count:
747 kodilog(
748 f"Excluded language filter: {input_count} -> {output_count} "
749 f"(excluded={excluded}, force_allow={force_allow_lang!r}, "
750 f"removed_by_lang={excluded_counts})",
751 level=xbmc.LOGINFO,
752 )
753
754 return filtered
plugin.video.jacktook/lib/clients/torrentio.py +6 -6
……@@ -1,10 +1,10 @@
11import re
2from typing import List, Dict, Tuple, Optional, Any, Callable
2from typing import Any, Callable, Dict, List, Optional, Tuple
33
44from lib.clients.base import BaseClient, TorrentStream
5from lib.utils.localization.countries import find_language_by_unicode
6from lib.utils.kodi.utils import convert_size_to_bytes, kodilog, translation
75from lib.utils.general.utils import USER_AGENT_HEADER, unicode_flag_to_country_code
6from lib.utils.kodi.utils import convert_size_to_bytes, translation
7from lib.utils.localization.countries import find_language_by_unicode
88
99
1010class Torrentio(BaseClient):
……@@ -27,13 +27,13 @@ class Torrentio(BaseClient):
2727 else:
2828 self.handle_exception(translation(30228))
2929 return None
30
30
3131 res = self.session.get(url, headers=USER_AGENT_HEADER, timeout=10)
3232 if res.status_code != 200:
3333 return
3434 return self.parse_response(res)
3535 except Exception as e:
36 self.handle_exception(f"{translation(30228)}: {str(e)}")
36 self.handle_exception(f"{translation(30228)}: {e!s}")
3737
3838 def parse_response(self, res: Any) -> List[TorrentStream]:
3939 res = res.json()
……@@ -90,7 +90,7 @@ class Torrentio(BaseClient):
9090 for flag in flag_emojis:
9191 languages.append(unicode_flag_to_country_code(flag).upper())
9292 full_lang = find_language_by_unicode(flag)
93 if (full_lang != None) and (full_lang not in full_languages):
93 if (full_lang is not None) and (full_lang not in full_languages):
9494 full_languages.append(full_lang)
9595 return languages, full_languages
9696
plugin.video.jacktook/lib/clients/trakt/paginator.py +16 -10
……@@ -1,4 +1,5 @@
11import json
2
23from lib.api.trakt.base_cache import connect_database
34
45
……@@ -14,34 +15,40 @@ class Paginator:
1415 def _store_page(self, page_number, total_pages, data):
1516 conn = self._connect()
1617 cursor = conn.cursor()
17 cursor.execute('''
18 cursor.execute(
19 """
1820 INSERT OR REPLACE INTO paginated_data (id, page_number, data, total_pages)
1921 VALUES (?, ?, ?, ?)
20 ''', (f'page_{page_number}', page_number, json.dumps(data), total_pages))
22 """,
23 (f"page_{page_number}", page_number, json.dumps(data), total_pages),
24 )
2125 conn.commit()
2226 conn.close()
2327
2428 def _retrieve_page(self, page_number):
2529 conn = self._connect()
2630 cursor = conn.cursor()
27 cursor.execute('''
31 cursor.execute(
32 """
2833 SELECT data FROM paginated_data WHERE page_number = ?
29 ''', (page_number,))
34 """,
35 (page_number,),
36 )
3037 result = cursor.fetchone()
3138 conn.close()
3239 if result and result[0] is not None:
3340 return json.loads(result[0])
3441 return None
35
42
3643 def _clear_table(self):
3744 conn = self._connect()
3845 cursor = conn.cursor()
39 cursor.execute('DELETE FROM paginated_data')
46 cursor.execute("DELETE FROM paginated_data")
4047 conn.commit()
4148 conn.close()
42
49
4350 def initialize(self, data):
44 self._clear_table()
51 self._clear_table()
4552 self.total_pages = (len(data) + self.page_size - 1) // self.page_size
4653 for i in range(self.total_pages):
4754 start = i * self.page_size
……@@ -53,7 +60,7 @@ class Paginator:
5360 self.current_page = page_number - 1
5461 data = self._retrieve_page(page_number)
5562 if data is None:
56 raise IndexError("Page number not found in database: Requested page {}".format(page_number))
63 raise IndexError(f"Page number not found in database: Requested page {page_number}")
5764 return data
5865
5966 def next_page(self):
……@@ -73,5 +80,4 @@ class Paginator:
7380 return self.get_page(self.current_page + 1)
7481
7582
76
7783paginator_db = Paginator()
plugin.video.jacktook/lib/clients/trakt/trakt.py +76 -102
……@@ -1,16 +1,18 @@
1import contextlib
12import json
23from datetime import datetime, timedelta
3from lib.clients.tmdb.utils.utils import build_play_trailer_context_menu_item, tmdb_get
4
45from lib.api.trakt.trakt import TraktAPI, TraktLists, TraktMovies, TraktTV
56from lib.api.trakt.trakt_utils import (
7 add_trakt_collection_context_menu,
68 add_trakt_custom_list_context_menu,
79 add_trakt_favorites_context_menu,
810 add_trakt_watched_context_menu,
911 add_trakt_watchlist_context_menu,
1012 is_trakt_auth,
1113)
14from lib.clients.tmdb.utils.utils import build_play_trailer_context_menu_item, tmdb_get
1215from lib.clients.trakt.utils import add_kodi_dir_item, extract_ids
13from lib.api.trakt.trakt_utils import add_trakt_collection_context_menu
1416from lib.utils.general.utils import (
1517 Anime,
1618 Enum,
……@@ -22,7 +24,6 @@ from lib.utils.general.utils import (
2224)
2325from lib.utils.kodi.utils import (
2426 action_url_run,
25 add_directory_items_batch,
2627 build_url,
2728 container_update,
2829 dialog_select,
……@@ -32,14 +33,15 @@ from lib.utils.kodi.utils import (
3233 execute_builtin,
3334 get_datetime,
3435 get_setting,
36 kodi_play_media,
3537 kodilog,
3638 make_list_item,
3739 notification,
38 kodi_play_media,
3940 refresh,
4041 show_keyboard,
4142 translation,
4243)
44
4345from .paginator import paginator_db
4446
4547
……@@ -137,9 +139,7 @@ class BaseTraktClient:
137139 )
138140
139141 @staticmethod
140 def _add_media_directory_item(
141 list_item, mode, title, ids, media_type=None, context_flags=None
142 ):
142 def _add_media_directory_item(list_item, mode, title, ids, media_type=None, context_flags=None):
143143 trailer_item = build_play_trailer_context_menu_item(
144144 ids=ids,
145145 media_type="movie" if mode == "movies" else "tv",
……@@ -162,12 +162,10 @@ class BaseTraktClient:
162162 if trailer_item:
163163 context_menu.append(trailer_item)
164164 context_menu += (
165 BaseTraktClient._trakt_context_menu(
166 "movies", ids, context_flags=context_flags
167 )
168 if is_trakt_auth()
169 else []
170 )
165 BaseTraktClient._trakt_context_menu("movies", ids, context_flags=context_flags)
166 if is_trakt_auth()
167 else []
168 )
171169 list_item.addContextMenuItems(context_menu)
172170 add_kodi_dir_item(
173171 list_item=list_item,
……@@ -243,9 +241,8 @@ class TraktClient:
243241 def _normalize_trakt_media_type(media_type, ids):
244242 if media_type in ("tv", "show", "shows", "tvshow", "tvshows", "anime"):
245243 return "shows"
246 if media_type in ("movie", "movies"):
247 if ids.get("tvdb") or ids.get("tvdb_id"):
248 return "shows"
244 if media_type in ("movie", "movies") and (ids.get("tvdb") or ids.get("tvdb_id")):
245 return "shows"
249246 if ids.get("tvdb") or ids.get("tvdb_id"):
250247 return "shows"
251248
……@@ -318,10 +315,8 @@ class TraktClient:
318315 if first_air_date is None and isinstance(show_details, dict):
319316 first_air_date = show_details.get("first_air_date")
320317 if first_air_date:
321 try:
318 with contextlib.suppress(TypeError, ValueError):
322319 show_item["year"] = int(str(first_air_date)[:4])
323 except (TypeError, ValueError):
324 pass
325320
326321 return {"shows": [show_item]}
327322
……@@ -340,9 +335,7 @@ class TraktClient:
340335 seasons_payload = []
341336
342337 for season_number in range(1, int(total_seasons) + 1):
343 season_details = tmdb_get(
344 "season_details", {"id": tmdb_id, "season": season_number}
345 )
338 season_details = tmdb_get("season_details", {"id": tmdb_id, "season": season_number})
346339 episodes = getattr(season_details, "episodes", None)
347340 if episodes is None and isinstance(season_details, dict):
348341 episodes = season_details.get("episodes", [])
……@@ -369,9 +362,7 @@ class TraktClient:
369362 episode_entries.append({"number": int(episode_number)})
370363
371364 if episode_entries:
372 seasons_payload.append(
373 {"number": int(season_number), "episodes": episode_entries}
374 )
365 seasons_payload.append({"number": int(season_number), "episodes": episode_entries})
375366
376367 if not seasons_payload:
377368 return None
……@@ -414,7 +405,11 @@ class TraktClient:
414405 "shows": [
415406 {
416407 "ids": show_item.get("ids", {}),
417 "seasons": [{"number": season.get("number")} for season in seasons if season.get("number")],
408 "seasons": [
409 {"number": season.get("number")}
410 for season in seasons
411 if season.get("number")
412 ],
418413 }
419414 ]
420415 }
……@@ -444,15 +439,9 @@ class TraktClient:
444439 set_content_type(mode)
445440 params = params or {}
446441 handlers = {
447 "movies": lambda q, m, p: TraktClient.handle_trakt_movie_query(
448 q, m, p, params
449 ),
450 "tv": lambda q, m, p: TraktClient.handle_trakt_show_query(
451 q, m, p, params
452 ),
453 "anime": lambda q, m, p: TraktClient.handle_trakt_anime_query(
454 category, p, submode
455 ),
442 "movies": lambda q, m, p: TraktClient.handle_trakt_movie_query(q, m, p, params),
443 "tv": lambda q, m, p: TraktClient.handle_trakt_show_query(q, m, p, params),
444 "anime": lambda q, m, p: TraktClient.handle_trakt_anime_query(category, p, submode),
456445 }
457446 handler = handlers.get(mode)
458447 if handler:
……@@ -465,9 +454,7 @@ class TraktClient:
465454 Trakt.TRENDING: lambda: TraktMovies().trakt_movies_trending(page),
466455 Trakt.TOP10: lambda: TraktMovies().trakt_movies_top10_boxoffice(),
467456 Trakt.WATCHED: lambda: TraktMovies().trakt_movies_most_watched(page),
468 Trakt.WATCHED_HISTORY: lambda: TraktLists().trakt_watched_history(
469 mode, page
470 ),
457 Trakt.WATCHED_HISTORY: lambda: TraktLists().trakt_watched_history(mode, page),
471458 Trakt.FAVORITED: lambda: TraktMovies().trakt_movies_most_favorited(page),
472459 Trakt.RECOMENDATIONS: lambda: TraktMovies().trakt_recommendations("movies"),
473460 Trakt.FAVORITES: lambda: TraktLists().trakt_favorites(mode),
……@@ -500,9 +487,7 @@ class TraktClient:
500487 query_handlers = {
501488 Trakt.TRENDING: lambda: TraktTV().trakt_tv_trending(page),
502489 Trakt.WATCHED: lambda: TraktTV().trakt_tv_most_watched(page),
503 Trakt.WATCHED_HISTORY: lambda: TraktLists().trakt_watched_history(
504 mode, page
505 ),
490 Trakt.WATCHED_HISTORY: lambda: TraktLists().trakt_watched_history(mode, page),
506491 Trakt.FAVORITED: lambda: TraktTV().trakt_tv_most_favorited(page),
507492 Trakt.RECOMENDATIONS: lambda: TraktTV().trakt_recommendations("shows"),
508493 Trakt.FAVORITES: lambda: TraktLists().trakt_favorites(mode),
……@@ -563,9 +548,7 @@ class TraktClient:
563548 @staticmethod
564549 def trakt_add_to_watchlist(params):
565550 ids = json.loads(params.get("ids", "{}"))
566 media_type = TraktClient._normalize_trakt_media_type(
567 params.get("media_type"), ids
568 )
551 media_type = TraktClient._normalize_trakt_media_type(params.get("media_type"), ids)
569552 try:
570553 TraktAPI().lists.add_to_watchlist(media_type, ids)
571554 notification(translation(90434), time=3000)
……@@ -577,9 +560,7 @@ class TraktClient:
577560 @staticmethod
578561 def trakt_remove_from_watchlist(params):
579562 ids = json.loads(params.get("ids", "{}"))
580 media_type = TraktClient._normalize_trakt_media_type(
581 params.get("media_type"), ids
582 )
563 media_type = TraktClient._normalize_trakt_media_type(params.get("media_type"), ids)
583564 try:
584565 TraktAPI().lists.remove_from_watchlist(media_type, ids)
585566 notification(translation(90436), time=3000)
……@@ -616,9 +597,7 @@ class TraktClient:
616597 @staticmethod
617598 def trakt_add_to_collection(params):
618599 ids = json.loads(params.get("ids", "{}"))
619 media_type = TraktClient._normalize_trakt_media_type(
620 params.get("media_type"), ids
621 )
600 media_type = TraktClient._normalize_trakt_media_type(params.get("media_type"), ids)
622601 try:
623602 payload = None
624603 if media_type not in ("movie", "movies"):
……@@ -627,11 +606,17 @@ class TraktClient:
627606 notification(translation(90465), time=3500)
628607 return
629608 result = TraktAPI().lists.add_to_collection(media_type, ids, payload=payload)
630 if media_type not in ("movie", "movies") and not TraktClient._result_has_collection_change(result, "add"):
609 if media_type not in (
610 "movie",
611 "movies",
612 ) and not TraktClient._result_has_collection_change(result, "add"):
631613 payload = TraktClient._build_show_collection_season_payload(ids)
632614 if payload is not None:
633615 result = TraktAPI().lists.add_to_collection(media_type, ids, payload=payload)
634 if media_type not in ("movie", "movies") and not TraktClient._result_has_collection_change(result, "add"):
616 if media_type not in (
617 "movie",
618 "movies",
619 ) and not TraktClient._result_has_collection_change(result, "add"):
635620 payload = TraktClient._build_show_collection_episode_payload(ids)
636621 if payload is not None:
637622 result = TraktAPI().lists.add_to_collection(media_type, ids, payload=payload)
……@@ -652,9 +637,7 @@ class TraktClient:
652637 @staticmethod
653638 def trakt_remove_from_collection(params):
654639 ids = json.loads(params.get("ids", "{}"))
655 media_type = TraktClient._normalize_trakt_media_type(
656 params.get("media_type"), ids
657 )
640 media_type = TraktClient._normalize_trakt_media_type(params.get("media_type"), ids)
658641 try:
659642 payload = None
660643 if media_type not in ("movie", "movies"):
……@@ -665,14 +648,24 @@ class TraktClient:
665648 notification(translation(90465), time=3500)
666649 return
667650 result = TraktAPI().lists.remove_from_collection(media_type, ids, payload=payload)
668 if media_type not in ("movie", "movies") and not TraktClient._result_has_collection_change(result, "remove"):
651 if media_type not in (
652 "movie",
653 "movies",
654 ) and not TraktClient._result_has_collection_change(result, "remove"):
669655 payload = TraktClient._build_show_collection_season_payload(ids)
670656 if payload is not None:
671 result = TraktAPI().lists.remove_from_collection(media_type, ids, payload=payload)
672 if media_type not in ("movie", "movies") and not TraktClient._result_has_collection_change(result, "remove"):
657 result = TraktAPI().lists.remove_from_collection(
658 media_type, ids, payload=payload
659 )
660 if media_type not in (
661 "movie",
662 "movies",
663 ) and not TraktClient._result_has_collection_change(result, "remove"):
673664 payload = TraktClient._build_show_collection_episode_payload(ids)
674665 if payload is not None:
675 result = TraktAPI().lists.remove_from_collection(media_type, ids, payload=payload)
666 result = TraktAPI().lists.remove_from_collection(
667 media_type, ids, payload=payload
668 )
676669 removed_count = TraktClient._trakt_sync_result_count(result, "deleted", media_type)
677670 not_found_count = TraktClient._trakt_sync_result_count(result, "not_found", media_type)
678671 if removed_count > 0:
……@@ -689,9 +682,7 @@ class TraktClient:
689682 @staticmethod
690683 def trakt_add_to_favorites(params):
691684 ids = json.loads(params.get("ids", "{}"))
692 media_type = TraktClient._normalize_trakt_media_type(
693 params.get("media_type"), ids
694 )
685 media_type = TraktClient._normalize_trakt_media_type(params.get("media_type"), ids)
695686 try:
696687 TraktAPI().lists.add_to_favorites(media_type, ids)
697688 notification(translation(90445), time=3000)
……@@ -703,9 +694,7 @@ class TraktClient:
703694 @staticmethod
704695 def trakt_remove_from_favorites(params):
705696 ids = json.loads(params.get("ids", "{}"))
706 media_type = TraktClient._normalize_trakt_media_type(
707 params.get("media_type"), ids
708 )
697 media_type = TraktClient._normalize_trakt_media_type(params.get("media_type"), ids)
709698 try:
710699 TraktAPI().lists.remove_from_favorites(media_type, ids)
711700 notification(translation(90446), time=3000)
……@@ -793,9 +782,7 @@ class TraktClient:
793782 if not selected_list:
794783 return
795784 try:
796 TraktAPI().lists.add_item_to_list(
797 selected_list.get("trakt_id"), media_type, ids
798 )
785 TraktAPI().lists.add_item_to_list(selected_list.get("trakt_id"), media_type, ids)
799786 notification(translation(90457), time=3000)
800787 except Exception as e:
801788 kodilog(f"Error adding item to Trakt list: {e}")
……@@ -809,9 +796,7 @@ class TraktClient:
809796 if not selected_list:
810797 return
811798 try:
812 TraktAPI().lists.remove_item_from_list(
813 selected_list.get("trakt_id"), media_type, ids
814 )
799 TraktAPI().lists.remove_item_from_list(selected_list.get("trakt_id"), media_type, ids)
815800 notification(translation(90536), time=3000)
816801 except Exception as e:
817802 kodilog(f"Error removing item from Trakt list: {e}")
……@@ -839,16 +824,12 @@ class TraktClient:
839824 kodilog(f"Error fetching Trakt user profile: {e}")
840825
841826 if get_setting("trakt_calendar_show_all"):
842 return TraktAPI().calendar.trakt_all_shows_calendar(
843 start_date=start_date, days=days
844 )
827 return TraktAPI().calendar.trakt_all_shows_calendar(start_date=start_date, days=days)
845828
846829 return TraktAPI().calendar.trakt_my_calendar(start_date=start_date, days=days)
847830
848831 @staticmethod
849 def process_trakt_result(
850 results, query, category, mode, submode, api, page, search_term=""
851 ):
832 def process_trakt_result(results, query, category, mode, submode, api, page, search_term=""):
852833 if query == Trakt.ACCOUNT_INFO:
853834 TraktPresentation.show_account_info(results)
854835 end_of_directory(cache=False)
……@@ -1011,9 +992,7 @@ class TraktClient:
1011992
1012993 @staticmethod
1013994 def show_trakt_list_content(list_type, mode, user, slug, with_auth, page, trakt_id=None):
1014 data = TraktAPI().lists.get_trakt_list_contents(
1015 list_type, user, slug, with_auth, trakt_id
1016 )
995 data = TraktAPI().lists.get_trakt_list_contents(list_type, user, slug, with_auth, trakt_id)
1017996 if not data:
1018997 notification("No results found", time=3000)
1019998 end_of_directory(cache=not bool(with_auth))
……@@ -1088,15 +1067,11 @@ class TraktPresentation:
10881067 lines = [str(translation(30916)), ""]
10891068 lines.append(f"Username: {user.get('username', '')}")
10901069 lines.append(f"Name: {user.get('name', '')}")
1091 lines.append(
1092 f"Joined: {TraktPresentation._format_iso_date(user.get('joined_at', ''))}"
1093 )
1070 lines.append(f"Joined: {TraktPresentation._format_iso_date(user.get('joined_at', ''))}")
10941071 lines.append(
10951072 f"Private: {TraktPresentation._format_bool_label(account.get('private', False))}"
10961073 )
1097 lines.append(
1098 f"VIP: {TraktPresentation._format_bool_label(user.get('vip', False))}"
1099 )
1074 lines.append(f"VIP: {TraktPresentation._format_bool_label(user.get('vip', False))}")
11001075 lines.append(f"Timezone: {account.get('timezone', '')}")
11011076 lines.append(f"Locale: {account.get('locale', '')}")
11021077 lines.append("")
……@@ -1112,9 +1087,7 @@ class TraktPresentation:
11121087 lines.append("Connections")
11131088 for key in ("facebook", "google", "twitter", "mastodon"):
11141089 connection = connections.get(key, False)
1115 lines.append(
1116 f"{key.title()}: {TraktPresentation._format_bool_label(bool(connection))}"
1117 )
1090 lines.append(f"{key.title()}: {TraktPresentation._format_bool_label(bool(connection))}")
11181091 return "\n".join(lines)
11191092
11201093 @staticmethod
……@@ -1257,14 +1230,8 @@ class TraktPresentation:
12571230 description = res["list"]["description"]
12581231 user_data = res["list"].get("user", {})
12591232 user_ids = user_data.get("ids", {})
1260 user_slug = _normalize_user_slug(
1261 user_ids.get("slug") or user_data.get("username")
1262 )
1233 user_slug = _normalize_user_slug(user_ids.get("slug") or user_data.get("username"))
12631234
1264 info_labels = {
1265 "title": list_title,
1266 "plot": description,
1267 }
12681235
12691236 url = build_url(
12701237 "trakt_list_content",
……@@ -1299,7 +1266,16 @@ class TraktPresentation:
12991266 )
13001267
13011268 @staticmethod
1302 def show_user_lists(res, mode, query=None, page=1, search_term="", api="trakt", category=None, submode=""):
1269 def show_user_lists(
1270 res,
1271 mode,
1272 query=None,
1273 page=1,
1274 search_term="",
1275 api="trakt",
1276 category=None,
1277 submode="",
1278 ):
13031279 list_title = res.get("name", "Untitled List")
13041280 description = res.get("description", "")
13051281 item_count = res.get("item_count")
……@@ -1505,12 +1481,10 @@ class TraktPresentation:
15051481
15061482 first_aired = res.get("first_aired") or ep_data.get("first_aired")
15071483
1508 ids = TraktPresentation._resolve_media_ids(
1509 "tv", res.get("show", {}).get("ids", {})
1510 )
1484 ids = TraktPresentation._resolve_media_ids("tv", res.get("show", {}).get("ids", {}))
15111485 tmdb_id = ids["tmdb_id"]
1512 imdb_id = ids["imdb_id"]
1513 tvdb_id = ids["tvdb_id"]
1486 ids["imdb_id"]
1487 ids["tvdb_id"]
15141488
15151489 if not tmdb_id:
15161490 return
plugin.video.jacktook/lib/clients/trakt/utils.py +2 -2
……@@ -1,7 +1,7 @@
1from lib.utils.kodi.utils import ADDON_HANDLE
2
31from xbmcplugin import addDirectoryItem
42
3from lib.utils.kodi.utils import ADDON_HANDLE
4
55
66def add_kodi_dir_item(
77 list_item,
plugin.video.jacktook/lib/clients/webdav/client.py +10 -8
……@@ -1,4 +1,12 @@
11import requests
2from xbmcgui import Dialog
3from xbmcplugin import (
4 SORT_METHOD_FILE,
5 SORT_METHOD_LABEL_IGNORE_THE,
6 addDirectoryItem,
7 addSortMethod,
8)
9
210from lib.api.webdav.webdav import WebDAVClient
311from lib.utils.kodi.utils import (
412 ADDON_HANDLE,
……@@ -12,8 +20,6 @@ from lib.utils.kodi.utils import (
1220 show_picture,
1321 translation,
1422)
15from xbmcgui import Dialog
16from xbmcplugin import addDirectoryItem, addSortMethod, SORT_METHOD_LABEL_IGNORE_THE, SORT_METHOD_FILE
1723
1824
1925def list_webdav(params):
……@@ -36,9 +42,7 @@ def list_webdav(params):
3642
3743 # --- FOLDERS ---
3844 if item["type"] == "folder":
39 new_relative_path = (
40 f"{relative_path}/{item['name']}" if relative_path else item["name"]
41 )
45 new_relative_path = f"{relative_path}/{item['name']}" if relative_path else item["name"]
4246 url = build_url("list_webdav", path=new_relative_path)
4347 is_folder = True
4448
……@@ -80,9 +84,7 @@ def list_webdav(params):
8084 url = item["url"]
8185 is_folder = False
8286
83 addDirectoryItem(
84 handle=ADDON_HANDLE, url=url, listitem=list_item, isFolder=is_folder
85 )
87 addDirectoryItem(handle=ADDON_HANDLE, url=url, listitem=list_item, isFolder=is_folder)
8688
8789 addSortMethod(ADDON_HANDLE, SORT_METHOD_LABEL_IGNORE_THE)
8890 addSortMethod(ADDON_HANDLE, SORT_METHOD_FILE)
plugin.video.jacktook/lib/clients/youtube_resolver.py +6 -8
……@@ -8,7 +8,6 @@ import requests
88from lib.clients.tmdb.trailers import resolve_tmdb_trailer
99from lib.utils.kodi.utils import kodilog
1010
11
1211WATCH_URL = "https://www.youtube.com/watch"
1312PLAYER_URL = "https://www.youtube.com/youtubei/v1/player"
1413CACHE_TTL_SECONDS = 300
……@@ -188,9 +187,8 @@ def _choose_better_video_format(best_fmt: Dict[str, object], candidate_fmt: Dict
188187
189188 best_container_rank = _container_rank(best_fmt.get("mimeType"))
190189 candidate_container_rank = _container_rank(candidate_fmt.get("mimeType"))
191 if (
192 candidate_container_rank != best_container_rank
193 and _video_bitrate_near_tie(candidate_bitrate, best_bitrate)
190 if candidate_container_rank != best_container_rank and _video_bitrate_near_tie(
191 candidate_bitrate, best_bitrate
194192 ):
195193 return candidate_fmt if candidate_container_rank < best_container_rank else best_fmt
196194
……@@ -203,7 +201,9 @@ def _choose_better_video_format(best_fmt: Dict[str, object], candidate_fmt: Dict
203201 return candidate_fmt if _video_rank(candidate_fmt) > _video_rank(best_fmt) else best_fmt
204202
205203
206def _select_best_video_format(formats: List[Dict[str, object]]) -> Optional[Dict[str, object]]:
204def _select_best_video_format(
205 formats: List[Dict[str, object]],
206) -> Optional[Dict[str, object]]:
207207 if not formats:
208208 return None
209209
……@@ -300,9 +300,7 @@ def resolve_trailer_playback(
300300
301301 watch_config = parse_watch_config(watch_response.text)
302302 if not watch_config:
303 kodilog(
304 f"Trailer/YouTube: missing watch config video_id={normalized_video_id!r}"
305 )
303 kodilog(f"Trailer/YouTube: missing watch config video_id={normalized_video_id!r}")
306304 return None
307305
308306 for client in YOUTUBE_CLIENTS:
plugin.video.jacktook/lib/clients/zilean.py +4 -4
……@@ -1,11 +1,13 @@
11import json
22import logging
33from types import SimpleNamespace
4from typing import Any, Callable, Dict, List, Optional
5
46from requests import ConnectTimeout, ReadTimeout
57from requests.exceptions import RequestException
8
69from lib.clients.base import BaseClient, TorrentStream
710from lib.utils.general.utils import USER_AGENT_HEADER, info_hash_to_magnet
8from typing import List, Optional, Dict, Any, Callable
911from lib.utils.kodi.utils import notification
1012
1113
……@@ -78,9 +80,7 @@ class Zilean(BaseClient):
7880 )
7981 if res.status_code != 200:
8082 return
81 response = json.loads(
82 res.content, object_hook=lambda item: SimpleNamespace(**item)
83 )
83 response = json.loads(res.content, object_hook=lambda item: SimpleNamespace(**item))
8484 torrents = []
8585 for result in response:
8686 torrents.append(
plugin.video.jacktook/lib/db/anime.py +4 -3
……@@ -1,11 +1,12 @@
11import os
22import threading
33from sqlite3 import dbapi2 as db
4from lib.utils.kodi.utils import notification, translation
54
5import xbmc
66import xbmcaddon
77from xbmcvfs import translatePath
88
9from lib.utils.kodi.utils import notification, translation
910
1011try:
1112 if xbmc.getCondVisibility("System.HasAddon(script.otaku.mappings)"):
……@@ -18,7 +19,7 @@ try:
1819 mappingDB = os.path.join(mappingPath, "resources", "data", "anime_mappings.db")
1920 else:
2021 mappingDB = None
21except:
22except Exception:
2223 OTAKU_ADDON = None
2324 mappingDB = None
2425
……@@ -34,7 +35,7 @@ def get_all_ids(anilist_id):
3435 conn.row_factory = _dict_factory
3536 conn.execute("PRAGMA FOREIGN_KEYS = 1")
3637 cursor = conn.cursor()
37 cursor.execute("SELECT * FROM anime WHERE anilist_id IN ({0})".format(anilist_id))
38 cursor.execute(f"SELECT * FROM anime WHERE anilist_id IN ({anilist_id})")
3839 mapping = cursor.fetchone()
3940 cursor.close()
4041 try_release_lock(mappingDB_lock)
plugin.video.jacktook/lib/db/cached.py +15 -20
……@@ -1,30 +1,27 @@
11import os
22import pickle
3import re
34import sqlite3
45import sys
56import threading
67import traceback
7import re
8
9from base64 import b64encode, b64decode
8from base64 import b64decode, b64encode
109from datetime import datetime, timedelta
1110from hashlib import sha256
1211
13from lib.jacktook.utils import kodilog
14
15import xbmcaddon
1612import xbmc
13import xbmcaddon
1714import xbmcgui
1815
16from lib.jacktook.utils import kodilog
17
1918PY3 = sys.version_info.major >= 3
2019if PY3:
2120 from xbmcvfs import translatePath
2221else:
2322 from xbmc import translatePath
2423
25ADDON_DATA = translatePath(
26 xbmcaddon.Addon("plugin.video.jacktook").getAddonInfo("profile")
27)
24ADDON_DATA = translatePath(xbmcaddon.Addon("plugin.video.jacktook").getAddonInfo("profile"))
2825ADDON_ID = xbmcaddon.Addon().getAddonInfo("id")
2926
3027if not PY3:
……@@ -49,7 +46,7 @@ def pickle_hash(obj):
4946 return h.hexdigest()
5047
5148
52class _BaseCache(object):
49class _BaseCache:
5350 __instance = None
5451
5552 _load_func = staticmethod(pickle.loads)
……@@ -148,7 +145,7 @@ class MemoryCache(_BaseCache):
148145 self._window.setProperty(self._database + key, b64encode(blob).decode())
149146 except Exception as e:
150147 kodilog(
151 f"[MemoryCache] Error storing key {key!r}: {str(e)}",
148 f"[MemoryCache] Error storing key {key!r}: {e!s}",
152149 level=xbmc.LOGERROR,
153150 )
154151 kodilog(traceback.format_exc(), level=xbmc.LOGERROR)
……@@ -185,17 +182,15 @@ class MemoryCache(_BaseCache):
185182 data = self._window.getProperty(self._database + "_KEY_INDEX_")
186183 if data:
187184 return self._load_func(b64decode(data))
188 except:
185 except Exception:
189186 pass
190187 return set()
191188
192189 def _save_key_index(self, index):
193190 try:
194191 blob = self._dump_func(index)
195 self._window.setProperty(
196 self._database + "_KEY_INDEX_", b64encode(blob).decode()
197 )
198 except:
192 self._window.setProperty(self._database + "_KEY_INDEX_", b64encode(blob).decode())
193 except Exception:
199194 pass
200195
201196 def _add_key_to_index(self, key):
……@@ -291,7 +286,7 @@ class SQLiteCache(_BaseCache):
291286 ")"
292287 )
293288 for k, v in SQLITE_SETTINGS.items():
294 self._conn.execute("PRAGMA {}={}".format(k, v))
289 self._conn.execute(f"PRAGMA {k}={v}")
295290 self._cleanup_interval = cleanup_interval
296291 self._last_cleanup = datetime.utcnow()
297292 self.clean_up()
……@@ -368,12 +363,12 @@ class SQLiteCache(_BaseCache):
368363 ),
369364 )
370365 kodilog(
371 "Set cache for key '{}' with expiry {}".format(key, expires),
366 f"Set cache for key '{key}' with expiry {expires}",
372367 level=xbmc.LOGDEBUG,
373368 )
374369 except Exception as e:
375370 kodilog(
376 "Failed to set cache for key '{}': {}".format(key, str(e)),
371 f"Failed to set cache for key '{key}': {e!s}",
377372 level=xbmc.LOGERROR,
378373 )
379374 # fallback to raw in‑memory store
……@@ -396,7 +391,7 @@ class SQLiteCache(_BaseCache):
396391 self._conn.execute("DELETE FROM `cached` WHERE key LIKE ?", (pattern,))
397392
398393 def _set_version(self, version):
399 self._conn.execute("PRAGMA user_version={}".format(version))
394 self._conn.execute(f"PRAGMA user_version={version}")
400395
401396 @property
402397 def version(self):
plugin.video.jacktook/lib/db/pickle_db.py +2 -3
……@@ -1,5 +1,6 @@
11import os
22import pickle
3
34import xbmcvfs
45
56from lib.jacktook.utils import kodilog
……@@ -15,9 +16,7 @@ class PickleDatabase:
1516 "jt:lib": {},
1617 }
1718
18 data_dir = xbmcvfs.translatePath(
19 os.path.join("special://profile/addon_data/", ADDON_ID)
20 )
19 data_dir = xbmcvfs.translatePath(os.path.join("special://profile/addon_data/", ADDON_ID))
2120 self._database_path = os.path.join(data_dir, "database.pickle")
2221 xbmcvfs.mkdirs(data_dir)
2322
plugin.video.jacktook/lib/domain/torrent.py +6 -1
……@@ -12,6 +12,7 @@ class TorrentStream:
1212 subindexer: str = ""
1313 addonKey: str = ""
1414 addonName: str = ""
15 addonSourceName: str = ""
1516 addonInstanceLabel: str = ""
1617 guid: str = ""
1718 infoHash: str = ""
……@@ -32,7 +33,7 @@ class TorrentStream:
3233 # Quality and URLs
3334 quality: str = "N/A"
3435 url: str = ""
35 # Stremio behaviorHints.proxyHeaders.request — headers the source requires
36 # Stremio behaviorHints.proxyHeaders.request -- headers the source requires
3637 # on playback (Referer/User-Agent for hotlink-protected CDNs).
3738 proxyHeaders: Dict[str, str] = field(default_factory=dict)
3839
……@@ -40,3 +41,7 @@ class TorrentStream:
4041 isPack: bool = False
4142 isCached: bool = False
4243 addedToDebrid: bool = False
44
45 # Codec & HDR
46 codec: str = ""
47 hdr: str = ""
plugin.video.jacktook/lib/download_manager.py +108 -0
……@@ -0,0 +1,108 @@
1import threading
2from dataclasses import dataclass
3from typing import Dict, List, Optional
4
5
6@dataclass
7class DownloadEntry:
8 id: str
9 name: str
10 dest_path: str
11 url: str
12 status: str = "downloading"
13 progress: int = 0
14 speed: int = 0
15 eta: int = 0
16 size: int = 0
17 downloaded: int = 0
18 thread: Optional[threading.Thread] = None
19 cancel_flag: bool = False
20
21
22class DownloadManager:
23 _instance: Optional["DownloadManager"] = None
24 _lock: threading.Lock = threading.Lock()
25
26 def __new__(cls):
27 if cls._instance is None:
28 with cls._lock:
29 if cls._instance is None:
30 cls._instance = super().__new__(cls)
31 cls._instance._init()
32 return cls._instance
33
34 def _init(self):
35 self._registry: Dict[str, DownloadEntry] = {}
36 self._registry_lock = threading.Lock()
37
38 @property
39 def registry(self) -> Dict[str, DownloadEntry]:
40 with self._registry_lock:
41 return dict(self._registry)
42
43 def register(
44 self,
45 name: str,
46 dest_path: str,
47 url: str,
48 thread: Optional[threading.Thread] = None,
49 ) -> Optional[DownloadEntry]:
50 with self._registry_lock:
51 existing = self._registry.get(dest_path)
52 if existing and existing.status == "downloading":
53 return None
54 entry = DownloadEntry(
55 id=dest_path,
56 name=name,
57 dest_path=dest_path,
58 url=url,
59 thread=thread,
60 )
61 self._registry[dest_path] = entry
62 return entry
63
64 def update_progress(
65 self,
66 dest_path: str,
67 downloaded: int,
68 speed: int,
69 eta: int,
70 progress: int,
71 size: int,
72 ):
73 with self._registry_lock:
74 entry = self._registry.get(dest_path)
75 if entry:
76 entry.downloaded = downloaded
77 entry.speed = speed
78 entry.eta = eta
79 entry.progress = progress
80 entry.size = size
81
82 def set_status(self, dest_path: str, status: str):
83 with self._registry_lock:
84 entry = self._registry.get(dest_path)
85 if entry:
86 entry.status = status
87
88 def set_thread(self, dest_path: str, thread: Optional[threading.Thread]):
89 with self._registry_lock:
90 entry = self._registry.get(dest_path)
91 if entry:
92 entry.thread = thread
93
94 def get_entry(self, dest_path: str) -> Optional[DownloadEntry]:
95 with self._registry_lock:
96 return self._registry.get(dest_path)
97
98 def remove_entry(self, dest_path: str):
99 with self._registry_lock:
100 self._registry.pop(dest_path, None)
101
102 def list_entries(self) -> List[DownloadEntry]:
103 with self._registry_lock:
104 return list(self._registry.values())
105
106 def clear(self):
107 with self._registry_lock:
108 self._registry.clear()
plugin.video.jacktook/lib/downloader.py +744 -282
……@@ -1,312 +1,774 @@
1import contextlib
12import json
23import os
4import re
35import ssl
46import threading
5import re
6from urllib.request import Request, urlopen
7import time
8from typing import Optional
79from urllib.parse import parse_qsl, unquote, urlparse
10from urllib.request import Request, urlopen
11
12import xbmc
13import xbmcgui
14import xbmcvfs
15from xbmcplugin import (
16 addDirectoryItems,
17 endOfDirectory,
18 setContent,
19)
20
21from lib.db.cached import MemoryCache
22from lib.download_manager import DownloadManager
23from lib.gui.custom_progress import CustomProgressDialog
24from lib.nav.debrid import resolve_cloud_download_url
825from lib.utils.general.utils import set_pluging_category, supported_video_extensions
926from lib.utils.kodi.utils import (
1027 ADDON_HANDLE,
11 apply_section_view,
28 ADDON_PATH,
1229 action_url_run,
30 apply_section_view,
31 build_url,
1332 bytes_to_human_readable,
1433 get_setting,
15 kodilog,
16 translatePath,
17 ADDON_PATH,
18 notification,
19 open_file,
20 translation,
21)
22from lib.db.cached import MemoryCache
23from lib.gui.custom_progress import CustomProgressDialog
24
25from xbmcplugin import (
26 addDirectoryItems,
27 endOfDirectory,
28 setContent,
34 kodilog,
35 notification,
36 open_file,
37 translatePath,
38 translation,
2939)
30import xbmcgui
31import xbmcvfs
32import xbmc
33
34
35cancel_flag_cache = MemoryCache()
36
37
40
41cancel_flag_cache = MemoryCache()
42
43
3844def handle_download_file(params):
3945 destination = params.get("destination")
40 if not destination or not os.path.exists(destination):
46 if not destination:
47 kodilog(f"[Downloader] Invalid download destination: {destination}")
48 notification("Invalid download destination.")
49 return
50
51 xbmcvfs.mkdirs(destination)
52
53 url = params.get("url", "")
54 file_name = normalize_file_name(params.get("file_name", ""), url)
55 dest_path = os.path.join(destination, file_name)
56 cancel_key = dest_path
57 kodilog(
58 f"[Downloader] handle_download_file: file_name={file_name}, dest_path={dest_path}, url_length={len(url)}"
59 )
60
61 manager = DownloadManager()
62 entry = manager.register(name=file_name, dest_path=dest_path, url=url)
63 if entry is None:
64 kodilog(f"[Downloader] Download already in progress: {dest_path}")
65 notification("Download already in progress.")
66 return
67
68 downloader = Downloader(
69 url=url,
70 destination=destination,
71 name=file_name,
72 registry_id=dest_path,
73 )
74 cancel_flag_cache.set(cancel_key, downloader.is_cancelled)
75 thread = downloader.run()
76 if thread:
77 manager.set_thread(dest_path, thread)
78 kodilog(f"[Downloader] Download thread started for: {dest_path}")
79 else:
80 kodilog(f"[Downloader] Failed to start download thread for: {dest_path}")
81
82
83def download_cloud_file(params):
84 url = params.get("url", "")
85 filename = params.get("filename", "")
86 mode = params.get("mode", "movie")
87 debrid_type = params.get("debrid_type", "")
88
89 if not url and debrid_type in ("TB", "Torbox"):
90 url = resolve_cloud_download_url(
91 {
92 "debrid_type": debrid_type,
93 "torrent_id": params.get("torrent_id", ""),
94 "file_id": params.get("file_id", ""),
95 }
96 )
97
98 if not url:
99 notification("Download link unavailable.")
100 return
101
102 dest_data = {
103 "title": filename,
104 "mode": "movies" if mode in ("movie", "movies") else mode,
105 }
106 destination = get_destination_path(dest_data)
107 if not destination:
41108 notification("Invalid download destination.")
42109 return
43110
44 file_name = normalize_file_name(params.get("file_name", ""), params.get("url", ""))
45 cancel_key = os.path.join(destination, file_name)
46 kodilog(f"Setting cancel event cache key: {cancel_key}")
47
48 downloader = Downloader(
49 url=params.get("url"),
50 destination=destination,
51 name=file_name,
52 )
53 cancel_flag_cache.set(cancel_key, downloader.is_cancelled)
54 downloader.run()
55
56
57def download_video(params):
58 data = json.loads(params["data"])
59 download_dir = get_setting("download_dir")
60 destination = translatePath(download_dir)
61 handle_download_file(
62 {"destination": destination, "file_name": data["title"], "url": data["url"]}
63 )
64
65
66class ProgressHandler:
67 def update(self, percent: int, message: str):
68 pass
69
70 def cancelled(self) -> bool:
71 return False
72
73 def close(self):
74 pass
75
76
77class KodiProgressHandler(ProgressHandler):
78 def __init__(self, title: str, addon_path: str):
79 self.dialog = CustomProgressDialog("custom_progress_dialog.xml", addon_path)
80 self.dialog.show_dialog()
81
82 def update(self, percent: int, message: str):
83 self.dialog.update_progress(percent, message)
84
85 def cancelled(self) -> bool:
86 return self.dialog.cancelled
87
88 def close(self):
89 self.dialog.close()
90
91
92class Downloader:
93 def __init__(
94 self,
95 url: str,
96 destination: str,
97 name: str,
98 ):
99 self.url = url
100 self.destination = destination
101 self.name = name
102 self.headers = {}
103 self.file_size = 0
104 self.monitor = xbmc.Monitor()
105 self.progress_handler = None
106 self.is_cancelled = False
107 self.dest_path = os.path.join(destination, name)
108
109 def run(self):
110 if not self.url:
111 notification("No URL provided for download.")
112 return
113 thread = threading.Thread(target=self._run, daemon=True)
114 thread.start()
115
116 def _run(self):
117 self._prepare_url()
118 if not self._validate_url():
119 notification("Invalid URL for download.")
120 return
121 self._start_download()
122
123 def _prepare_url(self):
124 try:
125 if "|" in self.url:
126 self.headers = dict(parse_qsl(self.url.rsplit("|", 1)[1]))
127 self.url = self.url.split("|")[0]
128 except Exception as e:
129 kodilog(f"[Downloader] Failed to parse headers: {str(e)}")
130
131 def _validate_url(self):
132 try:
133 if not self.headers.get("User-Agent"):
134 from lib.utils.general.utils import USER_AGENT_STRING
135
136 self.headers["User-Agent"] = USER_AGENT_STRING
137
138 request = Request(self.url, headers=self.headers)
139 context = ssl.create_default_context()
140 context.check_hostname = False
141 context.verify_mode = ssl.CERT_NONE
142
143 response = urlopen(request, context=context)
144 self.file_size = int(response.headers.get("Content-Length", 0))
145 return True
146 except Exception as e:
147 kodilog(f"[Downloader] Validation error: {str(e)}")
148 return False
149
150 def _start_download(self):
151 downloaded = 0
152 file_mode = "wb"
153
154 self.progress_handler = KodiProgressHandler("Downloading", ADDON_PATH)
155
156 try:
157 # Resume support
158 if os.path.exists(self.dest_path):
159 downloaded = os.path.getsize(self.dest_path)
160 if downloaded < self.file_size:
161 self.headers["Range"] = f"bytes={downloaded}-"
162 file_mode = "ab"
163 elif downloaded >= self.file_size:
164 notification(f"File already downloaded: {self.name}")
165 return
166
167 request = Request(self.url, headers=self.headers)
168 context = ssl.create_default_context()
169 context.check_hostname = False
170 context.verify_mode = ssl.CERT_NONE
171 response = urlopen(request, context=context)
172
173 with open_file(self.dest_path, file_mode) as file:
174 while not self.monitor.abortRequested():
175 chunk = response.read(1024 * 1024) # 1MB chunks
176 if not chunk:
177 kodilog("No more data to read")
178 break
179
180 file.write(chunk)
181 downloaded += len(chunk)
182
183 if self.file_size:
184 percent = int((downloaded / self.file_size) * 100)
185 else:
186 percent = 0
187
188 self.progress_handler.update(
189 percent,
190 f"{self.name} - {percent}% - {bytes_to_human_readable(downloaded)} / {bytes_to_human_readable(self.file_size)}",
191 )
192
193 # Handle cancellation
194 if (
195 self.progress_handler.cancelled()
196 or cancel_flag_cache.get(self.dest_path) is True
197 ):
198 cancel_flag_cache.set(self.dest_path, True)
199 self.is_cancelled = True
200 file.close()
201 break
202
203 self.monitor.waitForAbort(0.1)
204
205 if self.is_cancelled:
206 notification(f"Download cancelled: {self.name}")
207 else:
208 notification(f"Download completed: {self.name}")
209 except Exception as e:
210 notification(f"Download error: {str(e)}")
211 finally:
212 self.progress_handler.close()
213
214
215def handle_cancel_download(params):
216 file_path = json.loads(params.get("file_path"))
217 if file_path:
218 cancel_flag_cache.set(file_path, True)
219 xbmc.executebuiltin("Container.Refresh")
220 else:
221 notification("No active download found.")
222
223
224def handle_delete_file(params):
225 file_path = json.loads(params.get("file_path"))
226 if not xbmcvfs.exists(file_path):
227 notification("File not found.")
228 return
229 try:
230 xbmcvfs.delete(file_path)
231 notification(f"File Deleted")
232 xbmc.executebuiltin("Container.Refresh")
233 except Exception as e:
234 kodilog(f"Error deleting file: {str(e)}")
235 notification(f"Failed to delete file: {str(e)}")
236
237
238def downloads_viewer(params):
239 set_pluging_category(translation(90015))
240 translated_path = translatePath(get_setting("download_dir"))
111 file_name = normalize_file_name(filename, url)
112 dest_path = os.path.join(destination, file_name)
113
114 manager = DownloadManager()
115 entry = manager.register(name=file_name, dest_path=dest_path, url=url)
116 if entry is None:
117 notification("Download already in progress.")
118 return
119
120 downloader = Downloader(
121 url=url,
122 destination=destination,
123 name=file_name,
124 registry_id=dest_path,
125 )
126 thread = downloader.run()
127 if thread:
128 manager.set_thread(dest_path, thread)
129
130
131def get_destination_path(data):
132 download_dir = get_setting("download_dir")
133 destination = translatePath(download_dir)
134
135 if not get_setting("organize_downloads", False):
136 return destination
137
138 mode = data.get("mode", "")
139 if mode == "movies":
140 movies_folder = get_setting("download_folder_movies", "Movies")
141 destination = os.path.join(destination, movies_folder)
142 elif mode == "tv":
143 tv_data = data.get("tv_data", {})
144 show_name = tv_data.get("name") or data.get("title", "")
145 season = tv_data.get("season", 1)
146 tvshows_folder = get_setting("download_folder_tvshows", "TV Shows")
147 destination = os.path.join(
148 destination,
149 tvshows_folder,
150 show_name,
151 f"Season {int(season):02d}",
152 )
153
154 xbmcvfs.mkdirs(destination)
155 return destination
156
157
158def download_video(params):
159 data = json.loads(params["data"])
160 destination = get_destination_path(data)
161 handle_download_file(
162 {"destination": destination, "file_name": data["title"], "url": data["url"]}
163 )
164
165
166class ProgressHandler:
167 def update(
168 self,
169 percent: int,
170 message: str,
171 downloaded_str: str = "",
172 size_str: str = "",
173 speed_str: str = "",
174 eta_str: str = "",
175 ):
176 pass
177
178 def cancelled(self) -> bool:
179 return False
180
181 def close(self):
182 pass
183
184
185class KodiProgressHandler(ProgressHandler):
186 def __init__(self, title: str, addon_path: str):
187 self.dialog = CustomProgressDialog("custom_progress_dialog.xml", addon_path)
188 self.dialog.show_dialog()
189
190 def update(
191 self,
192 percent: int,
193 message: str,
194 downloaded_str: str = "",
195 size_str: str = "",
196 speed_str: str = "",
197 eta_str: str = "",
198 ):
199 self.dialog.update_progress(percent, message, downloaded_str, size_str, speed_str, eta_str)
200
201 def cancelled(self) -> bool:
202 return self.dialog.cancelled
203
204 def close(self):
205 self.dialog.close()
206
207
208class Downloader:
209 def __init__(
210 self,
211 url: str,
212 destination: str,
213 name: str,
214 registry_id: Optional[str] = None,
215 show_progress: bool = True,
216 ):
217 self.url = url
218 self.destination = destination
219 self.name = name
220 self.registry_id = registry_id
221 self.show_progress = show_progress
222 self.headers = {}
223 self.file_size = 0
224 self.monitor = xbmc.Monitor()
225 self.progress_handler = None
226 self.is_cancelled = False
227 self.dest_path = os.path.join(destination, name)
228 self.temp_path = self.dest_path + ".part"
229 self.meta_path = self.dest_path + ".jacktook.json"
230 self._start_time = None
231
232 def _write_metadata(
233 self,
234 status: str,
235 progress: int = 0,
236 size: int = 0,
237 downloaded: int = 0,
238 speed: int = 0,
239 eta: int = 0,
240 ):
241 try:
242 meta = {
243 "title": self.name,
244 "url": self.url,
245 "status": status,
246 "progress": progress,
247 "size": size,
248 "downloaded": downloaded,
249 "speed": speed,
250 "eta": eta,
251 }
252 with open_file(self.meta_path, "w") as f:
253 json.dump(meta, f)
254 except Exception as e:
255 kodilog(f"[Downloader] Failed to write metadata: {e!s}")
256
257 def _update_registry(self, downloaded: int, percent: int):
258 if not self.registry_id:
259 return
260 speed = 0
261 eta = 0
262 if self._start_time is not None:
263 elapsed = time.time() - self._start_time
264 if elapsed > 0:
265 speed = int(downloaded / elapsed)
266 remaining = self.file_size - downloaded
267 if speed > 0 and remaining > 0:
268 eta = int(remaining / speed)
269 else:
270 eta = 0
271 DownloadManager().update_progress(
272 self.registry_id,
273 downloaded=downloaded,
274 speed=speed,
275 eta=eta,
276 progress=percent,
277 size=self.file_size,
278 )
279
280 def _set_registry_status(self, status: str):
281 if not self.registry_id:
282 return
283 DownloadManager().set_status(self.registry_id, status)
284
285 def _is_cancelled(self):
286 if self.progress_handler and self.progress_handler.cancelled():
287 return True
288 if cancel_flag_cache.get(self.dest_path) is True:
289 return True
290 if self.registry_id:
291 entry = DownloadManager().get_entry(self.registry_id)
292 if entry and entry.cancel_flag:
293 return True
294 return False
295
296 def run(self):
297 if not self.url:
298 notification("No URL provided for download.")
299 return None
300 thread = threading.Thread(target=self._run, daemon=True)
301 thread.start()
302 return thread
303
304 def _run(self):
305 # Show progress dialog immediately, before network calls
306 if self.show_progress:
307 self.progress_handler = KodiProgressHandler("Downloading", ADDON_PATH)
308 self.progress_handler.update(0, translation(90807))
309 else:
310 self.progress_handler = ProgressHandler()
311
312 self._prepare_url()
313 if not self._validate_url():
314 notification("Invalid URL for download.")
315 self.progress_handler.close()
316 return
317 self._start_download()
318
319 def _prepare_url(self):
320 try:
321 if "|" in self.url:
322 self.headers = dict(parse_qsl(self.url.rsplit("|", 1)[1]))
323 self.url = self.url.split("|")[0]
324 except Exception as e:
325 kodilog(f"[Downloader] Failed to parse headers: {e!s}")
326
327 def _validate_url(self):
328 try:
329 if not self.headers.get("User-Agent"):
330 from lib.utils.general.utils import USER_AGENT_STRING
331
332 self.headers["User-Agent"] = USER_AGENT_STRING
333
334 request = Request(self.url, headers=self.headers)
335 context = ssl.create_default_context()
336 context.check_hostname = False
337 context.verify_mode = ssl.CERT_NONE
338
339 response = urlopen(request, context=context)
340 self.file_size = int(response.headers.get("Content-Length", 0))
341 return True
342 except Exception as e:
343 kodilog(f"[Downloader] Validation error: {e!s}")
344 return False
345
346 def _start_download(self):
347 downloaded = 0
348 file_mode = "wb"
349 speed = 0
350 eta = 0
351
352 # Ensure progress handler exists (may already be set by _run)
353 if self.progress_handler is None:
354 if self.show_progress:
355 self.progress_handler = KodiProgressHandler("Downloading", ADDON_PATH)
356 else:
357 self.progress_handler = ProgressHandler()
358
359 self._write_metadata("downloading", 0)
360 self._set_registry_status("downloading")
361 self._start_time = time.time()
362
363 try:
364 # Resume support — check temp file
365 if xbmcvfs.exists(self.temp_path):
366 stat = xbmcvfs.Stat(self.temp_path)
367 downloaded = stat.st_size()
368 if downloaded < self.file_size:
369 self.headers["Range"] = f"bytes={downloaded}-"
370 file_mode = "ab"
371 elif downloaded >= self.file_size:
372 # Temp file is complete but not renamed yet
373 xbmcvfs.rename(self.temp_path, self.dest_path)
374 self._write_metadata("completed", 100)
375 self._set_registry_status("completed")
376 notification(f"File already downloaded: {self.name}")
377 return
378
379 # Also check if final file already exists
380 if xbmcvfs.exists(self.dest_path):
381 stat = xbmcvfs.Stat(self.dest_path)
382 downloaded = stat.st_size()
383 if downloaded >= self.file_size:
384 self._write_metadata("completed", 100)
385 self._set_registry_status("completed")
386 notification(f"File already downloaded: {self.name}")
387 return
388
389 request = Request(self.url, headers=self.headers)
390 context = ssl.create_default_context()
391 context.check_hostname = False
392 context.verify_mode = ssl.CERT_NONE
393 response = urlopen(request, context=context)
394
395 with open_file(self.temp_path, file_mode) as file:
396 while not self.monitor.abortRequested():
397 chunk = response.read(1024 * 1024) # 1MB chunks
398 if not chunk:
399 kodilog("No more data to read")
400 break
401
402 file.write(chunk)
403 downloaded += len(chunk)
404
405 if self.file_size:
406 percent = int((downloaded / self.file_size) * 100)
407 else:
408 percent = 0
409
410 # Calculate speed and ETA for progress display
411 speed_str = ""
412 eta_str = ""
413 if self._start_time is not None:
414 elapsed = time.time() - self._start_time
415 if elapsed > 0:
416 speed = int(downloaded / elapsed)
417 speed_str = f"{bytes_to_human_readable(speed)}/s"
418 remaining = self.file_size - downloaded
419 if speed > 0 and remaining > 0:
420 eta = int(remaining / speed)
421 eta_mins, eta_secs = divmod(eta, 60)
422 eta_hrs, eta_mins = divmod(eta_mins, 60)
423 if eta_hrs:
424 eta_str = f"{eta_hrs}h {eta_mins}m"
425 elif eta_mins:
426 eta_str = f"{eta_mins}m {eta_secs}s"
427 else:
428 eta_str = f"{eta_secs}s"
429 else:
430 eta_str = ""
431
432 self.progress_handler.update(
433 percent,
434 self.name,
435 f"{bytes_to_human_readable(downloaded)} / {bytes_to_human_readable(self.file_size)}",
436 f"{percent}%",
437 speed_str,
438 eta_str,
439 )
440 self._write_metadata(
441 "downloading",
442 percent,
443 size=self.file_size,
444 downloaded=downloaded,
445 speed=speed,
446 eta=eta,
447 )
448 self._update_registry(downloaded, percent)
449
450 # Handle cancellation
451 if self._is_cancelled():
452 cancel_flag_cache.set(self.dest_path, True)
453 self.is_cancelled = True
454 self._write_metadata(
455 "paused",
456 percent,
457 size=self.file_size,
458 downloaded=downloaded,
459 speed=speed,
460 eta=eta,
461 )
462 self._set_registry_status("paused")
463 file.close()
464 break
465
466 self.monitor.waitForAbort(0.1)
467
468 if self.is_cancelled:
469 meta = get_download_metadata(self.dest_path)
470 if meta.get("status") == "paused":
471 notification(f"Download paused: {self.name}")
472 else:
473 notification(f"Download cancelled: {self.name}")
474 else:
475 # Rename temp to final on success
476 if xbmcvfs.exists(self.temp_path):
477 xbmcvfs.rename(self.temp_path, self.dest_path)
478 self._write_metadata(
479 "completed",
480 100,
481 size=self.file_size,
482 downloaded=downloaded,
483 speed=0,
484 eta=0,
485 )
486 self._set_registry_status("completed")
487 notification(f"Download completed: {self.name}")
488 except Exception as e:
489 self._set_registry_status("error")
490 notification(f"Download error: {e!s}")
491 finally:
492 if self.progress_handler:
493 self.progress_handler.close()
494
495
496def handle_cancel_download(params):
497 file_path = json.loads(params.get("file_path"))
498 if file_path:
499 cancel_flag_cache.set(file_path, True)
500 xbmc.executebuiltin("Container.Refresh")
501 else:
502 notification("No active download found.")
503
504
505def handle_pause_download(params, refresh=True):
506 file_path = json.loads(params.get("file_path"))
507 if file_path:
508 # Derive final path (strip .part if present)
509 final_path = file_path[:-5] if file_path.endswith(".part") else file_path
510 cancel_flag_cache.set(final_path, True)
511 meta_path = final_path + ".jacktook.json"
512 try:
513 if xbmcvfs.exists(meta_path):
514 with open_file(meta_path, "r") as f:
515 meta = json.load(f)
516 meta["status"] = "paused"
517 with open_file(meta_path, "w") as f:
518 json.dump(meta, f)
519 except Exception as e:
520 kodilog(f"[Downloader] Failed to update pause metadata: {e!s}")
521 if refresh:
522 xbmc.executebuiltin("Container.Refresh")
523 else:
524 notification("No active download found.")
525
526
527def resume_download(params):
528 file_path = json.loads(params.get("file_path"))
529 if not file_path:
530 notification("File not found.")
531 return
532
533 # Derive final path (strip .part if present)
534 final_path = file_path[:-5] if file_path.endswith(".part") else file_path
535 temp_path = final_path + ".part"
536
537 # Must have at least the temp file or the final file
538 if not xbmcvfs.exists(temp_path) and not xbmcvfs.exists(final_path):
539 notification("File not found.")
540 return
541
542 meta_path = final_path + ".jacktook.json"
543 try:
544 with open_file(meta_path, "r") as f:
545 meta = json.load(f)
546 except Exception as e:
547 kodilog(f"[Downloader] Failed to read metadata for resume: {e!s}")
548 notification("No metadata found for resume.")
549 return
550
551 cancel_flag_cache.set(final_path, False)
552 downloader = Downloader(
553 url=meta.get("url", ""),
554 destination=os.path.dirname(final_path),
555 name=os.path.basename(final_path),
556 )
557 downloader.run()
558
559
560def get_download_metadata(path):
561 # Derive final path (strip .part if present)
562 final_path = path[:-5] if path.endswith(".part") else path
563 meta_path = final_path + ".jacktook.json"
564 defaults = {"status": "unknown", "progress": 0, "title": ""}
565 try:
566 if xbmcvfs.exists(meta_path):
567 with open_file(meta_path, "r") as f:
568 meta = json.load(f)
569 defaults.update(meta)
570 except Exception:
571 pass
572 return defaults
573
574
575def handle_delete_file(params):
576 file_path = json.loads(params.get("file_path"))
577 if not xbmcvfs.exists(file_path):
578 notification("File not found.")
579 return
580 try:
581 xbmcvfs.delete(file_path)
582 # Also delete the temp .part file if it exists
583 final_path = file_path[:-5] if file_path.endswith(".part") else file_path
584 temp_path = final_path + ".part"
585 if xbmcvfs.exists(temp_path):
586 xbmcvfs.delete(temp_path)
587 meta_path = final_path + ".jacktook.json"
588 if xbmcvfs.exists(meta_path):
589 xbmcvfs.delete(meta_path)
590 notification("File Deleted")
591 xbmc.executebuiltin("Container.Refresh")
592 except Exception as e:
593 kodilog(f"Error deleting file: {e!s}")
594 notification(f"Failed to delete file: {e!s}")
595
596
597def _count_active_downloads(directory):
598 """Count downloads that are in-progress (downloading or paused) recursively
599 across all subdirectories.
600 """ # noqa: D205
601 count = 0
602
603 with contextlib.suppress(Exception):
604 count = _walk_count_active(directory)
605 return count
606
607
608def _walk_count_active(directory):
609 """Recursively walk a directory using xbmcvfs and count active downloads."""
610 count = 0
611
612 dirs, files = xbmcvfs.listdir(directory)
613 for f in files:
614 f = f.decode("utf-8") if isinstance(f, bytes) else f
615 if f.endswith(".jacktook.json"):
616 download_path = os.path.join(directory, f.replace(".jacktook.json", ""))
617 if download_path.endswith(".part"):
618 download_path = download_path[:-5]
619 meta = get_download_metadata(download_path)
620 status = meta.get("status", "")
621 if status in ("downloading", "paused"):
622 count += 1
623 for d in dirs:
624 d = d.decode("utf-8") if isinstance(d, bytes) else d
625 count += _walk_count_active(os.path.join(directory, d))
626 return count
627
628
629def downloads_viewer(params):
630 set_pluging_category(translation(90015))
631 download_dir = translatePath(get_setting("download_dir"))
632 path = params.get("path", download_dir)
633 if not path:
634 path = download_dir
635 translated_path = translatePath(path)
241636 item_list = []
242637
243638 try:
244639 setContent(ADDON_HANDLE, "files")
245640 directories, files = xbmcvfs.listdir(translated_path)
246 active_downloads = [
247 f for f in files if is_active_download(os.path.join(translated_path, f))
248 ]
249
250 active_label = f"[COLOR red]{translation(90373)}: {len(active_downloads)}[/COLOR]"
251 active_item = xbmcgui.ListItem(label=active_label)
252 active_item.setProperty("IsPlayable", "false")
253 active_item.setArt(
254 {"icon": os.path.join(ADDON_PATH, "resources", "img", "download.png")}
255 )
256 item_list.append(("", active_item, False))
257
258 for item in directories + files:
259 item_path = os.path.join(translated_path, item)
260 list_item = xbmcgui.ListItem(label=item)
261 if item in directories:
262 info_tag = list_item.getVideoInfoTag()
263 info_tag.setTitle(item)
264 info_tag.setMediaType("folder")
265 list_item.setProperty("IsPlayable", "false")
266 is_folder = True
267 else:
268 info_tag = list_item.getVideoInfoTag()
269 info_tag.setTitle(item)
270 info_tag.setMediaType("file")
271 context_menu = []
272
273 if is_active_download(item_path):
274 context_menu.append(
275 (
276 translation(90374),
277 action_url_run(
278 "handle_cancel_download",
279 file_path=json.dumps(item_path),
280 ),
281 )
282 )
283
284 context_menu.append(
285 (
286 translation(90375),
287 action_url_run(
288 "handle_delete_file", file_path=json.dumps(item_path)
289 ),
290 )
291 )
292
293 list_item.addContextMenuItems(context_menu)
294 is_folder = False
295 item_list.append((item_path, list_item, is_folder))
296
641 # At root level, count active downloads recursively across all subfolders
642 is_root = translated_path.rstrip(os.sep) == download_dir.rstrip(os.sep)
643 if is_root:
644 active_count = _count_active_downloads(translated_path)
645 else:
646 active_downloads = [
647 f for f in files if is_active_download(os.path.join(translated_path, f))
648 ]
649 active_count = len(active_downloads)
650
651 active_label = f"[COLOR red]{translation(90373)}: {active_count}[/COLOR]"
652 active_item = xbmcgui.ListItem(label=active_label)
653 active_item.setProperty("IsPlayable", "false")
654 active_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "download.png")})
655 item_list.append(("", active_item, False))
656
657 for item in directories + files:
658 item_path = os.path.join(translated_path, item)
659
660 # Skip metadata files
661 if item.endswith(".jacktook.json"):
662 continue
663
664 if item in directories:
665 list_item = xbmcgui.ListItem(label=item)
666 info_tag = list_item.getVideoInfoTag()
667 info_tag.setTitle(item)
668 info_tag.setMediaType("folder")
669 list_item.setProperty("IsPlayable", "false")
670 is_folder = True
671 # Navigate through the plugin so subdirectories work correctly
672 listing_url = build_url("downloads_viewer", path=item_path)
673 else:
674 # Strip .part for display but keep it for internal path
675 display_name = item[:-5] if item.endswith(".part") else item
676 meta = get_download_metadata(item_path)
677 status = meta.get("status", "unknown")
678 progress = meta.get("progress", 0)
679
680 if status == "completed":
681 label = f"[COLOR green][OK][/COLOR] {display_name}"
682 elif status == "paused":
683 label = f"[COLOR orange][PAUSED][/COLOR] {display_name} ({progress}%)"
684 elif status == "downloading":
685 label = f"[COLOR red][DL][/COLOR] {display_name}"
686 else:
687 label = display_name
688
689 list_item = xbmcgui.ListItem(label=label)
690 info_tag = list_item.getVideoInfoTag()
691 info_tag.setTitle(display_name)
692 info_tag.setMediaType("file")
693
694 # Make completed files playable directly via plugin URL
695 if status == "completed" and not item.endswith(".part"):
696 play_url = build_url("play_url", url=item_path, name=display_name)
697 list_item.setProperty("IsPlayable", "true")
698 list_item.setPath(play_url)
699 else:
700 list_item.setProperty("IsPlayable", "false")
701
702 context_menu = []
703
704 if status == "downloading":
705 context_menu.append(
706 (
707 translation(90777),
708 action_url_run(
709 "handle_pause_download",
710 file_path=json.dumps(item_path),
711 ),
712 )
713 )
714 elif status == "paused":
715 context_menu.append(
716 (
717 translation(90778),
718 action_url_run(
719 "resume_download",
720 file_path=json.dumps(item_path),
721 ),
722 )
723 )
724 elif is_active_download(item_path):
725 context_menu.append(
726 (
727 translation(90374),
728 action_url_run(
729 "handle_cancel_download",
730 file_path=json.dumps(item_path),
731 ),
732 )
733 )
734
735 if status in ("completed", "paused"):
736 context_menu.append(
737 (
738 translation(90782),
739 action_url_run("handle_delete_file", file_path=json.dumps(item_path)),
740 )
741 )
742
743 list_item.addContextMenuItems(context_menu)
744 is_folder = False
745
746 # Use plugin URL for playable completed files so Kodi routes
747 # them through play_url for proper resolution
748 if status == "completed" and not item.endswith(".part"):
749 listing_url = build_url("play_url", url=item_path, name=display_name)
750 else:
751 listing_url = item_path
752 item_list.append((listing_url, list_item, is_folder))
753
297754 addDirectoryItems(ADDON_HANDLE, item_list)
298755 endOfDirectory(ADDON_HANDLE)
299756 apply_section_view("view.downloads", content_type="files")
300757 except Exception as e:
301 notification(f"Error: {str(e)}", translation(90662))
758 notification(f"Error: {e!s}", translation(90662))
302759 endOfDirectory(ADDON_HANDLE, succeeded=False)
303
304
305def is_active_download(path):
306 cancel_flag = cancel_flag_cache.get(path)
307 return cancel_flag is not True
308
309
760
761
762def is_active_download(path):
763 meta = get_download_metadata(path)
764 if meta["status"] == "downloading":
765 return True
766 if meta["status"] in ("paused", "completed"):
767 return False
768 cancel_flag = cancel_flag_cache.get(path)
769 return cancel_flag is not True
770
771
310772def normalize_file_name(file_name, url=""):
311773 file_name = (file_name or "").strip()
312774 valid_extensions = {ext.lower() for ext in supported_video_extensions()}
plugin.video.jacktook/lib/gui/actor_info_window.py +5 -5
……@@ -1,13 +1,13 @@
11import threading
2
3import xbmcgui
4
5from lib.clients.tmdb.utils.utils import tmdb_get
26from lib.gui.base_window import BaseWindow
37from lib.utils.kodi.utils import (
4 ADDON_PATH,
5 kodilog,
6 notification,
78 execute_builtin,
9 kodilog,
810)
9from lib.clients.tmdb.utils.utils import tmdb_get
10import xbmcgui
1111
1212
1313class ActorInfoWindow(BaseWindow):
plugin.video.jacktook/lib/gui/base_window.py +263 -273
……@@ -1,273 +1,263 @@
1import abc
2from copy import deepcopy
3import hashlib
4import json
5from typing import Any, Dict, Tuple
6from lib.domain.torrent import TorrentStream
7from lib.utils.debrid.debrid_utils import get_magnet_from_uri, _is_torrent_ready_in_debrid
8from lib.utils.player.utils import resolve_playback_url
9from lib.utils.general.utils import Indexer, IndexerType, truncate_text
10from lib.utils.kodi.logging import summarize_locator_for_log
11from lib.utils.kodi.utils import ADDON, kodilog, translation
12import xbmcgui
13
14
15ACTION_PREVIOUS_MENU = 10
16ACTION_PLAYER_STOP = 13
17ACTION_NAV_BACK = 92
18
19
20class BaseWindow(xbmcgui.WindowXMLDialog):
21 def __init__(self, xml_file, location, item_information=None, previous_window=None):
22 super().__init__(xml_file, location)
23 self.item_information = {}
24 self.previous_window = previous_window
25 self.CACHE_KEY = ""
26 self._last_focused_control = (None, None)
27 self.action_exitkeys_id = {
28 ACTION_PREVIOUS_MENU,
29 ACTION_PLAYER_STOP,
30 ACTION_NAV_BACK,
31 }
32 if item_information is None:
33 return
34 self.add_item_information_to_window(item_information)
35
36 def onInit(self):
37 pass
38
39 def get_cached_focus(self):
40 if not self.CACHE_KEY:
41 return None, None
42
43 cache_prop = self._focus_cache_prop()
44 cached_data = xbmcgui.Window(10000).getProperty(cache_prop)
45 if not cached_data:
46 cached_data = ADDON.getSetting(self.CACHE_KEY)
47 if cached_data:
48 xbmcgui.Window(10000).setProperty(cache_prop, cached_data)
49 ADDON.setSetting(self.CACHE_KEY, "")
50 if cached_data:
51 try:
52 return json.loads(cached_data)
53 except json.JSONDecodeError:
54 return None, None
55 return None, None
56
57 def set_cached_focus(self, control_id, item_id):
58 if not self.CACHE_KEY:
59 return
60 cache_data = json.dumps((control_id, item_id))
61 xbmcgui.Window(10000).setProperty(self._focus_cache_prop(), cache_data)
62
63 def _focus_cache_prop(self):
64 cache_key = str(self.CACHE_KEY or "")
65 cache_hash = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()
66 return f"jacktook.focus.{cache_hash}"
67
68 def set_default_focus(
69 self, control_list=None, control_id=None, control_list_reset=False
70 ):
71 try:
72 # Retrieve cached focus if available
73 control_id, item_id = self.get_cached_focus()
74 if control_id and item_id:
75 control = self.getControl(control_id)
76 if isinstance(control, xbmcgui.ControlList):
77 control.selectItem(int(item_id))
78 self.setFocus(control)
79 return
80
81 if control_list and control_list.size() > 0:
82 if control_list_reset:
83 control_list.selectItem(0)
84 self.setFocus(control_list)
85 elif control_id:
86 control = self.getControl(control_id)
87 self.setFocus(control)
88 else:
89 raise ValueError("Neither valid control list nor control ID provided.")
90 except (RuntimeError, ValueError) as e:
91 kodilog(f"Could not set focus: {e}")
92 if control_id:
93 self.setFocusId(control_id)
94
95 def getControlList(self, control_id):
96 try:
97 control = self.getControl(control_id)
98 except RuntimeError as e:
99 kodilog(f"Control does not exist {control_id}", {e})
100 raise ValueError(f"Control with Id {control_id} does not exist")
101
102 if not isinstance(control, xbmcgui.ControlList):
103 raise AttributeError(
104 f"Control with Id {control_id} should be of type ControlList"
105 )
106 return control
107
108 def add_item_information_to_window(self, item_information):
109 self.item_information = deepcopy(item_information)
110 for i in self.item_information:
111 value = self.item_information[i]
112 if i == "overview":
113 value = truncate_text(str(value))
114 try:
115 self.setProperty(f"info.{i}", str(value))
116 except UnicodeEncodeError:
117 self.setProperty(f"info.{i}", value)
118
119 def getControlProgress(self, control_id):
120 control = self.getControl(control_id)
121 if not isinstance(control, xbmcgui.ControlProgress):
122 raise AttributeError(
123 f"Control with Id {control_id} should be of type ControlProgress"
124 )
125
126 return control
127
128 def onClick(self, control_id):
129 self.handle_action(7, control_id)
130
131 def onAction(self, action):
132 action_id = action.getId()
133 if action_id in self.action_exitkeys_id:
134 if self.previous_window:
135 self.previous_window.setProperty("instant_close", "true")
136 self.previous_window.close()
137 self.close()
138 elif action_id != 7:
139 self.handle_action(action_id, self.getFocusId())
140
141 def _ensure_playback_info(self, source: TorrentStream):
142 url, magnet, is_torrent = self._extract_source_details(source)
143
144 if source.addedToDebrid and source.debridType and source.infoHash:
145 is_ready = _is_torrent_ready_in_debrid(source.debridType, source.infoHash)
146 if not is_ready:
147 dialog = xbmcgui.Dialog()
148 message = (
149 f"{translation(90696) % source.debridType}\n\n"
150 f"{translation(90697)}"
151 )
152 choice = dialog.yesno(
153 translation(90695),
154 message,
155 yeslabel=translation(90698),
156 nolabel=translation(90699)
157 )
158 if not choice:
159 return {}
160 source.addedToDebrid = False
161 source.debridType = ""
162
163 source_data = self.prepare_source_data(
164 source=source,
165 url=url,
166 magnet=magnet,
167 is_torrent=is_torrent,
168 )
169 return resolve_playback_url(source_data) or {}
170
171 def _extract_source_details(self, source: TorrentStream) -> Tuple[str, str, bool]:
172 url = source.url or ""
173 guid = source.guid or ""
174 magnet = ""
175 indexer = source.indexer
176 source_type = source.type
177 is_torrent = source.type == IndexerType.TORRENT
178
179 kodilog(
180 "Source extraction input: indexer={!r}, type={!r}, guid={!r}, url={!r}, infohash={!r}".format(
181 indexer,
182 source_type,
183 summarize_locator_for_log(guid),
184 summarize_locator_for_log(url),
185 str(source.infoHash or "").lower()[:12],
186 )
187 )
188
189 if source_type in (IndexerType.DIRECT, IndexerType.STREMIO_DEBRID):
190 return url, magnet, is_torrent
191
192 # Prefer magnet in guid
193 if guid.startswith("magnet:?"):
194 magnet = guid
195 # Handle Burst indexer
196 elif indexer == Indexer.BURST:
197 url = guid
198 # Prefer .torrent in guid or url
199 elif guid.endswith(".torrent"):
200 url = guid
201 elif url.endswith(".torrent"):
202 pass
203
204 # Try to extract magnet from url
205 if url.startswith("http") and not url.endswith(".torrent"):
206 magnet_candidate, _, torrent_url = get_magnet_from_uri(url)
207 if torrent_url:
208 url = torrent_url
209 elif magnet_candidate:
210 magnet = magnet_candidate
211
212 # Try to extract magnet from guid if it's a details page
213 elif not magnet and guid.startswith("http"):
214 magnet_candidate, _, torrent_url = get_magnet_from_uri(guid)
215 if magnet_candidate:
216 magnet = magnet_candidate
217 elif torrent_url:
218 url = torrent_url
219
220 # --- Fallback: magnet from url ---
221 if not magnet and url.startswith("magnet:?"):
222 magnet, url = url, ""
223
224 # --- Fallback: magnet from infoHash ---
225 used_infohash_fallback = False
226 has_http_url = bool(url and url.startswith("http"))
227 if not magnet and source.infoHash and not has_http_url:
228 from lib.utils.general.utils import info_hash_to_magnet
229
230 magnet = info_hash_to_magnet(source.infoHash)
231 used_infohash_fallback = True
232
233 kodilog(
234 "Source extraction output: url={!r}, magnet={!r}, is_torrent={}, used_infohash_fallback={}".format(
235 summarize_locator_for_log(url),
236 summarize_locator_for_log(magnet),
237 is_torrent,
238 used_infohash_fallback,
239 )
240 )
241
242 return url, magnet, is_torrent
243
244 def prepare_source_data(
245 self,
246 source: TorrentStream,
247 url: str,
248 magnet: str,
249 is_torrent: bool,
250 pack_select: bool = False,
251 ) -> Dict[str, Any]:
252 """Prepare the source data dictionary for resolving playback."""
253 return {
254 "type": source.type,
255 "debrid_type": source.debridType,
256 "indexer": source.indexer,
257 "url": url,
258 "magnet": magnet,
259 # getattr: cached results are unpickled, and pickle restores __dict__
260 # directly without applying dataclass defaults — so sources pickled
261 # before this field existed genuinely lack the attribute.
262 "proxyHeaders": getattr(source, "proxyHeaders", None) or {},
263 "info_hash": source.infoHash,
264 "is_torrent": is_torrent,
265 "is_pack": pack_select,
266 "mode": self.item_information.get("mode"),
267 "ids": self.item_information.get("ids"),
268 "tv_data": self.item_information.get("tv_data"),
269 }
270
271 @abc.abstractmethod
272 def handle_action(self, action_id, control_id=None):
273 pass
1import abc
2import hashlib
3import json
4from copy import deepcopy
5from typing import Any, Dict, Tuple
6
7import xbmcgui
8
9from lib.domain.torrent import TorrentStream
10from lib.utils.debrid.debrid_utils import (
11 _is_torrent_ready_in_debrid,
12 get_magnet_from_uri,
13)
14from lib.utils.general.utils import Indexer, IndexerType, truncate_text
15from lib.utils.kodi.logging import summarize_locator_for_log
16from lib.utils.kodi.utils import ADDON, kodilog, translation
17from lib.utils.player.utils import resolve_playback_url
18
19ACTION_PREVIOUS_MENU = 10
20ACTION_PLAYER_STOP = 13
21ACTION_NAV_BACK = 92
22
23
24class BaseWindow(xbmcgui.WindowXMLDialog):
25 def __init__(self, xml_file, location, item_information=None, previous_window=None):
26 super().__init__(xml_file, location)
27 self.item_information = {}
28 self.previous_window = previous_window
29 self.CACHE_KEY = ""
30 self._last_focused_control = (None, None)
31 self.action_exitkeys_id = {
32 ACTION_PREVIOUS_MENU,
33 ACTION_PLAYER_STOP,
34 ACTION_NAV_BACK,
35 }
36 if item_information is None:
37 return
38 self.add_item_information_to_window(item_information)
39
40 def onInit(self):
41 pass
42
43 def get_cached_focus(self):
44 if not self.CACHE_KEY:
45 return None, None
46
47 cache_prop = self._focus_cache_prop()
48 cached_data = xbmcgui.Window(10000).getProperty(cache_prop)
49 if not cached_data:
50 cached_data = ADDON.getSetting(self.CACHE_KEY)
51 if cached_data:
52 xbmcgui.Window(10000).setProperty(cache_prop, cached_data)
53 ADDON.setSetting(self.CACHE_KEY, "")
54 if cached_data:
55 try:
56 return json.loads(cached_data)
57 except json.JSONDecodeError:
58 return None, None
59 return None, None
60
61 def set_cached_focus(self, control_id, item_id):
62 if not self.CACHE_KEY:
63 return
64 cache_data = json.dumps((control_id, item_id))
65 xbmcgui.Window(10000).setProperty(self._focus_cache_prop(), cache_data)
66
67 def _focus_cache_prop(self):
68 cache_key = str(self.CACHE_KEY or "")
69 cache_hash = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()
70 return f"jacktook.focus.{cache_hash}"
71
72 def set_default_focus(self, control_list=None, control_id=None, control_list_reset=False):
73 try:
74 # Retrieve cached focus if available
75 control_id, item_id = self.get_cached_focus()
76 if control_id and item_id:
77 control = self.getControl(control_id)
78 if isinstance(control, xbmcgui.ControlList):
79 control.selectItem(int(item_id))
80 self.setFocus(control)
81 return
82
83 if control_list and control_list.size() > 0:
84 if control_list_reset:
85 control_list.selectItem(0)
86 self.setFocus(control_list)
87 elif control_id:
88 control = self.getControl(control_id)
89 self.setFocus(control)
90 else:
91 raise ValueError("Neither valid control list nor control ID provided.")
92 except (RuntimeError, ValueError) as e:
93 kodilog(f"Could not set focus: {e}")
94 if control_id:
95 self.setFocusId(control_id)
96
97 def getControlList(self, control_id):
98 try:
99 control = self.getControl(control_id)
100 except RuntimeError as e:
101 kodilog(f"Control does not exist {control_id}", {e})
102 raise ValueError(f"Control with Id {control_id} does not exist") from e
103
104 if not isinstance(control, xbmcgui.ControlList):
105 raise AttributeError(f"Control with Id {control_id} should be of type ControlList")
106 return control
107
108 def add_item_information_to_window(self, item_information):
109 self.item_information = deepcopy(item_information)
110 for i in self.item_information:
111 value = self.item_information[i]
112 if i == "overview":
113 value = truncate_text(str(value))
114 try:
115 self.setProperty(f"info.{i}", str(value))
116 except UnicodeEncodeError:
117 self.setProperty(f"info.{i}", value)
118
119 def getControlProgress(self, control_id):
120 control = self.getControl(control_id)
121 if not isinstance(control, xbmcgui.ControlProgress):
122 raise AttributeError(f"Control with Id {control_id} should be of type ControlProgress")
123
124 return control
125
126 def onClick(self, control_id):
127 self.handle_action(7, control_id)
128
129 def onAction(self, action):
130 action_id = action.getId()
131 if action_id in self.action_exitkeys_id:
132 if self.previous_window:
133 self.previous_window.setProperty("instant_close", "true")
134 self.previous_window.close()
135 self.close()
136 elif action_id != 7:
137 self.handle_action(action_id, self.getFocusId())
138
139 def _ensure_playback_info(self, source: TorrentStream):
140 url, magnet, is_torrent = self._extract_source_details(source)
141
142 if source.addedToDebrid and source.debridType and source.infoHash:
143 is_ready = _is_torrent_ready_in_debrid(source.debridType, source.infoHash)
144 if not is_ready:
145 dialog = xbmcgui.Dialog()
146 message = f"{translation(90696) % source.debridType}\n\n{translation(90697)}"
147 choice = dialog.yesno(
148 translation(90695),
149 message,
150 yeslabel=translation(90698),
151 nolabel=translation(90699),
152 )
153 if not choice:
154 return {}
155 source.addedToDebrid = False
156 source.debridType = ""
157
158 source_data = self.prepare_source_data(
159 source=source,
160 url=url,
161 magnet=magnet,
162 is_torrent=is_torrent,
163 )
164 return resolve_playback_url(source_data) or {}
165
166 def _extract_source_details(self, source: TorrentStream) -> Tuple[str, str, bool]:
167 url = source.url or ""
168 guid = source.guid or ""
169 magnet = ""
170 indexer = source.indexer
171 source_type = source.type
172 is_torrent = source.type == IndexerType.TORRENT
173
174 kodilog(
175 "Source extraction input: indexer={!r}, type={!r}, guid={!r}, url={!r}, infohash={!r}".format(
176 indexer,
177 source_type,
178 summarize_locator_for_log(guid),
179 summarize_locator_for_log(url),
180 str(source.infoHash or "").lower()[:12],
181 )
182 )
183
184 if source_type in (IndexerType.DIRECT, IndexerType.STREMIO_DEBRID):
185 return url, magnet, is_torrent
186
187 # Prefer magnet in guid
188 if guid.startswith("magnet:?"):
189 magnet = guid
190 # Handle Burst indexer
191 elif indexer == Indexer.BURST or guid.endswith(".torrent"):
192 url = guid
193 elif url.endswith(".torrent"):
194 pass
195
196 # Try to extract magnet from url
197 if url.startswith("http") and not url.endswith(".torrent"):
198 magnet_candidate, _, torrent_url = get_magnet_from_uri(url)
199 if torrent_url:
200 url = torrent_url
201 elif magnet_candidate:
202 magnet = magnet_candidate
203
204 # Try to extract magnet from guid if it's a details page
205 elif not magnet and guid.startswith("http"):
206 magnet_candidate, _, torrent_url = get_magnet_from_uri(guid)
207 if magnet_candidate:
208 magnet = magnet_candidate
209 elif torrent_url:
210 url = torrent_url
211
212 # --- Fallback: magnet from url ---
213 if not magnet and url.startswith("magnet:"):
214 magnet, url = url, ""
215
216 # --- Fallback: magnet from infoHash ---
217 used_infohash_fallback = False
218 has_http_url = bool(url and url.startswith("http"))
219 if not magnet and source.infoHash and not has_http_url:
220 from lib.utils.general.utils import info_hash_to_magnet
221
222 magnet = info_hash_to_magnet(source.infoHash)
223 used_infohash_fallback = True
224
225 kodilog(
226 f"Source extraction output: url={summarize_locator_for_log(url)!r}, magnet={summarize_locator_for_log(magnet)!r}, is_torrent={is_torrent}, used_infohash_fallback={used_infohash_fallback}"
227 )
228
229 return url, magnet, is_torrent
230
231 def prepare_source_data(
232 self,
233 source: TorrentStream,
234 url: str,
235 magnet: str,
236 is_torrent: bool,
237 pack_select: bool = False,
238 ) -> Dict[str, Any]:
239 """Prepare the source data dictionary for resolving playback."""
240 return {
241 "type": source.type,
242 "debrid_type": source.debridType,
243 "indexer": source.indexer,
244 "url": url,
245 "magnet": magnet,
246 # getattr: cached results are unpickled, and pickle restores __dict__
247 # directly without applying dataclass defaults -- so sources pickled
248 # before this field existed genuinely lack the attribute.
249 "proxyHeaders": getattr(source, "proxyHeaders", None) or {},
250 "info_hash": source.infoHash,
251 "title": self.item_information.get("title")
252 or self.item_information.get("query")
253 or source.title,
254 "is_torrent": is_torrent,
255 "is_pack": pack_select,
256 "mode": self.item_information.get("mode"),
257 "ids": self.item_information.get("ids"),
258 "tv_data": self.item_information.get("tv_data"),
259 }
260
261 @abc.abstractmethod
262 def handle_action(self, action_id, control_id=None):
263 pass
plugin.video.jacktook/lib/gui/custom_dialogs.py +47 -90
……@@ -1,20 +1,26 @@
1import contextlib
12import json
3from concurrent.futures import ThreadPoolExecutor
24from typing import Dict, List
35
6import xbmcgui
7from xbmcgui import WindowXML, WindowXMLDialog
8
49from lib.domain.torrent import TorrentStream
510from lib.gui.custom_progress import CustomProgressDialog
611from lib.gui.play_next_window import PlayNext
712from lib.gui.resolver_window import ResolverWindow
813from lib.gui.resume_window import ResumeDialog
9from lib.utils.kodi.utils import ADDON_PATH, PLAYLIST, kodilog, translation
14from lib.gui.search_status_window import SearchStatusWindow, SearchTaskManager
1015from lib.gui.source_select import SourceSelect
11from lib.gui.search_status_window import SearchTaskManager, SearchStatusWindow
12from lib.domain.torrent import TorrentStream
13from concurrent.futures import ThreadPoolExecutor
14
15from xbmcgui import WindowXMLDialog, WindowXML
16import xbmcgui
17import xbmc
16from lib.utils.kodi.utils import (
17 ADDON_PATH,
18 PLAYLIST,
19 clear_property,
20 kodilog,
21 set_property,
22 translation,
23)
1824
1925
2026class CustomWindow(WindowXML):
……@@ -57,6 +63,7 @@ class CustomDialog(WindowXMLDialog):
5763 _url = 32502
5864 _close_button_id = 32503
5965 _qrcode = 32504
66 _call_to_action = 32505
6067
6168 def __init__(self, *args, **kwargs):
6269 super().__init__(*args, **kwargs)
……@@ -64,19 +71,19 @@ class CustomDialog(WindowXMLDialog):
6471 self.text = kwargs.get("text")
6572 self.url = kwargs.get("url")
6673 self.qrcode_path = kwargs.get("qrcode")
74 self.call_to_action = kwargs.get("call_to_action")
6775
6876 def onInit(self):
6977 self.getControl(self._heading).setLabel(self.heading)
7078 self.getControl(self._text).setLabel(self.text)
7179 self.getControl(self._url).setLabel(self.url)
80 self.getControl(self._call_to_action).setLabel(self.call_to_action or "")
7281 if self.qrcode_path:
7382 self.getControl(self._qrcode).setImage(self.qrcode_path)
7483 self.getControl(self._qrcode).setVisible(True)
7584 else:
76 try:
85 with contextlib.suppress(BaseException):
7786 self.getControl(self._qrcode).setVisible(False)
78 except:
79 pass
8087
8188 def onClick(self, controlId):
8289 if controlId == self._close_button_id:
……@@ -111,9 +118,7 @@ _mock_information = {
111118}
112119
113120
114def source_select(
115 item_info: Dict[str, str], xml_file: str, sources: List[TorrentStream]
116) -> bool:
121def source_select(item_info: Dict[str, str], xml_file: str, sources: List[TorrentStream]) -> bool:
117122 window = SourceSelect(
118123 xml_file,
119124 ADDON_PATH,
……@@ -134,81 +139,35 @@ def run_next_dialog(params):
134139 kodilog(f"Error accessing playlist in run_next_dialog: {e}")
135140 return
136141
137 if playlist_size > 0 and playlist_pos != (playlist_size - 1):
138 window = None
139 try:
140 window = PlayNext(
141 "playing_next.xml",
142 ADDON_PATH,
143 item_information=json.loads(params["item_info"]),
144 )
145 window.doModal()
146 finally:
147 action = window.action if window else None
148 if window is not None:
149 del window
150
151 if action == "next_episode":
152 xbmc.log(
153 f"[JACKTOOK] Next Episode triggered from dialog.",
154 xbmc.LOGINFO,
155 )
156 player = xbmc.Player()
157 if not player.isPlaying():
158 return
159
160 # Wait for window animation
161 xbmc.sleep(600)
162
163 if not player.isPlaying():
164 return
165
166 # Extract episode info and build next episode URL
167 item_info = json.loads(params["item_info"])
168 tv_data = item_info.get("tv_data", {})
169 current_episode = tv_data.get("episode")
170 season = tv_data.get("season")
171
172 if current_episode is None or season is None:
173 return
174
175 next_episode = current_episode + 1
176
177 # Build URL for next episode
178 from lib.utils.kodi.utils import build_url
179
180 next_tv_data = {
181 "episode": next_episode,
182 "season": season,
183 }
184
185 next_url = build_url(
186 "search",
187 mode=item_info.get("mode"),
188 query=item_info.get("title"),
189 ids=item_info.get("ids"),
190 tv_data=next_tv_data,
191 rescrape=True,
192 )
193
194 # Build URL for next episode
195 next_url = build_url(
196 "search",
197 mode=item_info.get("mode"),
198 query=item_info.get("title"),
199 ids=item_info.get("ids"),
200 tv_data=next_tv_data,
201 rescrape=True,
202 )
203
204 # Clear playlist to prevent "out of range" crash when video ends
205 PLAYLIST.clear()
142 window = None
143 try:
144 item_information = json.loads(params["item_info"])
145 if playlist_size > 0 and playlist_pos >= 0 and playlist_pos < playlist_size - 1:
146 try:
147 next_item = PLAYLIST[playlist_pos + 1]
148 next_label = next_item.getLabel()
149 if next_label:
150 item_information["next_label"] = next_label
151 except Exception as e:
152 kodilog(f"Unable to read next playlist item label: {e}")
153 else:
154 kodilog("No next playlist item label available for PlayNext dialog")
206155
207 # Play directly
208 from xbmcgui import ListItem
156 window = PlayNext(
157 "playing_next.xml",
158 ADDON_PATH,
159 item_information=item_information,
160 )
161 window.doModal()
162 finally:
163 action = window.action if window else None
164 if window is not None:
165 del window
209166
210 list_item = ListItem(path=next_url)
211 player.play(next_url, list_item)
167 if action == "next_episode":
168 set_property("jacktook_next_dialog_action", "next_episode")
169 else:
170 clear_property("jacktook_next_dialog_action")
212171
213172
214173def run_resume_dialog(params):
……@@ -327,8 +286,6 @@ def search_status_mock():
327286 manager.submit_task("Stremio", "stremio", dummy_task, 8, False, 20)
328287 manager.submit_task("Prowlarr", "prowlarr", dummy_task, 4, True, 0)
329288
330 window = SearchStatusWindow(
331 "search_status.xml", ADDON_PATH, task_manager=manager
332 )
289 window = SearchStatusWindow("search_status.xml", ADDON_PATH, task_manager=manager)
333290 window.doModal()
334291 del window
plugin.video.jacktook/lib/gui/custom_progress.py +23 -5
……@@ -1,6 +1,7 @@
1from lib.utils.kodi.utils import kodilog, translation
21import xbmcgui
32
3from lib.utils.kodi.utils import kodilog, translation
4
45
56class CustomProgressDialog(xbmcgui.WindowXMLDialog):
67 def __init__(self, xml_file: str, location: str):
……@@ -21,13 +22,30 @@ class CustomProgressDialog(xbmcgui.WindowXMLDialog):
2122 self.getControl(12001).setLabel(self.title)
2223 self.getControl(12002).setLabel(self.message)
2324 self.getControl(12004).setPercent(self.progress)
25 self.getControl(12006).setLabel(f"{self.progress}%")
2426 self.setFocusId(12003)
2527
26 def update_progress(self, percent, message=None):
28 def update_progress(
29 self,
30 percent,
31 message=None,
32 downloaded_str="",
33 size_str="",
34 speed_str="",
35 eta_str="",
36 ):
2737 self.progress = percent
28 self.getControl(12004).setPercent(percent)
29 if message:
30 self.getControl(12002).setLabel(message)
38 try:
39 self.getControl(12004).setPercent(percent)
40 self.getControl(12006).setLabel(f"{percent}%")
41 if message:
42 self.getControl(12002).setLabel(message)
43 if downloaded_str:
44 self.getControl(12007).setLabel(downloaded_str)
45 if speed_str:
46 self.getControl(12008).setLabel(f"{speed_str} | {eta_str}")
47 except Exception as e:
48 kodilog(f"[CustomProgressDialog] Update error: {e}")
3149
3250 def onClick(self, controlId):
3351 if controlId == 12003: # Cancel button
plugin.video.jacktook/lib/gui/download_manager_window.py +406 -0
……@@ -0,0 +1,406 @@
1import json
2import os
3import threading
4import time
5
6import xbmc
7import xbmcgui
8import xbmcvfs
9
10from lib.download_manager import DownloadManager
11from lib.downloader import (
12 Downloader,
13 cancel_flag_cache,
14 get_download_metadata,
15 handle_pause_download,
16)
17from lib.gui.base_window import BaseWindow
18from lib.utils.kodi.settings import get_setting as _get_setting
19from lib.utils.kodi.utils import (
20 bytes_to_human_readable,
21 execute_builtin,
22 kodilog,
23 translation,
24)
25from lib.utils.kodi.utils import open_file as _open_file
26from lib.utils.kodi.utils import translatePath as _translatePath
27
28
29class DownloadManagerWindow(BaseWindow):
30 def __init__(self, xml_file, location, item_information=None, previous_window=None):
31 super().__init__(xml_file, location, item_information, previous_window)
32 self._monitor = xbmc.Monitor()
33 self._closed = False
34 self._poll_thread = None
35 self._focused_item_id = None
36 self._last_registry_checksum = ""
37
38 def onInit(self):
39 self.setProperty("instant_close", "false")
40 self._sync_from_disk()
41 self._rebuild_list()
42 self._poll_thread = threading.Thread(target=self._poll_loop)
43 self._poll_thread.daemon = True
44 self._poll_thread.start()
45
46 def _sync_from_disk(self):
47 """Scan the download directory (recursively) for .jacktook.json files
48 and register or update any downloads in the registry. This ensures
49 that downloads started before the window was opened, or organized
50 into subfolders, appear in the manager.
51 """ # noqa: D205
52 download_dir = _translatePath(_get_setting("download_dir"))
53
54 if not download_dir or not xbmcvfs.exists(download_dir):
55 kodilog(f"[DownloadManagerWindow] Download directory not found: {download_dir}")
56 return
57
58 manager = DownloadManager()
59 try:
60 self._walk_and_sync(download_dir, manager)
61 except Exception as e:
62 kodilog(f"[DownloadManagerWindow] Sync from disk error: {e}")
63
64 def _walk_and_sync(self, directory, manager):
65 """Recursively walk a directory using xbmcvfs and sync .jacktook.json files."""
66 dirs, files = xbmcvfs.listdir(directory)
67
68 for filename in files:
69 filename = filename.decode("utf-8") if isinstance(filename, bytes) else filename
70 if not filename.endswith(".jacktook.json"):
71 continue
72 dest_path = os.path.join(directory, filename.replace(".jacktook.json", ""))
73 # Skip .part files — use the final path
74 if dest_path.endswith(".part"):
75 dest_path = dest_path[:-5]
76 meta = get_download_metadata(dest_path)
77 if not meta:
78 continue
79 status = meta.get("status", "unknown")
80 progress = meta.get("progress", 0)
81 url = meta.get("url", "")
82 name = meta.get("title", os.path.basename(dest_path))
83 # Only register non-cancelled entries
84 if status == "cancelled":
85 continue
86 entry = manager.get_entry(dest_path)
87 if entry:
88 # Update existing entry from disk
89 manager.set_status(dest_path, status)
90 manager.update_progress(
91 dest_path,
92 downloaded=meta.get("downloaded", 0),
93 speed=meta.get("speed", 0),
94 eta=meta.get("eta", 0),
95 progress=progress,
96 size=meta.get("size", 0),
97 )
98 else:
99 entry = manager.register(
100 name=name,
101 dest_path=dest_path,
102 url=url,
103 )
104 if entry:
105 manager.set_status(dest_path, status)
106 manager.update_progress(
107 dest_path,
108 downloaded=meta.get("downloaded", 0),
109 speed=meta.get("speed", 0),
110 eta=meta.get("eta", 0),
111 progress=progress,
112 size=meta.get("size", 0),
113 )
114 for dirname in dirs:
115 dirname = dirname.decode("utf-8") if isinstance(dirname, bytes) else dirname
116 subdir = os.path.join(directory, dirname)
117 self._walk_and_sync(subdir, manager)
118
119 def _poll_loop(self):
120 last_sync = 0
121 while not self._monitor.abortRequested() and not self._closed:
122 xbmc.sleep(500)
123 try:
124 now = time.time()
125 if now - last_sync >= 2:
126 self._sync_from_disk()
127 last_sync = now
128 self._update_selected_properties()
129 self._check_registry_changes()
130 except Exception as e:
131 kodilog(f"[DownloadManagerWindow] Poll error: {e}")
132
133 def _update_selected_properties(self):
134 # Always sync focused_item_id from the list's current selection
135 # so the details panel reflects keyboard/gamepad navigation too
136 try:
137 control_list = self.getControl(14001)
138 item = control_list.getSelectedItem()
139 if item:
140 self._focused_item_id = item.getProperty("entry_id") or self._focused_item_id
141 except Exception:
142 pass
143
144 manager = DownloadManager()
145 entry = manager.get_entry(self._focused_item_id) if self._focused_item_id else None
146 if entry:
147 self.setProperty("selected_progress", str(entry.progress))
148 self.setProperty(
149 "selected_speed",
150 bytes_to_human_readable(entry.speed) + "/s" if entry.speed else "",
151 )
152 eta_secs = entry.eta
153 if eta_secs and eta_secs > 0:
154 eta_mins, eta_secs = divmod(eta_secs, 60)
155 eta_hrs, eta_mins = divmod(eta_mins, 60)
156 if eta_hrs:
157 self.setProperty("selected_eta", f"{eta_hrs}h {eta_mins}m")
158 elif eta_mins:
159 self.setProperty("selected_eta", f"{eta_mins}m {eta_secs}s")
160 else:
161 self.setProperty("selected_eta", f"{eta_secs}s")
162 else:
163 self.setProperty("selected_eta", "")
164 self.setProperty(
165 "selected_size",
166 bytes_to_human_readable(entry.size) if entry.size else "",
167 )
168 self.setProperty(
169 "selected_downloaded",
170 bytes_to_human_readable(entry.downloaded) if entry.downloaded else "",
171 )
172 self.setProperty("selected_status", entry.status)
173 self.setProperty("selected_name", entry.name)
174 else:
175 self.setProperty("selected_progress", "")
176 self.setProperty("selected_speed", "")
177 self.setProperty("selected_eta", "")
178 self.setProperty("selected_size", "")
179 self.setProperty("selected_downloaded", "")
180 self.setProperty("selected_status", "")
181 self.setProperty("selected_name", "")
182
183 def _check_registry_changes(self):
184 manager = DownloadManager()
185 entries = manager.list_entries()
186 checksum = ",".join(f"{e.id}:{e.status}:{e.progress}" for e in entries)
187 if checksum != self._last_registry_checksum:
188 self._last_registry_checksum = checksum
189 execute_builtin("SendClick(14000,14002)")
190
191 def _rebuild_list(self):
192 try:
193 manager = DownloadManager()
194 entries = manager.list_entries()
195 control_list = self.getControl(14001)
196 selected_position = control_list.getSelectedPosition()
197 control_list.reset()
198
199 if not entries:
200 self.setProperty("downloads_empty", "true")
201 return
202
203 self.setProperty("downloads_empty", "false")
204 for entry in entries:
205 label = entry.name
206 speed_str = bytes_to_human_readable(entry.speed) + "/s" if entry.speed else ""
207 if entry.eta and entry.eta > 0:
208 eta_mins, eta_secs = divmod(entry.eta, 60)
209 eta_hrs, eta_mins = divmod(eta_mins, 60)
210 if eta_hrs:
211 eta_str = f"{eta_hrs}h {eta_mins}m"
212 elif eta_mins:
213 eta_str = f"{eta_mins}m {eta_secs}s"
214 else:
215 eta_str = f"{eta_secs}s"
216 else:
217 eta_str = ""
218 size_str = bytes_to_human_readable(entry.size) if entry.size else ""
219 downloaded_str = (
220 bytes_to_human_readable(entry.downloaded) if entry.downloaded else ""
221 )
222
223 # Build secondary info line
224 parts = []
225 if entry.status == "completed":
226 translation(90811) # "Completed"
227 elif speed_str:
228 parts.append(speed_str)
229 if eta_str and entry.status != "completed":
230 parts.append(eta_str)
231 if downloaded_str and size_str:
232 parts.append(f"{downloaded_str} / {size_str}")
233 elif size_str:
234 parts.append(size_str)
235
236 info_line = " • ".join(parts)
237
238 item = xbmcgui.ListItem(label=label)
239 item.setProperty("entry_id", entry.id)
240 item.setProperty("entry_name", entry.name)
241 item.setProperty("entry_status", entry.status)
242 item.setProperty("entry_progress", str(entry.progress))
243 item.setProperty("entry_speed", speed_str)
244 item.setProperty("entry_eta", eta_str)
245 item.setProperty("entry_size", size_str)
246 item.setProperty("entry_downloaded", downloaded_str)
247 item.setProperty("entry_info", info_line)
248 control_list.addItem(item)
249
250 if 0 <= selected_position < control_list.size():
251 control_list.selectItem(selected_position)
252
253 # Set focus on the list so navigation works immediately
254 self.setFocus(control_list)
255
256 # Default focus to first entry if no previous selection
257 if self._focused_item_id is None and entries:
258 self._focused_item_id = entries[0].id
259 except Exception as e:
260 kodilog(f"[DownloadManagerWindow] Rebuild list error: {e}")
261
262 def onAction(self, action):
263 action_id = action.getId()
264 if action_id in self.action_exitkeys_id:
265 self._closed = True
266 super().onAction(action)
267
268 def onClick(self, control_id):
269 if control_id == 14002:
270 self._rebuild_list()
271 return
272
273 if control_id == 14008:
274 self._closed = True
275 self.close()
276 return
277
278 if control_id == 14007:
279 self._clear_completed()
280 execute_builtin("SendClick(14000,14002)")
281 return
282
283 if control_id not in (14003, 14004, 14005, 14006):
284 return
285
286 try:
287 control_list = self.getControl(14001)
288 item = control_list.getSelectedItem()
289 if not item:
290 return
291 entry_id = item.getProperty("entry_id")
292 if not entry_id:
293 return
294 except Exception:
295 return
296
297 self._focused_item_id = entry_id
298
299 if control_id == 14003:
300 self._pause_download(entry_id)
301 elif control_id == 14004:
302 self._resume_download(entry_id)
303 elif control_id == 14005:
304 self._cancel_download(entry_id)
305 elif control_id == 14006:
306 self._delete_download(entry_id)
307
308 execute_builtin("SendClick(14000,14002)")
309
310 def _pause_download(self, entry_id):
311 manager = DownloadManager()
312 entry = manager.get_entry(entry_id)
313 if entry and entry.status == "downloading":
314 entry.cancel_flag = True
315 manager.set_status(entry_id, "paused")
316 import json
317
318 handle_pause_download({"file_path": json.dumps(entry_id)}, refresh=False)
319
320 def _resume_download(self, entry_id):
321 manager = DownloadManager()
322 entry = manager.get_entry(entry_id)
323 if entry and entry.status == "paused":
324 import os
325
326 dest_path = entry_id
327 # Clear the cancel flag left by the pause operation
328 cancel_flag_cache.set(dest_path, False)
329 entry.cancel_flag = False
330 meta = get_download_metadata(dest_path)
331 manager.set_status(entry_id, "downloading")
332 manager.update_progress(
333 entry_id,
334 downloaded=meta.get("downloaded", 0),
335 speed=0,
336 eta=0,
337 progress=meta.get("progress", 0),
338 size=meta.get("size", 0),
339 )
340 downloader = Downloader(
341 url=meta.get("url", entry.url),
342 destination=os.path.dirname(dest_path),
343 name=os.path.basename(dest_path),
344 registry_id=dest_path,
345 show_progress=False,
346 )
347 downloader.run()
348
349 def _cancel_download(self, entry_id):
350 manager = DownloadManager()
351 entry = manager.get_entry(entry_id)
352 if not entry:
353 return
354
355 # Cancel works for both downloading and paused states
356 entry.cancel_flag = True
357 cancel_flag_cache.set(entry_id, True)
358 manager.set_status(entry_id, "cancelled")
359
360 # Update metadata on disk so a cancelled download doesn't resume on restart
361 meta_path = entry_id + ".jacktook.json"
362 if xbmcvfs.exists(meta_path):
363 try:
364 with _open_file(meta_path, "r") as f:
365 meta = json.load(f)
366 meta["status"] = "cancelled"
367 with _open_file(meta_path, "w") as f:
368 json.dump(meta, f)
369 except Exception:
370 pass
371
372 # Delete the .part file (partial download) to free disk space
373 temp_path = entry_id + ".part"
374 if xbmcvfs.exists(temp_path):
375 xbmcvfs.delete(temp_path)
376
377 def _delete_download(self, entry_id):
378 manager = DownloadManager()
379 entry = manager.get_entry(entry_id)
380 if not entry:
381 return
382
383 dialog = xbmcgui.Dialog()
384 if not dialog.yesno(translation(90809), translation(90810)):
385 return
386
387 # Delete files
388 if xbmcvfs.exists(entry_id):
389 xbmcvfs.delete(entry_id)
390 temp_path = entry_id + ".part"
391 if xbmcvfs.exists(temp_path):
392 xbmcvfs.delete(temp_path)
393 meta_path = entry_id + ".jacktook.json"
394 if xbmcvfs.exists(meta_path):
395 xbmcvfs.delete(meta_path)
396
397 manager.remove_entry(entry_id)
398
399 def _clear_completed(self):
400 manager = DownloadManager()
401 for entry in list(manager.list_entries()):
402 if entry.status == "completed":
403 manager.remove_entry(entry.id)
404
405 def handle_action(self, action_id, control_id=None):
406 pass
plugin.video.jacktook/lib/gui/excluded_languages_dialog.py +31 -0
……@@ -0,0 +1,31 @@
1import json
2
3import xbmcgui
4
5from lib.clients.tmdb.utils.excluded_languages_data import EXCLUDABLE_LANGUAGES
6from lib.utils.kodi.utils import get_setting, set_setting, translation
7
8SETTING_KEY = "excluded_languages"
9
10
11def open_excluded_languages_dialog():
12 current_raw = get_setting(SETTING_KEY, "[]")
13 try:
14 current = json.loads(current_raw) if current_raw else []
15 except (ValueError, TypeError):
16 current = []
17
18 options = [lang["name"] for lang in EXCLUDABLE_LANGUAGES]
19 preselect = [i for i, lang in enumerate(EXCLUDABLE_LANGUAGES) if lang["id"] in current]
20
21 selected = xbmcgui.Dialog().multiselect(
22 translation(90813),
23 options,
24 preselect=preselect,
25 )
26
27 if selected is None:
28 return
29
30 new_selection = [EXCLUDABLE_LANGUAGES[i]["id"] for i in selected]
31 set_setting(SETTING_KEY, json.dumps(new_selection))
plugin.video.jacktook/lib/gui/external_scraper_dialog.py +144 -0
……@@ -0,0 +1,144 @@
1import json
2import os
3import sys
4
5import xbmc
6import xbmcgui
7
8from lib.utils.kodi.settings import get_setting
9from lib.utils.kodi.utils import kodilog, set_setting
10
11
12def choose_external_scraper():
13 """Display a dialog to choose an external scraper module.
14
15 Lists all enabled ``xbmc.python.module`` addons, lets the user pick
16 one, tests compatibility by importing its ``sources()`` function, and
17 stores the selection in settings.
18 """
19 addons = _list_compatible_modules()
20 if not addons:
21 xbmcgui.Dialog().notification(
22 "Jacktook",
23 get_setting("external_scraper_no_modules_msg") or "No compatible modules found",
24 )
25 return
26
27 items = []
28 for addon in addons:
29 name = addon.get("name", "")
30 icon = addon.get("thumbnail", "")
31 li = xbmcgui.ListItem(label=name)
32 li.setArt({"icon": icon})
33 items.append(li)
34
35 heading = get_setting("external_scraper_choose_heading") or "Choose External Scraper"
36 selected = xbmcgui.Dialog().select(heading, items, useDetails=True)
37 if selected == -1:
38 return
39
40 chosen = addons[selected]
41 module_id = chosen["addonid"]
42 module_name = chosen["name"]
43
44 # Test compatibility
45 if not _test_module_compatibility(chosen):
46 error_msg = (
47 get_setting("external_scraper_incompatible_msg")
48 or "The selected module is not compatible. Please choose a different one."
49 )
50 xbmcgui.Dialog().ok("Jacktook", error_msg)
51 # Let user try again
52 choose_external_scraper()
53 return
54
55 # Success — persist selection
56 set_setting("external_scraper_module", module_id)
57 set_setting("external_scraper_module_name", module_name)
58 set_setting("external_scraper_enabled", "true")
59
60 success_msg = (
61 get_setting("external_scraper_success_msg")
62 or f"Success!\n{module_name} set as External Scraper"
63 )
64 xbmcgui.Dialog().ok("Jacktook", success_msg)
65
66
67def open_external_scraper_settings():
68 """Open Kodi's settings window for the currently selected scraper."""
69 module_id = get_setting("external_scraper_module")
70 kodilog(f"ExternalScraper: open settings requested, module={module_id}")
71 if not module_id:
72 xbmcgui.Dialog().notification("Jacktook", "No external scraper selected")
73 return
74 try:
75 # When this action is launched from Jacktook's settings window, the
76 # current modal settings dialog can stay in front of (or block) the
77 # external addon's settings window. Close it first, then open the
78 # selected scraper settings.
79 xbmc.executebuiltin("Dialog.Close(addonsettings)")
80 xbmc.sleep(300)
81 xbmc.executebuiltin(f"Addon.OpenSettings({module_id})")
82 xbmc.sleep(500)
83 except Exception as exc:
84 kodilog(f"ExternalScraper: Addon.OpenSettings failed: {exc}")
85 xbmcgui.Dialog().notification("Jacktook", f"Unable to open settings: {module_id}")
86
87
88# ------------------------------------------------------------------
89# Internal helpers
90# ------------------------------------------------------------------
91
92
93def _list_compatible_modules() -> list:
94 """Return a list of enabled ``xbmc.python.module`` addons."""
95 try:
96 response = xbmc.executeJSONRPC(
97 json.dumps(
98 {
99 "jsonrpc": "2.0",
100 "method": "Addons.GetAddons",
101 "params": {
102 "type": "xbmc.python.module",
103 "enabled": True,
104 "properties": ["name", "thumbnail", "path"],
105 },
106 "id": 1,
107 }
108 )
109 )
110 data = json.loads(response)
111 addons = data.get("result", {}).get("addons", [])
112 # Exclude Kodi's own system modules
113 return [a for a in addons if not a["addonid"].startswith(("xbmc.", "kodi."))]
114 except Exception as exc:
115 kodilog(f"ExternalScraper: failed to list modules: {exc}")
116 return []
117
118
119def _test_module_compatibility(addon: dict) -> bool:
120 """Try importing ``sources()`` from *addon* to verify compatibility."""
121 module_id = addon["addonid"]
122 module_name = module_id.split(".")[-1]
123 addon_path = addon.get("path", "")
124
125 lib_path = os.path.join(addon_path, "lib")
126 if not os.path.isdir(lib_path):
127 kodilog(f"ExternalScraper: no lib dir for {module_id}")
128 return False
129
130 # Temporarily add to sys.path (we leave it there since the scraper
131 # will need it at search time anyway).
132 if lib_path not in sys.path:
133 sys.path.insert(0, lib_path)
134
135 try:
136 module = __import__(module_name, fromlist=["sources"])
137 source_dict = module.sources(specified_folders=["torrents"])
138 if not source_dict:
139 kodilog(f"ExternalScraper: {module_id} returned no providers")
140 return False
141 return True
142 except Exception as exc:
143 kodilog(f"ExternalScraper: {module_id} compatibility test failed: {exc}")
144 return False
plugin.video.jacktook/lib/gui/extras_window.py +73 -89
……@@ -1,22 +1,23 @@
1import contextlib
12import json
2import os
33import threading
4
5import xbmcgui
6
7from lib.api.fanart.fanart import get_fanart
8from lib.api.imdb.imdb_scraper import get_imdb_extras
9from lib.clients.tmdb.utils.utils import LANGUAGES, tmdb_get
410from lib.gui.base_window import BaseWindow
11from lib.utils.general.utils import truncate_text
512from lib.utils.kodi.utils import (
613 ADDON_PATH,
7 kodilog,
14 build_url,
815 execute_builtin,
9 notification,
1016 get_setting,
11 build_url,
17 kodilog,
18 notification,
1219 translation,
1320)
14from lib.clients.tmdb.utils.utils import tmdb_get, LANGUAGES
15from lib.api.fanart.fanart import get_fanart
16from lib.api.imdb.imdb_scraper import get_imdb_trivia, get_imdb_goofs
17from lib.utils.general.utils import truncate_text
18import xbmcgui
19import xbmc
2021
2122
2223class ExtrasWindow(BaseWindow):
……@@ -152,10 +153,8 @@ class ExtrasWindow(BaseWindow):
152153 # ── Fanart.tv for clearlogo and better backgrounds ──
153154 language_index = get_setting("language", 18)
154155 lang = "en"
155 try:
156 with contextlib.suppress(Exception):
156157 lang = LANGUAGES[int(language_index)].split("-")[0]
157 except Exception:
158 pass
159158
160159 media_type_fa = "movies" if self.media_type == "movie" else "tv"
161160 fa_id = str(self.tmdb_id)
……@@ -193,11 +192,7 @@ class ExtrasWindow(BaseWindow):
193192 if genres:
194193 genre_names = []
195194 for g in genres:
196 name = (
197 g.get("name", "")
198 if hasattr(g, "get")
199 else getattr(g, "name", "")
200 )
195 name = g.get("name", "") if hasattr(g, "get") else getattr(g, "name", "")
201196 if name:
202197 genre_names.append(name)
203198 if genre_names:
……@@ -213,10 +208,8 @@ class ExtrasWindow(BaseWindow):
213208 vote_average = getattr(data, "vote_average", None)
214209 if vote_average and float(vote_average) > 0:
215210 self.setProperty("tmdb_rating", "true")
216 try:
211 with contextlib.suppress(Exception):
217212 self.getControl(4005).setLabel(f"{float(vote_average):.1f}")
218 except Exception:
219 pass
220213
221214 # Year and runtime — populate Line 2 (control 2001)
222215 line2_parts = []
……@@ -246,18 +239,14 @@ class ExtrasWindow(BaseWindow):
246239 line2_parts.append(str(status))
247240
248241 if line2_parts:
249 try:
242 with contextlib.suppress(Exception):
250243 self.getControl(2001).setLabel(" • ".join(line2_parts))
251 except Exception:
252 pass
253244
254245 # Vote count / Tagline — populate Line 3 (control 3001)
255246 tagline = getattr(data, "tagline", "")
256247 if tagline:
257 try:
248 with contextlib.suppress(Exception):
258249 self.getControl(3001).setLabel(f"[I]{tagline}[/I]")
259 except Exception:
260 pass
261250
262251 except Exception as e:
263252 kodilog(f"Error populating metadata: {e}")
……@@ -306,15 +295,9 @@ class ExtrasWindow(BaseWindow):
306295 if not self.tmdb_id:
307296 return
308297 try:
309 path = (
310 "movie_recommendations"
311 if self.media_type == "movie"
312 else "tv_recommendations"
313 )
298 path = "movie_recommendations" if self.media_type == "movie" else "tv_recommendations"
314299 data = tmdb_get(path, {"id": self.tmdb_id, "page": 1})
315 self.populate_media_list(
316 self.recommended_list_id, data, "recommended.number"
317 )
300 self.populate_media_list(self.recommended_list_id, data, "recommended.number")
318301 except Exception as e:
319302 kodilog(f"Error fetching recommended: {e}")
320303
……@@ -326,9 +309,7 @@ class ExtrasWindow(BaseWindow):
326309 try:
327310 path = "movie_similar" if self.media_type == "movie" else "tv_similar"
328311 data = tmdb_get(path, {"id": self.tmdb_id, "page": 1})
329 self.populate_media_list(
330 self.similar_list_id, data, "more_like_this.number"
331 )
312 self.populate_media_list(self.similar_list_id, data, "more_like_this.number")
332313 except Exception as e:
333314 kodilog(f"Error fetching similar: {e}")
334315
……@@ -337,11 +318,7 @@ class ExtrasWindow(BaseWindow):
337318 def fetch_ai_similar(self):
338319 if not self.tmdb_id:
339320 return
340 path = (
341 "movie_recommendations"
342 if self.media_type == "movie"
343 else "tv_recommendations"
344 )
321 path = "movie_recommendations" if self.media_type == "movie" else "tv_recommendations"
345322 data = tmdb_get(path, {"id": self.tmdb_id, "page": 2})
346323 self.populate_media_list(self.ai_similar_list_id, data, "ai_similar.number")
347324
……@@ -682,37 +659,58 @@ class ExtrasWindow(BaseWindow):
682659 self._fetch_trakt_comments()
683660 return
684661
685 # 1. IMDb Trivia
662 # Fetch all IMDb extras in a single GraphQL call
686663 try:
687 trivia = get_imdb_trivia(self.imdb_id)
688 self._populate_text_panel(self.trivia_list_id, trivia, "imdb_trivia.number")
689 except Exception as e:
690 kodilog(f"Error fetching trivia: {e}")
664 extras = get_imdb_extras(self.imdb_id)
691665
692 # 2. IMDb Goofs
693 try:
694 goofs = get_imdb_goofs(self.imdb_id)
695 self._populate_text_panel(
696 self.blunders_list_id, goofs, "imdb_blunders.number"
697 )
698 except Exception as e:
699 kodilog(f"Error fetching goofs: {e}")
666 # 1. IMDb Reviews
667 try:
668 imdb_reviews = extras.get("reviews", [])
669 if imdb_reviews:
670 self._populate_text_panel(
671 self.reviews_list_id, imdb_reviews, "imdb_reviews.number"
672 )
673 except Exception as e:
674 kodilog(f"Error populating IMDb reviews: {e}")
700675
701 # 3. IMDb Parental Guide
702 try:
703 from lib.api.imdb.imdb_scraper import get_imdb_parentsguide
676 # 2. IMDb Trivia
677 try:
678 trivia = extras.get("trivia", [])
679 self._populate_text_panel(self.trivia_list_id, trivia, "imdb_trivia.number")
680 except Exception as e:
681 kodilog(f"Error populating trivia: {e}")
704682
705 guide = get_imdb_parentsguide(self.imdb_id)
706 self._populate_text_panel(
707 self.parental_guide_list_id, guide, "imdb_parentsguide.number"
708 )
709 except Exception as e:
710 kodilog(f"Error fetching parental guide: {e}")
683 # 3. IMDb Goofs / Blunders
684 try:
685 blunders = extras.get("blunders", [])
686 self._populate_text_panel(self.blunders_list_id, blunders, "imdb_blunders.number")
687 except Exception as e:
688 kodilog(f"Error populating blunders: {e}")
711689
712 # 4. TMDB Reviews
713 self._fetch_tmdb_reviews()
690 # 4. IMDb Parental Guide
691 try:
692 guide_items = extras.get("parentsguide", [])
693 guide_texts = []
694 for item in guide_items:
695 header = f"[B]{item['title']}[/B]"
696 if item.get("ranking"):
697 header += f" — {item['ranking']}"
698 if item.get("content"):
699 guide_texts.append(f"{header}[CR][CR]{item['content']}")
700 else:
701 guide_texts.append(header)
702 self._populate_text_panel(
703 self.parental_guide_list_id,
704 guide_texts,
705 "imdb_parentsguide.number",
706 )
707 except Exception as e:
708 kodilog(f"Error populating parental guide: {e}")
714709
715 # 5. Trakt Comments
710 except Exception as e:
711 kodilog(f"Error fetching IMDb GraphQL extras: {e}")
712
713 # 5. Trakt Comments (keep as separate source)
716714 self._fetch_trakt_comments()
717715
718716 def _fetch_tmdb_reviews(self):
……@@ -730,14 +728,10 @@ class ExtrasWindow(BaseWindow):
730728 if results:
731729 for rev in results:
732730 content = getattr(rev, "content", "") or rev.get("content", "")
733 author = getattr(rev, "author", "") or rev.get(
734 "author", "Unknown"
735 )
731 author = getattr(rev, "author", "") or rev.get("author", "Unknown")
736732 if content:
737733 reviews_list.append(f"[B]{author}[/B]\n{content}")
738 self._populate_text_panel(
739 self.reviews_list_id, reviews_list, "imdb_reviews.number"
740 )
734 self._populate_text_panel(self.reviews_list_id, reviews_list, "imdb_reviews.number")
741735 except Exception as e:
742736 kodilog(f"Error fetching TMDB reviews: {e}")
743737
……@@ -758,14 +752,10 @@ class ExtrasWindow(BaseWindow):
758752 user = comment.get("user", {}).get("username", "Unknown")
759753 if text:
760754 comments_list.append(f"[B]{user}[/B]\n{text}")
761 self._populate_text_panel(
762 self.comments_list_id, comments_list, "trakt_comments.number"
763 )
755 self._populate_text_panel(self.comments_list_id, comments_list, "trakt_comments.number")
764756 except Exception as e:
765757 kodilog(f"Error fetching trakt comments: {e}")
766 self._populate_text_panel(
767 self.comments_list_id, [], "trakt_comments.number"
768 )
758 self._populate_text_panel(self.comments_list_id, [], "trakt_comments.number")
769759
770760 def _populate_text_panel(self, list_id, string_list, count_property):
771761 try:
……@@ -807,9 +797,7 @@ class ExtrasWindow(BaseWindow):
807797 try:
808798 from lib.api.trakt.trakt import TraktAPI
809799
810 TraktAPI().lists.add_to_watchlist(
811 self.media_type, {"tmdb": self.tmdb_id}
812 )
800 TraktAPI().lists.add_to_watchlist(self.media_type, {"tmdb": self.tmdb_id})
813801 notification(translation(90409))
814802 except Exception as e:
815803 kodilog(f"Error modifying trakt watchlist: {e}")
……@@ -820,13 +808,9 @@ class ExtrasWindow(BaseWindow):
820808 from lib.api.trakt.trakt import TraktAPI
821809
822810 if self.media_type == "movie":
823 TraktAPI().lists.mark_as_watched(
824 "movie", None, None, {"tmdb": self.tmdb_id}
825 )
811 TraktAPI().lists.mark_as_watched("movie", None, None, {"tmdb": self.tmdb_id})
826812 else:
827 TraktAPI().lists.mark_as_watched(
828 "show", None, None, {"tmdb": self.tmdb_id}
829 )
813 TraktAPI().lists.mark_as_watched("show", None, None, {"tmdb": self.tmdb_id})
830814 notification(translation(90411))
831815 except Exception as e:
832816 kodilog(f"Error marking watched on trakt: {e}")
plugin.video.jacktook/lib/gui/filter_items_window.py +4 -3
……@@ -1,6 +1,8 @@
11import xbmcgui
2
23from lib.gui.base_window import BaseWindow
34
5
46class FilterWindow(BaseWindow):
57 def __init__(self, xml_file, location, filter):
68 super().__init__(xml_file, location, None)
……@@ -10,14 +12,13 @@ class FilterWindow(BaseWindow):
1012 def onInit(self):
1113 self.list_control = self.getControl(2000)
1214 self.list_control.reset()
13
15
1416 for q in self.filter:
1517 item = xbmcgui.ListItem(label=q)
1618 self.list_control.addItem(item)
1719
1820 self.set_default_focus(self.list_control, 2000, control_list_reset=True)
1921
20
2122 def handle_action(self, action_id, control_id=None):
2223 if action_id == 7: # Select
2324 pos = self.list_control.getSelectedPosition()
……@@ -25,4 +26,4 @@ class FilterWindow(BaseWindow):
2526 self.selected_filter = self.filter[pos]
2627 self.close()
2728 elif action_id in (2, 9, 10, 13, 92): # Back/Escape
28 self.close()
29 self.close()
plugin.video.jacktook/lib/gui/filter_type_window.py +1 -0
……@@ -1,4 +1,5 @@
11import xbmcgui
2
23from lib.gui.base_window import BaseWindow
34from lib.utils.kodi.utils import translation
45
plugin.video.jacktook/lib/gui/play_next_window.py +145 -1
……@@ -1,13 +1,157 @@
1import xbmc
2
13from lib.gui.play_window import PlayWindow
4from lib.utils.kodi.utils import get_setting, kodilog
5
6
7def _safe_text(value):
8 if value is None:
9 return ""
10 return str(value)
11
12
13def _safe_int(value):
14 try:
15 if value in (None, ""):
16 return None
17 return int(value)
18 except (TypeError, ValueError):
19 return None
20
21
22def _dict_value(data, *keys):
23 if not isinstance(data, dict):
24 return ""
25 for key in keys:
26 value = data.get(key)
27 if value not in (None, ""):
28 return value
29 return ""
30
31
32def build_next_episode_properties(item_information):
33 item_information = item_information or {}
34 tv_data = item_information.get("tv_data") or {}
35 next_tv_data = item_information.get("next_tv_data") or {}
36
37 if not isinstance(tv_data, dict):
38 tv_data = {}
39 if not isinstance(next_tv_data, dict):
40 next_tv_data = {}
41
42 season = _safe_int(next_tv_data.get("season"))
43 episode = _safe_int(next_tv_data.get("episode"))
44
45 if season is None:
46 season = _safe_int(tv_data.get("season"))
47
48 if episode is None:
49 current_episode = _safe_int(tv_data.get("episode"))
50 if current_episode is not None:
51 episode = current_episode + 1
52
53 if season is not None and episode is not None:
54 episode_label = f"S{season:02d}E{episode:02d}"
55 elif episode is not None:
56 episode_label = f"Episode {episode}"
57 else:
58 episode_label = ""
59
60 episode_name = _safe_text(_dict_value(next_tv_data, "name", "title", "episode_name"))
61 if not episode_name and episode is not None:
62 episode_name = f"Episode {episode}"
263
64 title = _safe_text(
65 _dict_value(
66 item_information,
67 "show_title",
68 "tvshowtitle",
69 "series_title",
70 "query",
71 "title",
72 "next_label",
73 )
74 )
375
4from lib.utils.kodi.utils import get_setting
76 return {
77 "next.title": title,
78 "next.poster": _safe_text(_dict_value(item_information, "poster", "thumb", "icon")),
79 "next.fanart": _safe_text(_dict_value(item_information, "fanart", "backdrop")),
80 "next.clearlogo": _safe_text(_dict_value(item_information, "clearlogo")),
81 "next.season": "" if season is None else str(season),
82 "next.episode": "" if episode is None else str(episode),
83 "next.episode_label": episode_label,
84 "next.episode_name": episode_name,
85 "next.plot": _safe_text(_dict_value(item_information, "plot", "overview")),
86 }
587
688
789class PlayNext(PlayWindow):
890 def __init__(self, xml_file, xml_location, item_information=None):
991 super().__init__(xml_file, xml_location, item_information=item_information)
1092 self.default_action = 2
93 self.auto_timeout = int(get_setting("playnext_auto_timeout", "10"))
94 self._set_next_episode_properties(item_information or {})
95
96 def _set_next_episode_properties(self, item_information):
97 for key, value in build_next_episode_properties(item_information).items():
98 self.setProperty(key, value)
99
100 def background_tasks(self):
101 if self.auto_timeout <= 0:
102 # Legacy mode: episode-remaining timer, respects global auto_play
103 self.setProperty("timer_label", "Playing in {} seconds")
104 super().background_tasks()
105 return
106
107 # Auto-timeout mode: count down from setting, independent of auto_play
108 try:
109 try:
110 progress_bar = self.getControl(3014)
111 if not hasattr(progress_bar, "setPercent"):
112 progress_bar = None
113 except (RuntimeError, AttributeError):
114 progress_bar = None
115
116 # Snapshot episode remaining for progress bar scaling
117 episode_remaining = max(int(self.getTotalTime()) - int(self.getTime()), 1)
118 self.duration = episode_remaining
119
120 remaining = self.auto_timeout
121 ticks = 0
122
123 while remaining > 0 and not self.closed and self.playing_file == self.getPlayingFile():
124 episode_left = int(self.getTotalTime()) - int(self.getTime())
125
126 # Episode ended (2s or less) -> auto-play immediately
127 if episode_left <= 2:
128 self.handle_action(7, 3001)
129 return
130
131 # Decrement displayed timeout every 2 ticks (= 1 real second)
132 if ticks > 0 and ticks % 2 == 0:
133 remaining -= 1
134
135 self.setProperty("timer", str(remaining))
136 self.setProperty(
137 "timer_label",
138 f"Auto-playing in {remaining} seconds",
139 )
140
141 if progress_bar is not None:
142 progress_bar.setPercent(self.calculate_percent())
143
144 xbmc.sleep(500)
145 ticks += 1
146
147 # Timeout reached — auto-play
148 if not self.closed and self.playing_file == self.getPlayingFile():
149 self.handle_action(7, 3001)
150
151 except Exception as e:
152 kodilog(f"Error in auto-timeout background_tasks: {e}")
153
154 self.close()
11155
12156 def smart_play_action(self):
13157 if get_setting("auto_play"):
plugin.video.jacktook/lib/gui/play_window.py +10 -6
……@@ -1,7 +1,9 @@
11import abc
2
3import xbmc
4
25from lib.gui.base_window import BaseWindow
36from lib.utils.kodi.utils import kodilog
4import xbmc
57
68
79class PlayWindow(BaseWindow):
……@@ -44,15 +46,15 @@ class PlayWindow(BaseWindow):
4446 super().onInit()
4547
4648 def calculate_percent(self):
47 return (
48 (int(self.getTotalTime()) - int(self.getTime())) / float(self.duration)
49 ) * 100
49 return ((int(self.getTotalTime()) - int(self.getTime())) / float(self.duration)) * 100
5050
5151 def background_tasks(self):
5252 try:
5353 try:
54 progress_bar = self.getControlProgress(3014)
55 except RuntimeError:
54 progress_bar = self.getControl(3014)
55 if not hasattr(progress_bar, "setPercent"):
56 progress_bar = None
57 except (RuntimeError, AttributeError):
5658 progress_bar = None
5759
5860 while (
……@@ -82,10 +84,12 @@ class PlayWindow(BaseWindow):
8284 def smart_play_action(self):
8385 """
8486 Perform the default smartplay action at window timeout
87
8588 :return:
8689 """
8790
8891 def close(self):
92
8993 self.closed = True
9094 super().close()
9195
plugin.video.jacktook/lib/gui/qr_progress_dialog.py +3 -2
……@@ -1,4 +1,5 @@
11import xbmcgui
2
23from lib.utils.kodi.utils import translation
34
45
……@@ -28,13 +29,13 @@ class QRProgressDialog(xbmcgui.WindowXMLDialog):
2829 ):
2930 self.title = title
3031 self.qr_image_path = qr_code
31
32
3233 # If custom message provided, use it directly (bypass translation formatting)
3334 if custom_message:
3435 self.message = custom_message
3536 elif is_debrid:
3637 if debrid_type == "RealDebrid":
37 self.message = "%s\n\n%s" % (
38 self.message = "{}\n\n{}".format(
3839 translation(90541) % "https://real-debrid.com/device",
3940 translation(90542) % user_code,
4041 )
plugin.video.jacktook/lib/gui/resolver_window.py +12 -14
……@@ -1,15 +1,20 @@
1from typing import Optional, Dict, Any, cast
21from datetime import timedelta
2from typing import Any, Dict, Optional, cast
33
44from lib.clients.subtitle.submanager import SubtitleManager
55from lib.db.cached import cache
6from lib.domain.torrent import TorrentStream
67from lib.gui.base_window import BaseWindow
78from lib.gui.source_pack_select import SourcePackSelect
89from lib.player import JacktookPLayer
910from lib.utils.debrid.debrid_utils import get_pack_info
10from lib.utils.kodi.utils import get_setting, kodilog
11from lib.utils.kodi.utils import ADDON_PATH, notification, set_property
12from lib.domain.torrent import TorrentStream
11from lib.utils.kodi.utils import (
12 ADDON_PATH,
13 get_setting,
14 kodilog,
15 notification,
16 set_property,
17)
1318
1419
1520class ResolverWindow(BaseWindow):
……@@ -147,17 +152,12 @@ class ResolverWindow(BaseWindow):
147152 key = ""
148153 if "ids" in playback_info:
149154 ids = cast(Dict[str, Any], playback_info["ids"] or {})
150 key = str(
151 ids.get("original_id")
152 or ids.get("imdb_id")
153 or ids.get("tmdb_id")
154 or ""
155 )
155 key = str(ids.get("original_id") or ids.get("imdb_id") or ids.get("tmdb_id") or "")
156156
157157 if key:
158158 tv_data = cast(Dict[str, Any], playback_info.get("tv_data", {}) or {})
159159 if tv_data and "season" in tv_data and "episode" in tv_data:
160 key += f'_{tv_data["season"]}_{tv_data["episode"]}'
160 key += f"_{tv_data['season']}_{tv_data['episode']}"
161161
162162 if key:
163163 cache.set(key, playback_info, timedelta(days=30))
……@@ -186,9 +186,7 @@ class ResolverWindow(BaseWindow):
186186
187187
188188class SourceException(Exception):
189 def __init__(
190 self, message: str, status_code: Optional[int] = None, error_content: Any = None
191 ):
189 def __init__(self, message: str, status_code: Optional[int] = None, error_content: Any = None):
192190 self.message = message
193191 self.status_code = status_code
194192 self.error_content = error_content
plugin.video.jacktook/lib/gui/resume_window.py +1 -0
……@@ -1,6 +1,7 @@
11from lib.gui.base_window import BaseWindow
22from lib.utils.kodi.utils import translation
33
4
45class ResumeDialog(BaseWindow):
56 def __init__(self, xml_file, xml_location, **kwargs):
67 super().__init__(xml_file, xml_location)
plugin.video.jacktook/lib/gui/search_status_window.py +69 -46
……@@ -1,15 +1,13 @@
1from dataclasses import dataclass, field
2from typing import List, Optional, Callable, Dict, Any
3from concurrent.futures import Future, ThreadPoolExecutor, as_completed
41import threading
2from concurrent.futures import Future, ThreadPoolExecutor
3from dataclasses import dataclass
4from typing import Callable, List, Optional
55
66import xbmc
7import xbmcgui
87
9from lib.utils.kodi.utils import kodilog, translation
10from lib.gui.base_window import BaseWindow
118from lib.domain.torrent import TorrentStream
12
9from lib.gui.base_window import BaseWindow
10from lib.utils.kodi.utils import kodilog, translation
1311
1412STATUS_MAP = {
1513 "Pending": 90244,
……@@ -24,9 +22,7 @@ STATUS_MAP = {
2422class SearchTask:
2523 name: str
2624 indexer_key: str
27 status: str = (
28 "Pending" # "Pending", "In Progress", "Completed", "Failed", "Cancelled"
29 )
25 status: str = "Pending" # "Pending", "In Progress", "Completed", "Failed", "Cancelled"
3026 result_count: int = 0
3127 error: str = ""
3228 future: Optional[Future] = None
……@@ -37,19 +33,19 @@ class SearchTaskManager:
3733 self.executor = executor
3834 self.tasks: List[SearchTask] = []
3935 self._cancel_event = threading.Event()
36 self._lock = threading.RLock()
4037
41 def submit_task(
42 self, name: str, indexer_key: str, fn: Callable, *args, **kwargs
43 ) -> SearchTask:
38 def submit_task(self, name: str, indexer_key: str, fn: Callable, *args, **kwargs) -> SearchTask:
4439 task = SearchTask(name=name, indexer_key=indexer_key)
45 self.tasks.append(task)
40 with self._lock:
41 self.tasks.append(task)
4642
4743 def wrapped_fn(*a, **kw):
4844 if self._cancel_event.is_set():
49 task.status = "Cancelled"
45 self._update_task(task, status="Cancelled")
5046 return []
5147
52 task.status = "In Progress"
48 self._update_task(task, status="In Progress")
5349 try:
5450 # ARTIFICIAL DELAY FOR TESTING GUI UPDATES
5551 # xbmc.sleep(8000)
……@@ -57,15 +53,17 @@ class SearchTaskManager:
5753 results = fn(*a, **kw)
5854
5955 if self._cancel_event.is_set():
60 task.status = "Cancelled"
56 self._update_task(task, status="Cancelled")
6157 return results if results else []
6258
63 task.status = "Completed"
64 task.result_count = len(results) if results else 0
59 self._update_task(
60 task,
61 status="Completed",
62 result_count=len(results) if results else 0,
63 )
6564 return results
6665 except Exception as e:
67 task.status = "Failed"
68 task.error = str(e)
66 self._update_task(task, status="Failed", error=str(e))
6967 kodilog(f"Task {name} failed: {e}")
7068 return []
7169
……@@ -75,10 +73,11 @@ class SearchTaskManager:
7573
7674 def cancel_pending(self):
7775 self._cancel_event.set()
78 for task in self.tasks:
79 if task.status in ["Pending", "In Progress"]:
80 if task.future and getattr(task.future, "cancel", lambda: False)():
81 task.status = "Cancelled"
76 with self._lock:
77 for task in self.tasks:
78 if task.status in ["Pending", "In Progress"]:
79 if not task.future or getattr(task.future, "cancel", lambda: False)():
80 task.status = "Cancelled"
8281
8382 @property
8483 def is_cancelled(self) -> bool:
……@@ -86,11 +85,42 @@ class SearchTaskManager:
8685
8786 @property
8887 def is_complete(self) -> bool:
89 return all(t.status in ["Completed", "Failed", "Cancelled"] for t in self.tasks)
88 with self._lock:
89 return all(t.status in ["Completed", "Failed", "Cancelled"] for t in self.tasks)
90
91 def _update_task(
92 self,
93 task: SearchTask,
94 status: Optional[str] = None,
95 result_count: Optional[int] = None,
96 error: Optional[str] = None,
97 ):
98 with self._lock:
99 if status is not None:
100 task.status = status
101 if result_count is not None:
102 task.result_count = result_count
103 if error is not None:
104 task.error = error
105
106 def snapshot_tasks(self) -> List[SearchTask]:
107 with self._lock:
108 return [
109 SearchTask(
110 name=task.name,
111 indexer_key=task.indexer_key,
112 status=task.status,
113 result_count=task.result_count,
114 error=task.error,
115 )
116 for task in self.tasks
117 ]
90118
91119 def collect_results(self) -> List[TorrentStream]:
92120 total_results = []
93 for task in self.tasks:
121 with self._lock:
122 tasks = list(self.tasks)
123 for task in tasks:
94124 if task.future and task.future.done():
95125 try:
96126 res = task.future.result()
……@@ -120,20 +150,7 @@ class SearchStatusWindow(BaseWindow):
120150 self.setProperty("instant_close", "false")
121151
122152 # Set initial task names so they appear immediately
123 for i, task in enumerate(self.task_manager.tasks):
124 if i >= self.MAX_TASK_SLOTS:
125 break
126 self.setProperty(f"task_{i}_name", task.name)
127 self.setProperty(
128 f"task_{i}_status", translation(STATUS_MAP.get(task.status, 90244))
129 )
130 self.setProperty(f"task_{i}_color", self._get_status_color(task.status))
131 self.setProperty(f"task_{i}_results", "")
132
133 # Start background update thread
134 self._update_thread = threading.Thread(target=self._update_loop)
135 self._update_thread.daemon = True
136 self._update_thread.start()
153 self._update_ui_state()
137154
138155 def _get_status_color(self, status: str) -> str:
139156 if status == "Pending":
……@@ -148,9 +165,9 @@ class SearchStatusWindow(BaseWindow):
148165 return "FFFFA500" # Orange
149166 return "FFFFFFFF" # White
150167
151 def _update_loop(self):
168 def run_until_complete(self, poll_interval_ms=250, final_delay_ms=500):
152169 while not self._monitor.abortRequested() and not self.task_manager.is_complete:
153 xbmc.sleep(250)
170 xbmc.sleep(poll_interval_ms)
154171
155172 if self.task_manager.is_cancelled:
156173 break
……@@ -162,16 +179,17 @@ class SearchStatusWindow(BaseWindow):
162179
163180 # Give user a brief moment to see final state if not cancelled
164181 if not self.task_manager.is_cancelled:
165 xbmc.sleep(500)
182 xbmc.sleep(final_delay_ms)
166183
167184 self.close()
168185
169186 def _update_ui_state(self):
170 total_tasks = len(self.task_manager.tasks)
187 tasks = self.task_manager.snapshot_tasks()
188 total_tasks = len(tasks)
171189 completed_tasks = 0
172190 total_results = 0
173191
174 for i, task in enumerate(self.task_manager.tasks):
192 for i, task in enumerate(tasks):
175193 if i >= self.MAX_TASK_SLOTS:
176194 break
177195
……@@ -185,16 +203,21 @@ class SearchStatusWindow(BaseWindow):
185203 if task.status == "Completed":
186204 completed_tasks += 1
187205 self.setProperty(f"task_{i}_results", str(task.result_count))
206 self.setProperty(f"task_{i}_show_spinner", "")
188207 elif task.status in ["Failed", "Cancelled"]:
189208 completed_tasks += 1
190209 self.setProperty(f"task_{i}_results", "")
210 self.setProperty(f"task_{i}_show_spinner", "")
191211 elif task.status == "In Progress":
192212 self.setProperty(
193213 f"task_{i}_results",
194214 str(task.result_count) if task.result_count > 0 else "",
195215 )
216 show_spinner = "true" if task.result_count == 0 else ""
217 self.setProperty(f"task_{i}_show_spinner", show_spinner)
196218 else:
197219 self.setProperty(f"task_{i}_results", "")
220 self.setProperty(f"task_{i}_show_spinner", "")
198221
199222 # Update progress
200223 if total_tasks > 0:
plugin.video.jacktook/lib/gui/skip_intro_window.py +5 -3
……@@ -3,8 +3,7 @@ from threading import Thread
33import xbmc
44import xbmcgui
55
6from lib.utils.kodi.utils import ADDON_PATH, kodilog
7
6from lib.utils.kodi.utils import kodilog
87
98ACTION_PREVIOUS_MENU = 10
109ACTION_PLAYER_STOP = 13
……@@ -14,10 +13,11 @@ ACTION_NAV_BACK = 92
1413class SkipIntroWindow(xbmcgui.WindowXMLDialog):
1514 """
1615 Overlay dialog that shows a 'Skip Intro' or 'Skip Recap' button
16
1717 during playback when IntroDB segment data is available.
1818 """
19
2019 def __init__(self, xml_file, xml_location, segment_data=None, label="Skip Intro"):
20
2121 super().__init__(xml_file, xml_location)
2222 self.segment_data = segment_data or {}
2323 self.label = label
……@@ -41,6 +41,7 @@ class SkipIntroWindow(xbmcgui.WindowXMLDialog):
4141 def _background_monitor(self):
4242 """Monitor playback and auto-close when segment ends or playback stops."""
4343 try:
44
4445 end_sec = self.segment_data.get("end_sec", 0)
4546
4647 while not self.closed and self.player.isPlaying():
……@@ -80,6 +81,7 @@ class SkipIntroWindow(xbmcgui.WindowXMLDialog):
8081 def _do_skip(self):
8182 """Seek to the end of the current segment."""
8283 try:
84
8385 end_sec = self.segment_data.get("end_sec", 0)
8486 if end_sec > 0 and self.player.isPlaying():
8587 self.player.seekTime(end_sec)
plugin.video.jacktook/lib/gui/source_manager_dialog.py +163 -0
……@@ -0,0 +1,163 @@
1import json
2import os
3from datetime import timedelta
4
5import xbmcgui
6
7from lib.db.cached import cache
8from lib.utils.kodi.settings import get_setting
9from lib.utils.kodi.utils import ADDON_PATH, kodilog, translation
10
11BUILTIN_SOURCE_SETTINGS = [
12 ("jackett_enabled", "Jackett"),
13 ("prowlarr_enabled", "Prowlarr"),
14 ("jacktookburst_enabled", "Burst"),
15 ("jackgram_enabled", "Jackgram"),
16 ("easynews_enabled", "Easynews"),
17 ("stremio_enabled", "Stremio"),
18 ("external_scraper_enabled", "External Scraper"),
19]
20
21CACHE_KEY = "source_manager_selection"
22KNOWN_CACHE_KEY = "source_manager_known_keys"
23
24
25def _parse_selection(raw):
26 if raw is None:
27 return []
28 if isinstance(raw, str):
29 try:
30 return json.loads(raw)
31 except (ValueError, TypeError):
32 return []
33 try:
34 return list(raw)
35 except TypeError:
36 return []
37
38
39def _get_icon_path(name):
40 icon_name = f"{name.lower()}.png"
41 media_path = os.path.join(ADDON_PATH, "resources", "media", icon_name)
42 if os.path.exists(media_path):
43 return media_path
44 img_path = os.path.join(ADDON_PATH, "resources", "img", icon_name)
45 if os.path.exists(img_path):
46 return img_path
47 return os.path.join(ADDON_PATH, "icon.png")
48
49
50def _load_stremio_addons():
51 """Import and return the Stremio addon selector, or None on failure."""
52 try:
53 from lib.clients.stremio.helpers import get_selected_stream_addons
54
55 return get_selected_stream_addons
56 except Exception as e:
57 kodilog(f"Failed to import get_selected_stream_addons: {e}")
58 return None
59
60
61def _add_stremio_sources(items, cache_keys, get_selected_stream_addons):
62 """Append enabled Stremio addon entries to items/cache_keys."""
63 if get_selected_stream_addons is None:
64 return
65 try:
66 addons = get_selected_stream_addons()
67 for addon in addons:
68 key = f"Stremio:{addon.key()}"
69 addon_name = addon.manifest.name
70 addon_icon_path = _get_icon_path(addon_name)
71 li_addon = xbmcgui.ListItem(label=addon_name)
72 li_addon.setArt({"icon": addon_icon_path})
73 items.append(li_addon)
74 cache_keys.append(key)
75 except Exception as e:
76 kodilog(f"Error loading Stremio addons: {e}")
77
78
79def _build_source_items():
80 """Return (ListItem list, cache key list) for enabled sources."""
81 get_selected_stream_addons = _load_stremio_addons()
82 items = []
83 cache_keys = []
84
85 for setting_key, display_name in BUILTIN_SOURCE_SETTINGS:
86 if not get_setting(setting_key):
87 continue
88
89 if setting_key == "external_scraper_enabled":
90 module_name = get_setting("external_scraper_module_name")
91 if module_name:
92 display_name = str(module_name)
93
94 if setting_key == "stremio_enabled":
95 _add_stremio_sources(items, cache_keys, get_selected_stream_addons)
96 continue
97
98 icon_path = _get_icon_path(display_name)
99 li = xbmcgui.ListItem(label=display_name)
100 li.setArt({"icon": icon_path})
101 items.append(li)
102 cache_keys.append(display_name)
103
104 return items, cache_keys
105
106
107def _resolve_selection(cache_keys):
108 """Load saved selection, auto-select newly enabled sources, and persist changes.
109
110 Returns the current selection list.
111 """
112 current_selection = _parse_selection(cache.get(CACHE_KEY))
113 known_keys = _parse_selection(cache.get(KNOWN_CACHE_KEY))
114 modified = False
115
116 if not current_selection:
117 current_selection = list(cache_keys)
118 known_keys = list(cache_keys)
119 modified = True
120 else:
121 # Only auto-select sources that were not known the last time the
122 # dialog was saved. This avoids re-selecting sources the user
123 # deliberately deselected.
124 newly_enabled = [key for key in cache_keys if key not in known_keys]
125 if newly_enabled:
126 current_selection.extend(newly_enabled)
127 known_keys = list(cache_keys)
128 modified = True
129
130 if modified:
131 cache.set(CACHE_KEY, json.dumps(current_selection), expires=timedelta(days=365))
132 cache.set(KNOWN_CACHE_KEY, json.dumps(known_keys), expires=timedelta(days=365))
133
134 return current_selection
135
136
137def _persist_selection(selected, cache_keys):
138 """Save the user's selection and the set of known source keys."""
139 cache.set(CACHE_KEY, json.dumps(selected), expires=timedelta(days=365))
140 cache.set(KNOWN_CACHE_KEY, json.dumps(cache_keys), expires=timedelta(days=365))
141
142
143def open_source_manager_dialog():
144 items, cache_keys = _build_source_items()
145 if not items:
146 xbmcgui.Dialog().notification("Jacktook", translation(90756))
147 return
148
149 current_selection = _resolve_selection(cache_keys)
150 preselect = [i for i, key in enumerate(cache_keys) if key in current_selection]
151
152 result = xbmcgui.Dialog().multiselect(
153 translation(90755),
154 items,
155 preselect=preselect,
156 useDetails=True,
157 )
158
159 if result is None or not result:
160 return
161
162 selected = [cache_keys[i] for i in result]
163 _persist_selection(selected, cache_keys)
plugin.video.jacktook/lib/gui/source_pack_select.py +6 -8
……@@ -1,7 +1,8 @@
1from typing import Optional, Dict, Any
1from typing import Any, Dict, Optional
2
3from lib.domain.torrent import TorrentStream
24from lib.gui.source_pack_window import SourcePackWindow
35from lib.utils.general.utils import DebridType
4from lib.domain.torrent import TorrentStream
56from lib.utils.player.utils import resolve_playback_url
67
78
……@@ -14,9 +15,7 @@ class SourcePackSelect(SourcePackWindow):
1415 pack_info: Optional[Dict[str, Any]] = None,
1516 item_information: Optional[Dict[str, Any]] = None,
1617 ) -> None:
17 super().__init__(
18 xml_file, location, pack_info=pack_info, item_information=item_information
19 )
18 super().__init__(xml_file, location, pack_info=pack_info, item_information=item_information)
2019 self.source: Optional[TorrentStream] = source
2120 self.pack_info: Optional[Dict[str, Any]] = pack_info
2221 self.position: int = -1
……@@ -30,9 +29,8 @@ class SourcePackSelect(SourcePackWindow):
3029
3130 def handle_action(self, action_id: int, control_id: Optional[int] = None) -> None:
3231 self.position = self.display_list.getSelectedPosition()
33 if action_id == 7:
34 if control_id == 1000:
35 self._resolve_item()
32 if action_id == 7 and control_id == 1000:
33 self._resolve_item()
3634
3735 def _resolve_item(self) -> None:
3836 self.setProperty("resolving", "true")
plugin.video.jacktook/lib/gui/source_pack_window.py +3 -1
……@@ -1,12 +1,14 @@
11import abc
2
23import xbmcgui
4
35from lib.gui.base_window import BaseWindow
46
57
68class SourcePackWindow(BaseWindow):
79 def __init__(self, xml_file, location, source=None, pack_info=None, item_information=None):
810 super().__init__(xml_file, location, item_information=item_information)
9 self.pack_info = pack_info or {"files": []}
11 self.pack_info = pack_info or {"files": []}
1012 self.source = source or type("EmptySource", (), {"type": "", "quality": ""})()
1113 self.display_list = None
1214
plugin.video.jacktook/lib/gui/source_select.py +606 -590
……@@ -1,332 +1,338 @@
1from typing import List, Optional, Dict
2from lib.domain.torrent import TorrentStream
3from lib.gui.filter_type_window import FilterTypeWindow
4from lib.gui.filter_items_window import FilterWindow
5from lib.gui.base_window import BaseWindow
6from lib.gui.resolver_window import ResolverWindow
7from lib.gui.resume_window import ResumeDialog
8from lib.utils.debrid.debrid_utils import (
9 add_source_to_debrid,
10 get_torrent_data_from_uri,
11 get_source_status,
12)
13from lib.utils.kodi.utils import (
14 action_url_run,
15 bytes_to_human_readable,
16 ADDON_PATH,
17 get_setting,
18 kodilog,
19 notification,
20 translatePath,
21 translation,
22)
1import contextlib
2import threading
3from typing import Dict, List, Optional
4
5import xbmc
6import xbmcgui
7
8from lib.clients.stremio.constants import STREMIO_ADDON_ALIASES_KEY
9from lib.db.cached import cache
10from lib.domain.torrent import TorrentStream
11from lib.gui.base_window import BaseWindow
12from lib.gui.filter_items_window import FilterWindow
13from lib.gui.filter_type_window import FilterTypeWindow
14from lib.gui.qr_progress_dialog import QRProgressDialog
15from lib.gui.resolver_window import ResolverWindow
16from lib.gui.resume_window import ResumeDialog
17
18# For web subtitle upload
19from lib.services.subtitle_server import SubtitleUploadServer, get_local_ip
20from lib.utils.debrid.debrid_utils import (
21 add_source_to_debrid,
22 get_source_status,
23 get_torrent_data_from_uri,
24)
25from lib.utils.debrid.qrcode_utils import make_qrcode
2326from lib.utils.general.utils import (
2427 IndexerType,
2528 extract_publish_date,
26 get_info_hash_from_magnet,
29 format_season_episode,
2730 get_colored_languages,
31 get_info_hash_from_magnet,
2832 get_provider_color,
2933 get_random_color,
30 format_season_episode,
31 safe_json_loads,
3234 normalize_tv_data,
35 safe_json_loads,
3336)
34from lib.utils.parsers.title_parser import parse_title_info
35from lib.utils.torrent.torrserver_utils import add_source_to_torrserver
36
37import xbmcgui
38import xbmc
39import threading
40
41# For web subtitle upload
42from lib.services.subtitle_server import SubtitleUploadServer, get_local_ip
43from lib.utils.debrid.qrcode_utils import make_qrcode
44from lib.gui.qr_progress_dialog import QRProgressDialog
45
46THEMES = {
47 "0": {"card_bg": "FF362e33", "card_focus": "992A3E5C", "card_accent": "FF00559D"},
48 "1": {"card_bg": "FF000000", "card_focus": "FF1A1A1A", "card_accent": "FF333333"},
49 "2": {"card_bg": "FF1A0B2E", "card_focus": "994D004D", "card_accent": "FF00F3FF"},
50 "3": {"card_bg": "FF1B261B", "card_focus": "992D402D", "card_accent": "FF7CFC00"},
51 "4": {"card_bg": "FF141414", "card_focus": "992B2510", "card_accent": "FFD4AF37"},
52}
53
54
55class SourceSelect(BaseWindow):
56 def __init__(
57 self,
58 xml_file: str,
59 location: str,
60 item_information: Optional[Dict] = None,
61 sources: Optional[List[TorrentStream]] = None,
62 uncached: Optional[List[TorrentStream]] = None,
63 ):
64 super().__init__(xml_file, location, item_information=item_information)
65 self.uncached_sources: List[TorrentStream] = uncached or []
66 self.position: int = -1
67 self.sources: List[TorrentStream] = sources or []
68 self.list_sources: List[TorrentStream] = []
69 self.item_information: Dict = item_information or {}
70 self.playback_info: Optional[Dict] = None
71 self.resume: Optional[bool] = None
72 self.CACHE_KEY: str = str(self.item_information.get("tv_data", "")) or str(
73 self.item_information.get("ids", "")
74 )
75 self.setProperty("instant_close", "false")
76 self.setProperty("resolving", "false")
77 self.filtered_sources: Optional[List[TorrentStream]] = None
78 self.filter_applied: bool = False
79 self.resolved = False
80
81 def onInit(self) -> None:
82 theme_index = get_setting("source_select_theme", "0")
83 theme = THEMES.get(str(theme_index), THEMES["0"])
84 self.setProperty("style.card_bg", theme["card_bg"])
85 self.setProperty("style.card_focus", theme["card_focus"])
86 self.setProperty("style.card_accent", theme["card_accent"])
87
88 self.display_list: xbmcgui.ControlList = self.getControlList(1000)
89 self.populate_qualities_header()
90 self.populate_sources_list()
91 self.set_default_focus(self.display_list, 1000, control_list_reset=True)
92 super().onInit()
93
94 def populate_qualities_header(self):
95 from collections import Counter
96
97 fixed_qualities = [
98 ("[B][COLOR yellow]4k[/COLOR][/B]", "4k"),
99 ("[B][COLOR blue]1080p[/COLOR][/B]", "1080p"),
100 ("[B][COLOR orange]720p[/COLOR][/B]", "720p"),
101 ("[B][COLOR yellow]N/A[/COLOR][/B]", "N/A"),
102 ]
103
104 qualities = [s.quality for s in self.sources if s.quality]
105 counts = Counter(qualities)
106 qualities_list = self.getControl(1300)
107 qualities_list.reset()
108
109 # Set fixed width for 4 items
110 self.setProperty("quality_item_width", str(int(800 / 4)))
111
112 for display, key in fixed_qualities:
113 count = counts.get(display, 0)
114 label = f"{display} ({count})"
115 list_item = xbmcgui.ListItem(label)
116 list_item.setProperty("quality", key)
117 qualities_list.addItem(list_item)
118
119 def doModal(self) -> bool:
120 super().doModal()
121 return self.resolved
122
123 def handle_action(self, action_id: int, control_id: Optional[int] = None) -> None:
124 if action_id == 1 and control_id == 1000:
125 self._handle_filter_action()
126 elif action_id == 117: # Context menu action
127 self._handle_context_menu_action()
128 elif control_id == 1300:
129 self._handle_quality_select_action()
130 elif action_id == 7 and control_id == 1000: # Select action
131 self._handle_select_action(control_id)
132
133 def _handle_filter_action(self):
134 filter_type_popup = FilterTypeWindow("filter_type.xml", ADDON_PATH)
135 filter_type_popup.doModal()
136 selected_type = filter_type_popup.selected_type
137 del filter_type_popup
138
139 def get_unique(attr):
140 return sorted(
141 set(getattr(s, attr) for s in self.sources if getattr(s, attr))
142 )
143
144 filter_map = {
145 "quality": {
146 "items": lambda: get_unique("quality"),
147 "filter": lambda val: [s for s in self.sources if s.quality == val],
148 },
149 "provider": {
150 "items": lambda: get_unique("provider"),
151 "filter": lambda val: [s for s in self.sources if s.provider == val],
152 },
153 "type": {
154 "items": lambda: get_unique("type"),
155 "filter": lambda val: [s for s in self.sources if s.type == val],
156 },
157 "indexer": {
158 "items": lambda: get_unique("indexer"),
159 "filter": lambda val: [s for s in self.sources if s.indexer == val],
160 },
161 "language": {
162 "items": self._get_all_languages,
163 "filter": lambda val: [
164 s
165 for s in self.sources
166 if val in getattr(s, "languages", [])
167 or val in getattr(s, "fullLanguages", [])
168 ],
169 },
170 }
171
172 one_click_filters = {
173 "only_torrents": lambda: [s for s in self.sources if s.type == "Torrent"],
174 "only_debrid": lambda: [s for s in self.sources if s.type == "Debrid"],
175 }
176
177 if selected_type in one_click_filters:
178 filtered_sources = one_click_filters[selected_type]()
179 if not filtered_sources:
180 kodilog("No sources found matching the filter")
181 return
182 self.filtered_sources = filtered_sources
183 self.filter_applied = True
184 elif selected_type in filter_map:
185 items = filter_map[selected_type]["items"]()
186 if selected_type == "language" and not items:
187 notification(translation(90406))
188 return
189 popup = FilterWindow("filter_items.xml", ADDON_PATH, filter=items)
190 popup.doModal()
191 selected_filter = popup.selected_filter
192 del popup
193 if selected_filter is not None:
194 filtered_sources = filter_map[selected_type]["filter"](selected_filter)
195 if not filtered_sources:
196 kodilog("No sources found matching the filter")
197 return
198 self.filtered_sources = filtered_sources
199 self.filter_applied = True
200 else:
201 self.filtered_sources = None
202 self.filter_applied = False
203 else:
204 self.filtered_sources = None
205 self.filter_applied = False
206
207 self.populate_sources_list()
208 self.set_default_focus(self.display_list, 1000, control_list_reset=True)
209
210 def _handle_context_menu_action(self):
211 self.position = self.display_list.getSelectedPosition()
212 selected_source = self.list_sources[self.position]
213
214 menu_items = []
215 menu_actions = []
216
217 if selected_source.type == IndexerType.TORRENT:
218 menu_items.extend(
219 [
220 translation(90365),
221 translation(90359),
222 translation(90083),
223 translation(90744),
224 ]
225 )
226 menu_actions.extend(
227 [
228 "download_to_debrid",
229 "add_to_torrserver",
230 "download_file",
231 "upload_subtitle",
232 ]
233 )
234 elif selected_source.type in (IndexerType.DIRECT, IndexerType.STREMIO_DEBRID):
235 menu_items.extend(
236 [translation(90083), translation(90082), translation(90744)]
237 )
238 menu_actions.extend(
239 ["download_file", "subtitle_download", "upload_subtitle"]
240 )
241 else:
242 menu_items.extend(
243 [
244 translation(90084),
245 translation(90083),
246 translation(90082),
247 translation(90744),
248 ]
249 )
250 menu_actions.extend(
251 [
252 "pack_select",
253 "download_file",
254 "subtitle_download",
255 "upload_subtitle",
256 ]
257 )
258
259 response = xbmcgui.Dialog().contextmenu(menu_items)
260 if response < 0 or response >= len(menu_actions):
261 return
262
263 action = menu_actions[response]
264 kodilog(
265 f"SourceSelect context action selected: action={action}, mode={self.item_information.get('mode')}, query={self.item_information.get('query')}, year={self.item_information.get('year')}"
266 )
267 if action == "download_to_debrid":
268 try:
269 self._download_to_debrid(selected_source)
270 except Exception as e:
271 kodilog(f"SourceSelect _download_to_debrid raised: {e}")
272 notification(str(e))
273 elif action == "add_to_torrserver":
274 self._add_to_torrserver(selected_source)
275 elif action == "download_file":
276 self._download_file(selected_source)
277 elif action == "subtitle_download":
278 self._resolve_item(selected_source, is_subtitle_download=True)
279 elif action == "pack_select":
280 self._resolve_item(selected_source, pack_select=True)
281 elif action == "upload_subtitle":
282 self._upload_subtitle(selected_source)
283
284 def _handle_quality_select_action(self):
285 quality_list = self.getControl(1300)
286 selected_item = quality_list.getSelectedItem()
287 if selected_item:
288 selected_quality = selected_item.getProperty("quality")
289 filtered_sources = [
290 s for s in self.sources if selected_quality in s.quality
291 ]
292 if not filtered_sources:
293 kodilog("No sources found matching the filter")
294 return
295 self.filtered_sources = filtered_sources
296 self.filter_applied = True
297 self.populate_sources_list()
298
299 def _handle_select_action(self, control_id):
300 self.position = self.display_list.getSelectedPosition()
301 selected_source = self.list_sources[self.position]
302
303 control_list = self.getControl(control_id)
304 self.set_cached_focus(control_id, control_list.getSelectedPosition())
305 self._resolve_item(selected_source, pack_select=False)
306
307 def populate_sources_list(self) -> None:
308 self.display_list.reset()
309 self.list_sources = (
310 self.filtered_sources
311 if self.filter_applied and self.filtered_sources is not None
312 else self.sources
313 )
314
315 for source in self.list_sources:
316 info = parse_title_info(source.title)
317 menu_item = xbmcgui.ListItem(label=source.title)
318 menu_item.setProperty("title", source.title)
319 menu_item.setProperty("display_title", info["clean_title"])
320 menu_item.setProperty("codec", info["codec"])
321 menu_item.setProperty("audio", info["audio"])
322 menu_item.setProperty("hdr_info", info["badges"])
323 menu_item.setProperty("release_group", info["release_group"])
324 kodilog(f"SourceSelect populate_sources_list: source.type={source.type}")
325 if source.type in IndexerType.TORRENT:
326 if source.addedToDebrid and source.debridType:
327 provider_name = source.debridType
328 else:
329 provider_name = source.type or source.subindexer
37from lib.utils.kodi.utils import (
38 ADDON_PATH,
39 action_url_run,
40 bytes_to_human_readable,
41 get_setting,
42 kodilog,
43 notification,
44 translation,
45)
46from lib.utils.parsers.title_parser import parse_title_info
47from lib.utils.torrent.torrserver_utils import add_source_to_torrserver
48
49THEMES = {
50 "0": {"card_bg": "FF362e33", "card_focus": "992A3E5C", "card_accent": "FF00559D"},
51 "1": {"card_bg": "FF000000", "card_focus": "FF1A1A1A", "card_accent": "FF333333"},
52 "2": {"card_bg": "FF1A0B2E", "card_focus": "994D004D", "card_accent": "FF00F3FF"},
53 "3": {"card_bg": "FF1B261B", "card_focus": "992D402D", "card_accent": "FF7CFC00"},
54 "4": {"card_bg": "FF141414", "card_focus": "992B2510", "card_accent": "FFD4AF37"},
55}
56
57
58def _get_source_indexer_label(source: TorrentStream, aliases: Dict) -> str:
59 current_alias = aliases.get(source.addonKey) if isinstance(aliases, dict) else ""
60 if current_alias:
61 if source.addonInstanceLabel and source.addonName:
62 return source.addonInstanceLabel.replace(source.addonName, current_alias, 1)
63 return current_alias
64 return source.addonInstanceLabel or source.indexer
65
66
67class SourceSelect(BaseWindow):
68 def __init__(
69 self,
70 xml_file: str,
71 location: str,
72 item_information: Optional[Dict] = None,
73 sources: Optional[List[TorrentStream]] = None,
74 uncached: Optional[List[TorrentStream]] = None,
75 ):
76 super().__init__(xml_file, location, item_information=item_information)
77 self.uncached_sources: List[TorrentStream] = uncached or []
78 self.position: int = -1
79 self.sources: List[TorrentStream] = sources or []
80 self.list_sources: List[TorrentStream] = []
81 self.item_information: Dict = item_information or {}
82 self.playback_info: Optional[Dict] = None
83 self.resume: Optional[bool] = None
84 self.CACHE_KEY: str = str(self.item_information.get("tv_data", "")) or str(
85 self.item_information.get("ids", "")
86 )
87 self.setProperty("instant_close", "false")
88 self.setProperty("resolving", "false")
89 self.filtered_sources: Optional[List[TorrentStream]] = None
90 self.filter_applied: bool = False
91 self.resolved = False
92
93 def onInit(self) -> None:
94 theme_index = get_setting("source_select_theme", "0")
95 theme = THEMES.get(str(theme_index), THEMES["0"])
96 self.setProperty("style.card_bg", theme["card_bg"])
97 self.setProperty("style.card_focus", theme["card_focus"])
98 self.setProperty("style.card_accent", theme["card_accent"])
99
100 self.display_list: xbmcgui.ControlList = self.getControlList(1000)
101 self.populate_qualities_header()
102 self.populate_sources_list()
103 self.set_default_focus(self.display_list, 1000, control_list_reset=True)
104 super().onInit()
105
106 def populate_qualities_header(self):
107 from collections import Counter
108
109 fixed_qualities = [
110 ("[B][COLOR yellow]4k[/COLOR][/B]", "4k"),
111 ("[B][COLOR blue]1080p[/COLOR][/B]", "1080p"),
112 ("[B][COLOR orange]720p[/COLOR][/B]", "720p"),
113 ("[B][COLOR yellow]N/A[/COLOR][/B]", "N/A"),
114 ]
115
116 qualities = [s.quality for s in self.sources if s.quality]
117 counts = Counter(qualities)
118 qualities_list = self.getControl(1300)
119 qualities_list.reset()
120
121 # Set fixed width for 4 items
122 self.setProperty("quality_item_width", str(int(800 / 4)))
123
124 for display, key in fixed_qualities:
125 count = counts.get(display, 0)
126 label = f"{display} ({count})"
127 list_item = xbmcgui.ListItem(label)
128 list_item.setProperty("quality", key)
129 qualities_list.addItem(list_item)
130
131 def doModal(self) -> bool:
132 super().doModal()
133 return self.resolved
134
135 def handle_action(self, action_id: int, control_id: Optional[int] = None) -> None:
136 if action_id == 1 and control_id == 1000:
137 self._handle_filter_action()
138 elif action_id == 117: # Context menu action
139 self._handle_context_menu_action()
140 elif control_id == 1300:
141 self._handle_quality_select_action()
142 elif action_id == 7 and control_id == 1000: # Select action
143 self._handle_select_action(control_id)
144
145 def _handle_filter_action(self):
146 filter_type_popup = FilterTypeWindow("filter_type.xml", ADDON_PATH)
147 filter_type_popup.doModal()
148 selected_type = filter_type_popup.selected_type
149 del filter_type_popup
150
151 def get_unique(attr):
152 return sorted(set(getattr(s, attr) for s in self.sources if getattr(s, attr)))
153
154 filter_map = {
155 "quality": {
156 "items": lambda: get_unique("quality"),
157 "filter": lambda val: [s for s in self.sources if s.quality == val],
158 },
159 "provider": {
160 "items": lambda: get_unique("provider"),
161 "filter": lambda val: [s for s in self.sources if s.provider == val],
162 },
163 "type": {
164 "items": lambda: get_unique("type"),
165 "filter": lambda val: [s for s in self.sources if s.type == val],
166 },
167 "indexer": {
168 "items": lambda: get_unique("indexer"),
169 "filter": lambda val: [s for s in self.sources if s.indexer == val],
170 },
171 "language": {
172 "items": self._get_all_languages,
173 "filter": lambda val: [
174 s
175 for s in self.sources
176 if val in getattr(s, "languages", []) or val in getattr(s, "fullLanguages", [])
177 ],
178 },
179 }
180
181 one_click_filters = {
182 "only_torrents": lambda: [s for s in self.sources if s.type == "Torrent"],
183 "only_debrid": lambda: [s for s in self.sources if s.type == "Debrid"],
184 }
185
186 if selected_type in one_click_filters:
187 filtered_sources = one_click_filters[selected_type]()
188 if not filtered_sources:
189 kodilog("No sources found matching the filter")
190 return
191 self.filtered_sources = filtered_sources
192 self.filter_applied = True
193 elif selected_type in filter_map:
194 items = filter_map[selected_type]["items"]()
195 if selected_type == "language" and not items:
196 notification(translation(90406))
197 return
198 popup = FilterWindow("filter_items.xml", ADDON_PATH, filter=items)
199 popup.doModal()
200 selected_filter = popup.selected_filter
201 del popup
202 if selected_filter is not None:
203 filtered_sources = filter_map[selected_type]["filter"](selected_filter)
204 if not filtered_sources:
205 kodilog("No sources found matching the filter")
206 return
207 self.filtered_sources = filtered_sources
208 self.filter_applied = True
209 else:
210 self.filtered_sources = None
211 self.filter_applied = False
212 else:
213 self.filtered_sources = None
214 self.filter_applied = False
215
216 self.populate_sources_list()
217 self.set_default_focus(self.display_list, 1000, control_list_reset=True)
218
219 def _handle_context_menu_action(self):
220 self.position = self.display_list.getSelectedPosition()
221 selected_source = self.list_sources[self.position]
222
223 menu_items = []
224 menu_actions = []
225
226 if selected_source.type == IndexerType.TORRENT:
227 menu_items.extend(
228 [
229 translation(90365),
230 translation(90359),
231 translation(90082),
232 translation(90744),
233 ]
234 )
235 menu_actions.extend(
236 [
237 "download_to_debrid",
238 "add_to_torrserver",
239 "subtitle_download",
240 "upload_subtitle",
241 ]
242 )
243 elif selected_source.type in (IndexerType.DIRECT, IndexerType.STREMIO_DEBRID):
244 menu_items.extend([translation(90083), translation(90082), translation(90744)])
245 menu_actions.extend(["download_file", "subtitle_download", "upload_subtitle"])
246 else:
247 menu_items.extend(
248 [
249 translation(90084),
250 translation(90083),
251 translation(90082),
252 translation(90744),
253 ]
254 )
255 menu_actions.extend(
256 [
257 "pack_select",
258 "download_file",
259 "subtitle_download",
260 "upload_subtitle",
261 ]
262 )
263
264 response = xbmcgui.Dialog().contextmenu(menu_items)
265 if response < 0 or response >= len(menu_actions):
266 return
267
268 action = menu_actions[response]
269 kodilog(
270 f"SourceSelect context action selected: action={action}, mode={self.item_information.get('mode')}, query={self.item_information.get('query')}, year={self.item_information.get('year')}"
271 )
272 if action == "download_to_debrid":
273 try:
274 self._download_to_debrid(selected_source)
275 except Exception as e:
276 kodilog(f"SourceSelect _download_to_debrid raised: {e}")
277 notification(str(e))
278 elif action == "add_to_torrserver":
279 self._add_to_torrserver(selected_source)
280 elif action == "download_file":
281 self._download_file(selected_source)
282 elif action == "subtitle_download":
283 self._resolve_item(selected_source, is_subtitle_download=True)
284 elif action == "pack_select":
285 self._resolve_item(selected_source, pack_select=True)
286 elif action == "upload_subtitle":
287 self._upload_subtitle(selected_source)
288
289 def _handle_quality_select_action(self):
290 quality_list = self.getControl(1300)
291 selected_item = quality_list.getSelectedItem()
292 if selected_item:
293 selected_quality = selected_item.getProperty("quality")
294 filtered_sources = [s for s in self.sources if selected_quality in s.quality]
295 if not filtered_sources:
296 kodilog("No sources found matching the filter")
297 return
298 self.filtered_sources = filtered_sources
299 self.filter_applied = True
300 self.populate_sources_list()
301
302 def _handle_select_action(self, control_id):
303 self.position = self.display_list.getSelectedPosition()
304 selected_source = self.list_sources[self.position]
305
306 control_list = self.getControl(control_id)
307 self.set_cached_focus(control_id, control_list.getSelectedPosition())
308 self._resolve_item(selected_source, pack_select=False)
309
310 def populate_sources_list(self) -> None:
311 self.display_list.reset()
312 self.list_sources = (
313 self.filtered_sources
314 if self.filter_applied and self.filtered_sources is not None
315 else self.sources
316 )
317 aliases = {}
318 with contextlib.suppress(Exception):
319 aliases = cache.get(STREMIO_ADDON_ALIASES_KEY) or {}
320
321 for source in self.list_sources:
322 info = parse_title_info(source.title)
323 menu_item = xbmcgui.ListItem(label=source.title)
324 menu_item.setProperty("title", source.title)
325 menu_item.setProperty("display_title", info["clean_title"])
326 menu_item.setProperty("codec", info["codec"])
327 menu_item.setProperty("audio", info["audio"])
328 menu_item.setProperty("hdr_info", info["badges"])
329 menu_item.setProperty("release_group", info["release_group"])
330 kodilog(f"SourceSelect populate_sources_list: source.type={source.type}")
331 if source.type in IndexerType.TORRENT:
332 if source.addedToDebrid and source.debridType:
333 provider_name = source.debridType
334 else:
335 provider_name = source.type or source.subindexer
330336 elif source.type == IndexerType.DEBRID:
331337 provider_name = source.debridType or source.type
332338 elif source.type == IndexerType.STREMIO_DEBRID:
……@@ -340,108 +346,113 @@ class SourceSelect(BaseWindow):
340346 provider_name = source.indexer or source.type
341347 else:
342348 provider_name = source.debridType or source.type
343 indexer_label = source.addonInstanceLabel or source.indexer
349 indexer_label = _get_source_indexer_label(source, aliases)
344350 menu_item.setProperty("type", get_provider_color(provider_name))
345 menu_item.setProperty("indexer", get_random_color(indexer_label))
346 menu_item.setProperty("guid", source.guid)
347 menu_item.setProperty("infoHash", source.infoHash)
348 menu_item.setProperty("size", bytes_to_human_readable(int(source.size)))
349 if source.seeders and not source.isCached:
350 menu_item.setProperty("seeders", str(source.seeders))
351 if source.peers and not source.isCached:
352 menu_item.setProperty("peers", str(source.peers))
353 menu_item.setProperty(
354 "fullLanguages", get_colored_languages(source.fullLanguages)
355 )
356 menu_item.setProperty("provider", get_random_color(source.provider))
357 menu_item.setProperty(
358 "publishDate", extract_publish_date(source.publishDate)
359 )
360 menu_item.setProperty("quality", source.quality)
361 menu_item.setProperty("status", get_source_status(source))
362 menu_item.setProperty("isPack", str(source.isPack))
363
364 self.display_list.addItem(menu_item)
365
366 def _refresh_sources_list(self) -> None:
367 if not hasattr(self, "display_list"):
368 return
369
370 current_position = max(getattr(self, "position", 0), 0)
371 self.populate_sources_list()
372 if self.list_sources:
373 self.set_default_focus(self.display_list, 1000, control_list_reset=True)
374 self.display_list.selectItem(
375 min(current_position, len(self.list_sources) - 1)
376 )
377
378 def _download_to_debrid(self, selected_source) -> None:
379 kodilog("SourceSelect _download_to_debrid entered")
380 url, magnet, _ = self._extract_source_details(selected_source)
381 info_hash = selected_source.infoHash or ""
382 torrent_data = b""
383
384 if not info_hash and magnet:
385 info_hash = get_info_hash_from_magnet(magnet).lower()
386
387 if not info_hash and url.startswith("magnet:?"):
388 info_hash = get_info_hash_from_magnet(url).lower()
389
390 if url:
391 kodilog(f"Fetching torrent URL to extract info_hash: {url}")
392 torrent_data, magnet_candidate, extracted_hash, torrent_url = (
393 get_torrent_data_from_uri(url)
394 )
395 if magnet_candidate and not magnet:
396 magnet = magnet_candidate
397 if extracted_hash and not info_hash:
398 info_hash = extracted_hash
399 if torrent_url:
400 selected_source.url = torrent_url
401
402 if not info_hash and not torrent_data:
403 notification(translation(90361))
404 return
405
406 debrid_type = add_source_to_debrid(
407 info_hash,
408 selected_source.debridType,
409 torrent_data=torrent_data,
410 torrent_name=selected_source.title
411 or self.item_information.get("title", ""),
412 )
413 if debrid_type:
414 kodilog(f"SourceSelect download_to_debrid succeeded via {debrid_type}")
415 if info_hash:
416 selected_source.infoHash = info_hash
417 selected_source.debridType = debrid_type
418 selected_source.addedToDebrid = True
419 self._refresh_sources_list()
420
421 def _add_to_torrserver(self, selected_source) -> None:
422 url, magnet, _ = self._extract_source_details(selected_source)
423 title = selected_source.title or self.item_information.get("title", "")
424 poster = self.item_information.get("poster", "")
425
426 add_source_to_torrserver(
427 magnet=magnet,
428 url=url,
429 info_hash=selected_source.infoHash or "",
430 title=title,
431 poster=poster,
432 )
433
351 menu_item.setProperty("indexer", get_random_color(indexer_label))
352 menu_item.setProperty("guid", source.guid)
353 menu_item.setProperty("infoHash", source.infoHash)
354 menu_item.setProperty("size", bytes_to_human_readable(int(source.size)))
355 if source.seeders and not source.isCached:
356 menu_item.setProperty("seeders", str(source.seeders))
357 if source.peers and not source.isCached:
358 menu_item.setProperty("peers", str(source.peers))
359 menu_item.setProperty("fullLanguages", get_colored_languages(source.fullLanguages))
360 menu_item.setProperty("provider", get_random_color(source.provider))
361 menu_item.setProperty("publishDate", extract_publish_date(source.publishDate))
362 menu_item.setProperty("quality", source.quality)
363 menu_item.setProperty("status", get_source_status(source))
364 menu_item.setProperty("isPack", str(source.isPack))
365
366 self.display_list.addItem(menu_item)
367
368 def _refresh_sources_list(self) -> None:
369 if not hasattr(self, "display_list"):
370 return
371
372 current_position = max(getattr(self, "position", 0), 0)
373 self.populate_sources_list()
374 if self.list_sources:
375 self.set_default_focus(self.display_list, 1000, control_list_reset=True)
376 self.display_list.selectItem(min(current_position, len(self.list_sources) - 1))
377
378 def _download_to_debrid(self, selected_source) -> None:
379 kodilog("SourceSelect _download_to_debrid entered")
380 url, magnet, _ = self._extract_source_details(selected_source)
381 info_hash = selected_source.infoHash or ""
382 torrent_data = b""
383
384 if not info_hash and magnet:
385 info_hash = get_info_hash_from_magnet(magnet).lower()
386
387 if not info_hash and url.startswith("magnet:"):
388 info_hash = get_info_hash_from_magnet(url).lower()
389
390 if url:
391 kodilog(f"Fetching torrent URL to extract info_hash: {url}")
392 torrent_data, magnet_candidate, extracted_hash, torrent_url = get_torrent_data_from_uri(
393 url
394 )
395 if magnet_candidate and not magnet:
396 magnet = magnet_candidate
397 if extracted_hash and not info_hash:
398 info_hash = extracted_hash
399 if torrent_url:
400 selected_source.url = torrent_url
401
402 if not info_hash and not torrent_data:
403 notification(translation(90361))
404 return
405
406 debrid_type = add_source_to_debrid(
407 info_hash,
408 selected_source.debridType,
409 torrent_data=torrent_data,
410 torrent_name=selected_source.title or self.item_information.get("title", ""),
411 )
412 if debrid_type:
413 kodilog(f"SourceSelect download_to_debrid succeeded via {debrid_type}")
414 if info_hash:
415 selected_source.infoHash = info_hash
416 selected_source.debridType = debrid_type
417 selected_source.addedToDebrid = True
418 self._refresh_sources_list()
419
420 def _add_to_torrserver(self, selected_source) -> None:
421 url, magnet, _ = self._extract_source_details(selected_source)
422 title = selected_source.title or self.item_information.get("title", "")
423 poster = self.item_information.get("poster", "")
424
425 import json as _json
426
427 metadata = {
428 "title": title,
429 "mode": self.item_information.get("mode", ""),
430 "ids": self.item_information.get("ids", {}),
431 "tv_data": self.item_information.get("tv_data", {}),
432 }
433 data = _json.dumps(metadata)
434
435 add_source_to_torrserver(
436 magnet=magnet,
437 url=url,
438 info_hash=selected_source.infoHash or "",
439 title=title,
440 poster=poster,
441 data=data,
442 )
443
434444 def _download_file(self, selected_source) -> None:
435445 self.playback_info = self._ensure_playback_info(selected_source)
436446 if self.playback_info:
437447 self.playback_info.update(self.item_information)
438448 else:
439 notification(translation(90407))
440 return
449 notification(translation(90407))
450 return
441451
442 download_dir = get_setting("download_dir")
443452 try:
444 title = (self.playback_info.get("title") or self.item_information.get("title") or "").strip()
453 title = (
454 self.playback_info.get("title") or self.item_information.get("title") or ""
455 ).strip()
445456 year = self.item_information.get("year", "")
446457 tv_data = self.playback_info.get("tv_data")
447458 if isinstance(tv_data, str):
……@@ -456,185 +467,190 @@ class SourceSelect(BaseWindow):
456467 else:
457468 file_name = title
458469
470 mode = self.playback_info.get("mode", "movie")
471 dest_data = {
472 "title": title,
473 "mode": "movies" if mode in ("movie", "movies") else mode,
474 }
475 if tv_data:
476 dest_data["tv_data"] = tv_data
477
478 from lib.downloader import get_destination_path
479
480 destination = get_destination_path(dest_data)
481 if not destination:
482 notification(translation(90407))
483 return
484
459485 xbmc.executebuiltin(
460486 action_url_run(
461487 "handle_download_file",
462488 file_name=file_name,
463489 url=self.playback_info["url"],
464 destination=translatePath(download_dir),
490 destination=destination,
465491 )
466492 )
467 except Exception as e:
468 kodilog(f"Failed to start download: {str(e)}")
469 xbmcgui.Dialog().notification(
470 translation(90653), translation(90654) % str(e)
471 )
472
473 def _upload_subtitle(self, selected_source) -> None:
474 """Show upload subtitle dialog with local/web options."""
475 kodilog("[UPLOAD_SUBTITLE] Starting subtitle upload process")
476 dialog = xbmcgui.Dialog()
477 options = [
478 translation(90746), # Local File
479 translation(90747), # Upload from Browser
480 ]
481 choice = dialog.select(translation(90744), options) # Upload Subtitle
482
483 if choice == 0: # Local file
484 self._upload_subtitle_local(selected_source)
485 elif choice == 1: # Web upload
486 self._upload_subtitle_from_web(selected_source)
487
488 def _upload_subtitle_local(self, selected_source) -> None:
489 """Open file browser to select a local subtitle file and resolve the source with it."""
490 dialog = xbmcgui.Dialog()
491 subtitle_file = dialog.browse(
492 1, translation(90745), "files", ".srt|.ass|.ssa|.sub|.vtt|.txt"
493 )
494 if not subtitle_file:
495 kodilog(
496 "[UPLOAD_SUBTITLE] User cancelled subtitle upload (no file selected)"
497 )
498 return
499 self._resolve_item(selected_source, local_subtitle_path=subtitle_file)
500
501 def _upload_subtitle_from_web(self, selected_source) -> None:
502 """Start web server and show QR dialog for browser-based subtitle upload."""
503 kodilog("[UPLOAD_SUBTITLE] Starting web upload server")
504
505 local_ip = get_local_ip()
506 server = SubtitleUploadServer(port=8082)
507 server.start()
508
509 if not server.is_running:
510 kodilog("[UPLOAD_SUBTITLE] Failed to start upload server")
511 notification(translation(90744))
512 return
513
514 url = f"http://{local_ip}:{server.port}/"
515
516 subtitle_result = [None] # Use list for mutability in nested function
517 dialog = None
518 upload_complete = threading.Event()
519
520 try:
521 kodilog(f"[UPLOAD_SUBTITLE] Server started at {url}")
522
523 # Generate QR code
524 qr_path = make_qrcode(url)
525
526 # Show QR dialog
527 dialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH)
528 dialog.setup(
529 title=translation(90748), # Upload subtitle from your browser
530 qr_code=qr_path or "",
531 url=url,
532 user_code="",
533 is_debrid=False,
534 custom_message=translation(90749) % url,
535 )
536
537 # Start polling thread to detect upload completion
538 def poll_for_upload():
539 kodilog("[UPLOAD_SUBTITLE] Polling thread started")
540 import time
541
542 poll_count = 0
543 while not upload_complete.is_set():
544 time.sleep(0.5)
545 poll_count += 1
546
547 # Check if file was uploaded
548 file_path = server.get_uploaded_file()
549 if file_path:
550 kodilog(
551 f"[UPLOAD_SUBTITLE] File detected via polling: {file_path}"
552 )
553 subtitle_result[0] = file_path
554 upload_complete.set()
555 # Close dialog from thread
556 try:
557 dialog.close()
558 except Exception as e:
559 kodilog(
560 f"[UPLOAD_SUBTITLE] Error closing dialog from thread: {e}"
561 )
562 break
563
564 # Timeout after 10 minutes
565 if poll_count > 1200:
566 kodilog("[UPLOAD_SUBTITLE] Polling timeout")
567 upload_complete.set()
568 try:
569 dialog.close()
570 except:
571 pass
572 break
573 kodilog("[UPLOAD_SUBTITLE] Polling thread ended")
574
575 # Start polling thread
576 poll_thread = threading.Thread(target=poll_for_upload, daemon=True)
577 poll_thread.start()
578
579 # Show modal dialog (blocks until closed)
580 kodilog("[UPLOAD_SUBTITLE] Showing modal dialog...")
581 dialog.doModal()
582
583 # Signal polling thread to stop
584 upload_complete.set()
585
586 # Check result
587 if subtitle_result[0]:
588 kodilog(f"[UPLOAD_SUBTITLE] Upload successful: {subtitle_result[0]}")
589 elif dialog.iscanceled:
590 kodilog("[UPLOAD_SUBTITLE] User cancelled")
591 else:
592 kodilog("[UPLOAD_SUBTITLE] Dialog closed without upload")
593
594 finally:
595 upload_complete.set()
596 server.stop()
597 kodilog("[UPLOAD_SUBTITLE] Web upload server stopped")
598
599 # If we got a file, resolve the source with it
600 if subtitle_result[0]:
601 self._resolve_item(selected_source, local_subtitle_path=subtitle_result[0])
602
603 def _resolve_item(
604 self,
605 selected_source,
606 pack_select: bool = False,
607 is_subtitle_download=False,
608 local_subtitle_path: str = None,
609 ) -> None:
610 self.setProperty("resolving", "true")
611 resolver_window = ResolverWindow(
612 "resolver.xml",
613 ADDON_PATH,
614 source=selected_source,
615 previous_window=self,
616 item_information=self.item_information,
617 is_subtitle_download=is_subtitle_download,
618 local_subtitle_path=local_subtitle_path,
619 )
620 self.resolved = resolver_window.doModal(pack_select)
621 del resolver_window
622
623 def show_resume_dialog(self, playback_percent: float) -> Optional[bool]:
624 try:
625 resume_window = ResumeDialog(
626 "resume_dialog.xml",
627 ADDON_PATH,
628 resume_percent=playback_percent,
629 )
630 resume_window.doModal()
631 return resume_window.resume
632 finally:
633 del resume_window
634
635 def _get_all_languages(self):
636 all_languages = set()
637 for s in self.sources:
638 all_languages.update(getattr(s, "languages", []))
639 all_languages.update(getattr(s, "fullLanguages", []))
640 return sorted(all_languages)
493 except Exception as e:
494 kodilog(f"Failed to start download: {e!s}")
495 xbmcgui.Dialog().notification(translation(90653), translation(90654) % str(e))
496
497 def _upload_subtitle(self, selected_source) -> None:
498 """Show upload subtitle dialog with local/web options."""
499 kodilog("[UPLOAD_SUBTITLE] Starting subtitle upload process")
500 dialog = xbmcgui.Dialog()
501 options = [
502 translation(90746), # Local File
503 translation(90747), # Upload from Browser
504 ]
505 choice = dialog.select(translation(90744), options) # Upload Subtitle
506
507 if choice == 0: # Local file
508 self._upload_subtitle_local(selected_source)
509 elif choice == 1: # Web upload
510 self._upload_subtitle_from_web(selected_source)
511
512 def _upload_subtitle_local(self, selected_source) -> None:
513 """Open file browser to select a local subtitle file and resolve the source with it."""
514 dialog = xbmcgui.Dialog()
515 subtitle_file = dialog.browse(
516 1, translation(90745), "files", ".srt|.ass|.ssa|.sub|.vtt|.txt"
517 )
518 if not subtitle_file:
519 kodilog("[UPLOAD_SUBTITLE] User cancelled subtitle upload (no file selected)")
520 return
521 self._resolve_item(selected_source, local_subtitle_path=subtitle_file)
522
523 def _upload_subtitle_from_web(self, selected_source) -> None:
524 """Start web server and show QR dialog for browser-based subtitle upload."""
525 kodilog("[UPLOAD_SUBTITLE] Starting web upload server")
526
527 local_ip = get_local_ip()
528 server = SubtitleUploadServer(port=8082)
529 server.start()
530
531 if not server.is_running:
532 kodilog("[UPLOAD_SUBTITLE] Failed to start upload server")
533 notification(translation(90744))
534 return
535
536 url = f"http://{local_ip}:{server.port}/"
537
538 subtitle_result = [None] # Use list for mutability in nested function
539 dialog = None
540 upload_complete = threading.Event()
541
542 try:
543 kodilog(f"[UPLOAD_SUBTITLE] Server started at {url}")
544
545 # Generate QR code
546 qr_path = make_qrcode(url)
547
548 # Show QR dialog
549 dialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH)
550 dialog.setup(
551 title=translation(90748), # Upload subtitle from your browser
552 qr_code=qr_path or "",
553 url=url,
554 user_code="",
555 is_debrid=False,
556 custom_message=translation(90749) % url,
557 )
558
559 # Start polling thread to detect upload completion
560 def poll_for_upload():
561 kodilog("[UPLOAD_SUBTITLE] Polling thread started")
562 import time
563
564 poll_count = 0
565 while not upload_complete.is_set():
566 time.sleep(0.5)
567 poll_count += 1
568
569 # Check if file was uploaded
570 file_path = server.get_uploaded_file()
571 if file_path:
572 kodilog(f"[UPLOAD_SUBTITLE] File detected via polling: {file_path}")
573 subtitle_result[0] = file_path
574 upload_complete.set()
575 # Close dialog from thread
576 try:
577 dialog.close()
578 except Exception as e:
579 kodilog(f"[UPLOAD_SUBTITLE] Error closing dialog from thread: {e}")
580 break
581
582 # Timeout after 10 minutes
583 if poll_count > 1200:
584 kodilog("[UPLOAD_SUBTITLE] Polling timeout")
585 upload_complete.set()
586 with contextlib.suppress(BaseException):
587 dialog.close()
588 break
589 kodilog("[UPLOAD_SUBTITLE] Polling thread ended")
590
591 # Start polling thread
592 poll_thread = threading.Thread(target=poll_for_upload, daemon=True)
593 poll_thread.start()
594
595 # Show modal dialog (blocks until closed)
596 kodilog("[UPLOAD_SUBTITLE] Showing modal dialog...")
597 dialog.doModal()
598
599 # Signal polling thread to stop
600 upload_complete.set()
601
602 # Check result
603 if subtitle_result[0]:
604 kodilog(f"[UPLOAD_SUBTITLE] Upload successful: {subtitle_result[0]}")
605 elif dialog.iscanceled:
606 kodilog("[UPLOAD_SUBTITLE] User cancelled")
607 else:
608 kodilog("[UPLOAD_SUBTITLE] Dialog closed without upload")
609
610 finally:
611 upload_complete.set()
612 server.stop()
613 kodilog("[UPLOAD_SUBTITLE] Web upload server stopped")
614
615 # If we got a file, resolve the source with it
616 if subtitle_result[0]:
617 self._resolve_item(selected_source, local_subtitle_path=subtitle_result[0])
618
619 def _resolve_item(
620 self,
621 selected_source,
622 pack_select: bool = False,
623 is_subtitle_download=False,
624 local_subtitle_path: Optional[str] = None,
625 ) -> None:
626 self.setProperty("resolving", "true")
627 resolver_window = ResolverWindow(
628 "resolver.xml",
629 ADDON_PATH,
630 source=selected_source,
631 previous_window=self,
632 item_information=self.item_information,
633 is_subtitle_download=is_subtitle_download,
634 local_subtitle_path=local_subtitle_path,
635 )
636 self.resolved = resolver_window.doModal(pack_select)
637 del resolver_window
638
639 def show_resume_dialog(self, playback_percent: float) -> Optional[bool]:
640 try:
641 resume_window = ResumeDialog(
642 "resume_dialog.xml",
643 ADDON_PATH,
644 resume_percent=playback_percent,
645 )
646 resume_window.doModal()
647 return resume_window.resume
648 finally:
649 del resume_window
650
651 def _get_all_languages(self):
652 all_languages = set()
653 for s in self.sources:
654 all_languages.update(getattr(s, "languages", []))
655 all_languages.update(getattr(s, "fullLanguages", []))
656 return sorted(all_languages)
plugin.video.jacktook/lib/jacktook/client.py +36 -22
……@@ -1,12 +1,13 @@
1from lib.clients.base import BaseClient, TorrentStream
1from typing import Any, Callable, Dict, List, Optional
2
3from ..clients.base import BaseClient, TorrentStream
4from ..utils.kodi.utils import convert_size_to_bytes, get_setting
25from .providers import (
36 burst_search,
47 burst_search_episode,
58 burst_search_movie,
69 burst_search_season,
710)
8from lib.utils.kodi.utils import convert_size_to_bytes, get_setting
9from typing import List, Optional, Dict, Any, Callable
1011
1112
1213class Burst(BaseClient):
……@@ -22,15 +23,14 @@ class Burst(BaseClient):
2223 season: Optional[int],
2324 episode: Optional[int],
2425 silent: bool = False,
26 **kwargs: Any,
2527 ) -> Optional[List[TorrentStream]]:
2628 try:
2729 if mode == "tv" or media_type == "tv":
2830 if get_setting("include_season_packs"):
2931 results = burst_search_season(tmdb_id, query, season, silent=silent)
3032 else:
31 results = burst_search_episode(
32 tmdb_id, query, season, episode, silent=silent
33 )
33 results = burst_search_episode(tmdb_id, query, season, episode, silent=silent)
3434 elif mode == "movies" or media_type == "movies":
3535 results = burst_search_movie(tmdb_id, query, silent=silent)
3636 else:
……@@ -39,25 +39,39 @@ class Burst(BaseClient):
3939 results = self.parse_response(results)
4040 return results
4141 except Exception as e:
42 self.handle_exception(f"Burst error: {str(e)}")
42 self.handle_exception(f"Burst error: {e!s}")
4343
4444 def parse_response(self, res: List[Dict[str, Any]]) -> List[TorrentStream]:
4545 results = []
4646 for r in res:
47 results.append(
48 TorrentStream(
49 title=r.title,
50 type="Torrent",
51 indexer="Burst",
52 guid=r.guid,
53 infoHash="",
54 size=convert_size_to_bytes(str(r.size)),
55 seeders=int(r.seeders),
56 peers=int(r.peers),
57 languages=[],
58 fullLanguages="",
59 provider=r.indexer,
60 publishDate="",
47 try:
48 raw_seeders = getattr(r, "seeders", None)
49 raw_peers = getattr(r, "peers", None)
50 raw_size = getattr(r, "size", "")
51
52 seeders = int(raw_seeders) if raw_seeders is not None else 0
53 peers = int(raw_peers) if raw_peers is not None else 0
54 size = convert_size_to_bytes(str(raw_size)) if raw_size is not None else 0
55
56 results.append(
57 TorrentStream(
58 title=getattr(r, "title", ""),
59 type="Torrent",
60 indexer="Burst",
61 guid=getattr(r, "guid", ""),
62 infoHash="",
63 size=size,
64 seeders=seeders,
65 peers=peers,
66 languages=[],
67 fullLanguages="",
68 provider=getattr(r, "indexer", ""),
69 publishDate="",
70 )
71 )
72 except (ValueError, TypeError) as e:
73 self.handle_exception(
74 f"Burst parse error for result {getattr(r, 'title', '?')}: {e}"
6175 )
62 )
76 continue
6377 return results
plugin.video.jacktook/lib/jacktook/listener.py +6 -8
……@@ -1,12 +1,12 @@
1from .utils import NoProvidersError
2from .provider_base import ProviderListener
3from lib.utils.kodi.utils import ADDON_NAME, translation
41from xbmcgui import DialogProgressBG
52
3from ..utils.kodi.utils import ADDON_NAME, translation
4from .provider_base import ProviderListener
5
66
77class ProviderListenerDialog(ProviderListener):
88 def __init__(self, providers, method, timeout=10, silent=False):
9 super(ProviderListenerDialog, self).__init__(providers, method, timeout=timeout)
9 super().__init__(providers, method, timeout=timeout)
1010 self._total = len(providers)
1111 self._count = 0
1212 self._silent = silent
……@@ -18,16 +18,14 @@ class ProviderListenerDialog(ProviderListener):
1818 self._dialog.update(int(100 * self._count / self._total))
1919
2020 def __enter__(self):
21 ret = super(ProviderListenerDialog, self).__enter__()
21 ret = super().__enter__()
2222 if self._dialog:
2323 self._dialog.create(ADDON_NAME, translation(90660))
2424 return ret
2525
2626 def __exit__(self, exc_type, exc_val, exc_tb):
2727 try:
28 return super(ProviderListenerDialog, self).__exit__(
29 exc_type, exc_val, exc_tb
30 )
28 return super().__exit__(exc_type, exc_val, exc_tb)
3129 finally:
3230 if self._dialog:
3331 self._dialog.close()
plugin.video.jacktook/lib/jacktook/provider_base.py +8 -16
……@@ -4,6 +4,8 @@ import sys
44import threading
55import time
66
7import xbmc
8
79from .utils import (
810 ADDON_ID,
911 bytes_to_str,
……@@ -13,10 +15,8 @@ from .utils import (
1315 run_script,
1416 str_to_bytes,
1517)
16import xbmc
1718
18
19__all__ = ["ProviderResult", "Provider"]
19__all__ = ["Provider", "ProviderResult"]
2020
2121
2222def get_providers():
……@@ -60,7 +60,7 @@ class ProviderResult(dict):
6060 set_size, size = _setter_and_getter("size")
6161
6262
63class Provider(object):
63class Provider:
6464 def __init__(self):
6565 self._methods = {}
6666 for name in dir(self):
……@@ -81,18 +81,12 @@ class Provider(object):
8181 def search_season(self, tmdb_id, show_title, season_number, titles):
8282 raise NotImplementedError("'search_season' method must be implemented")
8383
84 def search_episode(
85 self, tmdb_id, show_title, season_number, episode_number, titles
86 ):
84 def search_episode(self, tmdb_id, show_title, season_number, episode_number, titles):
8785 raise NotImplementedError("'search_episode' method must be implemented")
8886
8987 def resolve(self, provider_data):
9088 raise NotImplementedError("'resolve' method must be implemented")
9189
92 def ping(self):
93 kodilog("Ping method called")
94 return ADDON_ID
95
9690 def register(self):
9791 if len(sys.argv) != 4:
9892 kodilog("Expecting 4 arguments")
……@@ -103,9 +97,7 @@ class Provider(object):
10397 if method in self._methods:
10498 try:
10599 data = json.loads(base64.b64decode(data_b64))
106 value = self._methods[method](
107 *data.get("args", []), **data.get("kwargs", {})
108 )
100 value = self._methods[method](*data.get("args", []), **data.get("kwargs", {}))
109101 except Exception as e:
110102 kodilog(f"Failed running method '{method}': {e}")
111103 value = None
……@@ -120,9 +112,9 @@ class Provider(object):
120112
121113class ProviderListener(xbmc.Monitor):
122114 def __init__(self, providers, method, timeout=10):
123 super(ProviderListener, self).__init__()
115 super().__init__()
124116 self._waiting = {i: True for i in providers}
125 self._method = "Other.{}.{}".format(ADDON_ID, method)
117 self._method = f"Other.{ADDON_ID}.{method}"
126118 self._timeout = timeout
127119 self._data = {}
128120 self._lock = threading.Lock()
plugin.video.jacktook/lib/jacktook/providers.py +6 -10
……@@ -1,11 +1,11 @@
1from lib.jacktook.provider_base import (
1from ..utils.kodi.utils import kodilog
2from .listener import ProviderListenerDialog
3from .provider_base import (
24 ProviderResult,
35 get_providers,
46 send_to_providers,
57)
6from lib.jacktook.listener import ProviderListenerDialog
7from lib.jacktook.utils import NoProvidersError
8from lib.utils.kodi.utils import kodilog
8from .utils import NoProvidersError
99
1010
1111def burst_search(query, silent=False):
……@@ -21,9 +21,7 @@ def burst_search_show(show_id, query, silent=False):
2121
2222
2323def burst_search_season(show_id, show_title, season_number, silent=False):
24 return search(
25 "search_season", show_id, show_title, int(season_number), silent=silent
26 )
24 return search("search_season", show_id, show_title, int(season_number), silent=silent)
2725
2826
2927def burst_search_episode(show_id, query, season_number, episode_number, silent=False):
……@@ -43,9 +41,7 @@ def search(method, *args, **kwargs):
4341 if not providers:
4442 raise NoProvidersError("No providers available")
4543
46 with ProviderListenerDialog(
47 providers, method, timeout=30, silent=silent
48 ) as listener:
44 with ProviderListenerDialog(providers, method, timeout=30, silent=silent) as listener:
4945 send_to_providers(providers, method, *args, **kwargs)
5046
5147 results = []
plugin.video.jacktook/lib/jacktook/utils.py +7 -9
……@@ -1,8 +1,11 @@
11import json
2
23import xbmc
34import xbmcaddon
45
5from lib.utils.kodi.logging import kodilog
6
7def kodilog(message, level=xbmc.LOGINFO):
8 xbmc.log("[###JACKTOOKLOG###] " + str(message), level)
69
710
811ADDON = xbmcaddon.Addon()
……@@ -13,9 +16,7 @@ ADDON_NAME = ADDON.getAddonInfo("name")
1316
1417
1518def get_installed_addons(addon_type="", content="unknown", enabled="all"):
16 data = execute_json_rpc(
17 "Addons.GetAddons", type=addon_type, content=content, enabled=enabled
18 )
19 data = execute_json_rpc("Addons.GetAddons", type=addon_type, content=content, enabled=enabled)
1920 addons = data["result"].get("addons")
2021 return [(a["addonid"], a["type"]) for a in addons] if addons else []
2122
……@@ -28,20 +29,17 @@ def notify_all(sender, message, data=None):
2829
2930
3031def run_script(script_id, *args):
31 xbmc.executebuiltin("RunScript({})".format(",".join((script_id,) + args)))
32 xbmc.executebuiltin("RunScript({})".format(",".join((script_id, *args))))
3233
3334
3435def execute_json_rpc(method, rpc_version="2.0", rpc_id=1, **params):
3536 return json.loads(
3637 xbmc.executeJSONRPC(
37 json.dumps(
38 dict(jsonrpc=rpc_version, method=method, params=params, id=rpc_id)
39 )
38 json.dumps(dict(jsonrpc=rpc_version, method=method, params=params, id=rpc_id))
4039 )
4140 )
4241
4342
44
4543class ResolveTimeoutError(Exception):
4644 pass
4745
plugin.video.jacktook/lib/nav/debrid.py +163 -25
……@@ -1,32 +1,36 @@
1from datetime import timedelta
12from threading import Thread
23from typing import Any, Dict, List
3from datetime import timedelta
44
55from xbmcplugin import addDirectoryItem
66
77from lib.api.debrid.alldebrid import AllDebrid
88from lib.api.debrid.debrider import Debrider
9from lib.api.debrid.offcloud import Offcloud
910from lib.api.debrid.premiumize import Premiumize
1011from lib.api.debrid.realdebrid import RealDebrid
1112from lib.api.debrid.torbox import Torbox
1213from lib.clients.debrid.alldebrid import AllDebridHelper
1314from lib.clients.debrid.debrider import DebriderHelper
15from lib.clients.debrid.offcloud import OffcloudHelper
1416from lib.clients.debrid.realdebrid import RealDebridHelper
1517from lib.clients.debrid.torbox import TorboxHelper
18from lib.db.cached import cache
1619from lib.services.debrid.auth import (
1720 run_alldebrid_auth,
1821 run_debrider_auth,
22 run_offcloud_auth,
1923 run_premiumize_auth,
2024 run_realdebrid_auth,
2125 run_torbox_auth,
2226)
2327from lib.services.debrid.download import run_realdebrid_download
24from lib.db.cached import cache
2528from lib.utils.general.utils import (
2629 DebridType,
2730 IndexerType,
2831 build_list_item,
2932 check_debrid_enabled,
33 get_public_ip,
3034 get_random_color,
3135 set_pluging_category,
3236)
……@@ -36,9 +40,9 @@ from lib.utils.kodi.utils import (
3640 apply_section_view,
3741 build_url,
3842 end_of_directory,
39 execute_builtin,
4043 finish_action,
4144 get_setting,
45 kodilog,
4246 notification,
4347 translation,
4448)
……@@ -56,6 +60,12 @@ def _start_torbox_download(magnet):
5660 thread.start()
5761
5862
63def _start_offcloud_download(magnet):
64 oc_client = Offcloud(token=str(get_setting("offcloud_token") or ""))
65 thread = Thread(target=oc_client.add_cloud_download, args=(magnet,))
66 thread.start()
67
68
5969def _start_premiumize_download(magnet):
6070 pm_client = Premiumize(token=str(get_setting("premiumize_token")))
6171 thread = Thread(target=pm_client.download, args=(magnet,), kwargs={"pack": False})
……@@ -67,11 +77,13 @@ DEBRID_CLOUD_ACTIONS = {
6777 DebridType.DB: {"info": "debrider_info"},
6878 DebridType.AD: {"info": "alldebrid_info"},
6979 DebridType.TB: {"downloads": "get_tb_downloads", "info": "torbox_info"},
80 DebridType.OC: {"downloads": "get_oc_downloads", "info": "offcloud_info"},
7081}
7182
7283DEBRID_DOWNLOAD_HANDLERS = {
7384 "RD": _start_realdebrid_download,
7485 "TB": _start_torbox_download,
86 "OC": _start_offcloud_download,
7587 "PM": _start_premiumize_download,
7688}
7789
……@@ -80,6 +92,7 @@ DEBRID_INFO_HANDLERS = {
8092 DebridType.AD: lambda: AllDebridHelper().get_info(),
8193 DebridType.DB: lambda: DebriderHelper().get_info(),
8294 DebridType.TB: lambda: TorboxHelper().get_info(),
95 DebridType.OC: lambda: OffcloudHelper().get_info(),
8396}
8497
8598
……@@ -119,17 +132,13 @@ def cloud_details(params):
119132
120133def cloud(params):
121134 set_pluging_category(translation(90014))
122 activated_debrids = [
123 debrid for debrid in DebridType.values() if check_debrid_enabled(debrid)
124 ]
135 activated_debrids = [debrid for debrid in DebridType.values() if check_debrid_enabled(debrid)]
125136 for debrid in activated_debrids:
126137 torrent_li = build_list_item(debrid, "download.png")
127138 actions = DEBRID_CLOUD_ACTIONS.get(debrid, {})
128139 info_action = actions.get("info")
129140 if info_action:
130 torrent_li.addContextMenuItems(
131 [("Account Info", action_url_run(info_action))]
132 )
141 torrent_li.addContextMenuItems([("Account Info", action_url_run(info_action))])
133142 addDirectoryItem(
134143 ADDON_HANDLE,
135144 build_url("cloud_details", debrid_name=debrid),
……@@ -191,24 +200,35 @@ def get_rd_downloads(params):
191200 rd_client = RealDebrid(token=str(get_setting("real_debrid_token", "")))
192201 raw_downloads = rd_client.get_user_downloads_list(page=page)
193202 cache.set(cache_key, raw_downloads, DEBRID_CLOUD_CACHE_EXPIRY)
194 downloads: List[Dict[str, Any]] = [
195 item for item in raw_downloads if isinstance(item, dict)
196 ]
203 downloads: List[Dict[str, Any]] = [item for item in raw_downloads if isinstance(item, dict)]
197204
198205 sorted_downloads = sorted(
199206 downloads, key=lambda item: str(item.get("filename", "")), reverse=False
200207 )
201208 for download in sorted_downloads:
202 torrent_li = build_list_item(
203 f"{formatted_type} - {download.get('filename')}", "download.png"
204 )
209 filename = download.get("filename", "")
210 torrent_li = build_list_item(f"{formatted_type} - {filename}", "download.png")
205211 torrent_li.setProperty("IsPlayable", "true")
212 direct_url = download.get("download", "")
213 context_menu = [
214 (
215 translation(90791),
216 action_url_run(
217 "download_cloud_file",
218 url=direct_url,
219 filename=filename,
220 mode="movie",
221 debrid_type=debrid_type,
222 ),
223 )
224 ]
225 torrent_li.addContextMenuItems(context_menu)
206226 addDirectoryItem(
207227 ADDON_HANDLE,
208228 build_url(
209229 "play_info_hash",
210 url=download.get("download"),
211 title=download.get("filename"),
230 url=direct_url,
231 title=filename,
212232 debrid_type=debrid_type,
213233 ),
214234 torrent_li,
……@@ -237,9 +257,7 @@ def get_tb_downloads(params):
237257 if raw_downloads is None:
238258 raw_downloads = TorboxHelper().get_cloud_downloads()
239259 cache.set(cache_key, raw_downloads, DEBRID_CLOUD_CACHE_EXPIRY)
240 downloads: List[Dict[str, Any]] = [
241 item for item in raw_downloads if isinstance(item, dict)
242 ]
260 downloads: List[Dict[str, Any]] = [item for item in raw_downloads if isinstance(item, dict)]
243261
244262 def _torbox_sort_key(item: Dict[str, Any]):
245263 return (
……@@ -254,20 +272,35 @@ def get_tb_downloads(params):
254272 )
255273
256274 notification(
257 f"Torbox Cloud: {len(sorted_downloads)} playable items found", time=2500, sound=False
275 f"Torbox Cloud: {len(sorted_downloads)} playable items found",
276 time=2500,
277 sound=False,
258278 ) if params.get("debug") else None
259279
260280 for download in sorted_downloads:
261 torrent_li = build_list_item(
262 f"{formatted_type} - {download.get('name')}", "download.png"
263 )
281 name = download.get("name", "")
282 torrent_li = build_list_item(f"{formatted_type} - {name}", "download.png")
264283 torrent_li.setProperty("IsPlayable", "true")
284 context_menu = [
285 (
286 translation(90791),
287 action_url_run(
288 "download_cloud_file",
289 torrent_id=download.get("torrent_id", ""),
290 file_id=download.get("file_id", ""),
291 filename=name,
292 mode="movie",
293 debrid_type=debrid_type,
294 ),
295 )
296 ]
297 torrent_li.addContextMenuItems(context_menu)
265298 addDirectoryItem(
266299 ADDON_HANDLE,
267300 build_url(
268301 "play_media",
269302 data={
270 "title": download.get("name"),
303 "title": name,
271304 "url": "",
272305 "mode": "movie",
273306 "type": IndexerType.DEBRID,
……@@ -285,6 +318,64 @@ def get_tb_downloads(params):
285318 apply_section_view("view.downloads", content_type="files")
286319
287320
321def get_oc_downloads(params):
322 debrid_type = DebridType.OC
323 debrid_color = get_random_color(debrid_type, formatted=False)
324 formatted_type = f"[B][COLOR {debrid_color}]{debrid_type}[/COLOR][/B]"
325
326 cache_key = _cloud_downloads_cache_key(DebridType.OC)
327 raw_downloads = cache.get(cache_key)
328 if raw_downloads is None:
329 raw_downloads = OffcloudHelper().get_cloud_downloads()
330 cache.set(cache_key, raw_downloads, DEBRID_CLOUD_CACHE_EXPIRY)
331 downloads: List[Dict[str, Any]] = [item for item in raw_downloads if isinstance(item, dict)]
332
333 sorted_downloads = sorted(
334 downloads,
335 key=lambda item: (str(item.get("created_at") or ""), str(item.get("name", ""))),
336 reverse=True,
337 )
338
339 for download in sorted_downloads:
340 name = download.get("name", "")
341 direct_url = download.get("url", "")
342 torrent_li = build_list_item(f"{formatted_type} - {name}", "download.png")
343 torrent_li.setProperty("IsPlayable", "true")
344 torrent_li.addContextMenuItems(
345 [
346 (
347 translation(90791),
348 action_url_run(
349 "download_cloud_file",
350 url=direct_url,
351 filename=name,
352 mode="movie",
353 debrid_type=debrid_type,
354 ),
355 )
356 ]
357 )
358 addDirectoryItem(
359 ADDON_HANDLE,
360 build_url(
361 "play_media",
362 data={
363 "title": name,
364 "url": direct_url,
365 "mode": "movie",
366 "type": IndexerType.DEBRID,
367 "debrid_type": debrid_type,
368 "request_id": download.get("request_id", ""),
369 },
370 ),
371 torrent_li,
372 isFolder=False,
373 )
374
375 end_of_directory()
376 apply_section_view("view.downloads", content_type="files")
377
378
288379def download(magnet, debrid_type):
289380 handler = DEBRID_DOWNLOAD_HANDLERS.get(debrid_type)
290381 if not handler:
……@@ -293,6 +384,38 @@ def download(magnet, debrid_type):
293384 handler(magnet)
294385
295386
387def resolve_cloud_download_url(data):
388 debrid_type = data.get("debrid_type", "")
389 if debrid_type in ("RD", "RealDebrid"):
390 url = data.get("url", "")
391 return url if url else None
392 elif debrid_type in ("TB", "Torbox"):
393 torrent_id = data.get("torrent_id", "")
394 file_id = data.get("file_id", "")
395 if not torrent_id or not file_id:
396 return None
397 cache_key = f"tb_download_link|{torrent_id}|{file_id}"
398 cached_url = cache.get(cache_key)
399 if cached_url:
400 return cached_url
401 try:
402 response = TorboxHelper().client.create_download_link(
403 torrent_id, file_id, get_public_ip()
404 )
405 if response:
406 url = response.get("data", "")
407 if url:
408 cache.set(cache_key, url, timedelta(minutes=5))
409 return url
410 except Exception as e:
411 kodilog(f"[resolve_cloud_download_url] TB link resolution failed: {e}")
412 return None
413 elif debrid_type in ("OC", "Offcloud"):
414 url = data.get("url", "")
415 return url if url else None
416 return None
417
418
296419def rd_auth(params):
297420 rd_client = RealDebrid(token=str(get_setting("real_debrid_token", "")))
298421 run_realdebrid_auth(rd_client)
……@@ -343,6 +466,21 @@ def tb_remove_auth(params):
343466 torbox_client.remove_auth()
344467
345468
469def oc_auth(params):
470 offcloud_client = Offcloud(token=str(get_setting("offcloud_token") or ""))
471 run_offcloud_auth(offcloud_client)
472
473
474def oc_remove_auth(params):
475 offcloud_client = Offcloud(token=str(get_setting("offcloud_token") or ""))
476 offcloud_client.remove_auth()
477
478
346479def torbox_info(params):
347480 finish_action()
348481 DEBRID_INFO_HANDLERS[DebridType.TB]()
482
483
484def offcloud_info(params):
485 finish_action()
486 DEBRID_INFO_HANDLERS[DebridType.OC]()
plugin.video.jacktook/lib/nav/library_history.py +13 -12
……@@ -1,16 +1,20 @@
1from datetime import timedelta
21import os
2from datetime import timedelta
33
44from xbmcgui import Dialog
55from xbmcplugin import addDirectoryItem, endOfDirectory
66
77from lib.db.cached import cache
88from lib.utils.general.items_menus import history_menu_items, library_menu_items
9from lib.utils.general.utils import build_list_item, clear_history_by_type, set_pluging_category
9from lib.utils.general.utils import (
10 build_list_item,
11 clear_history_by_type,
12 set_pluging_category,
13)
1014from lib.utils.kodi.settings import get_cache_expiration
1115from lib.utils.kodi.utils import (
12 ADDON_PATH,
1316 ADDON_HANDLE,
17 ADDON_PATH,
1418 add_directory_items_batch,
1519 apply_section_view,
1620 build_url,
……@@ -18,6 +22,7 @@ from lib.utils.kodi.utils import (
1822 end_of_directory,
1923 finish_action,
2024 kodi_play_media,
25 kodilog,
2126 make_list_item,
2227 notification,
2328 show_keyboard,
……@@ -64,9 +69,7 @@ def _render_menu(items, cache_listing=True):
6469 context_menu.append(
6570 (
6671 label,
67 container_update(
68 context_item["action"], **context_item.get("params", {})
69 ),
72 container_update(context_item["action"], **context_item.get("params", {})),
7073 )
7174 )
7275 if context_menu:
……@@ -127,8 +130,7 @@ def remove_from_library(params):
127130
128131
129132def clear_library(params):
130 from lib.utils.views.library import clear_library_items
131 from lib.utils.views.library import show_library_items
133 from lib.utils.views.library import clear_library_items, show_library_items
132134
133135 kodilog(f"clear_library action invoked with params={params}")
134136
……@@ -142,9 +144,10 @@ def clear_library(params):
142144
143145
144146def add_to_library(params):
145 from lib.utils.general.utils import add_to_library as add_lib
146147 import json
147148
149 from lib.utils.general.utils import add_to_library as add_lib
150
148151 data = params.get("data")
149152 if data:
150153 add_lib(json.loads(data))
……@@ -188,9 +191,7 @@ def search_direct(params):
188191
189192 for item_mode, text in cache.get_list(key=mode):
190193 list_item = make_list_item(label=f"[I]{text}[/I]")
191 list_item.setArt(
192 {"icon": os.path.join(ADDON_PATH, "resources", "img", "search.png")}
193 )
194 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "search.png")})
194195 list_item.setProperty("IsPlayable", "true")
195196 list_item.addContextMenuItems(
196197 [
plugin.video.jacktook/lib/navigation.py +282 -88
……@@ -1,16 +1,25 @@
11import json
22import os
3from lib.api.trakt.trakt import ProviderException, TraktAPI
4from lib.clients.trakt.trakt import TraktClient
53
4import xbmcgui
5from xbmcgui import ListItem
6from xbmcplugin import (
7 addDirectoryItem,
8)
9
10import lib.nav.debrid as debrid_navigation
11import lib.nav.library_history as library_history_navigation
12from lib.api.trakt.trakt import ProviderException, TraktAPI
613from lib.clients.stremio.catalog_menus import list_stremio_catalogs
714from lib.clients.tmdb.tmdb import (
815 TmdbClient,
916)
10
17from lib.clients.trakt.trakt import TraktClient
18from lib.clients.youtube_resolver import (
19 extract_video_id,
20 resolve_item_trailer,
21)
1122from lib.db.cached import RuntimeCache, cache
12
13from lib.downloader import downloads_viewer
1423from lib.gui.custom_dialogs import (
1524 CustomDialog,
1625 download_dialog_mock,
……@@ -18,87 +27,87 @@ from lib.gui.custom_dialogs import (
1827 run_next_mock,
1928 source_select_mock,
2029)
21
2230from lib.player import JacktookPLayer
31from lib.updater import downgrade_addon_menu, updates_check_addon
32from lib.utils.general.items_menus import (
33 animation_items,
34 anime_items,
35 movie_items,
36 root_menu_items,
37 trakt_movie_discovery_items,
38 trakt_movie_library_items,
39 trakt_tv_discovery_items,
40 trakt_tv_library_items,
41 tv_items,
42)
43from lib.utils.general.utils import (
44 build_list_item,
45 clear_all_cache,
46 clear_trakt_db_cache,
47 make_listing,
48 set_content_type,
49 set_pluging_category,
50 show_log_export_dialog,
51)
52from lib.utils.general.utils import (
53 clear_database_cache as utils_clear_database_cache,
54)
55from lib.utils.general.utils import (
56 clear_debrid_cache as utils_clear_debrid_cache,
57)
58from lib.utils.general.utils import (
59 clear_mdblist_cache as utils_clear_mdblist_cache,
60)
61from lib.utils.general.utils import (
62 clear_stremio_cache as utils_clear_stremio_cache,
63)
64from lib.utils.general.utils import (
65 clear_tmdb_cache as utils_clear_tmdb_cache,
66)
67from lib.utils.kodi.settings import addon_settings
68from lib.utils.kodi.settings_backup import (
69 export_settings_backup as kodi_export_settings_backup,
70)
71from lib.utils.kodi.settings_backup import (
72 factory_reset_action as kodi_factory_reset_action,
73)
74from lib.utils.kodi.settings_backup import (
75 reset_all_settings_action as kodi_reset_all_settings_action,
76)
77from lib.utils.kodi.settings_backup import (
78 restore_settings_backup as kodi_restore_settings_backup,
79)
2380from lib.utils.kodi.utils import (
2481 ADDON_HANDLE,
2582 ADDON_PATH,
83 ADDON_VERSION,
2684 CHANGELOG_PATH,
2785 JACKTORR_ADDON,
86 action_url_run,
2887 add_directory_items_batch,
2988 apply_section_view,
30 action_url_run,
3189 build_url,
3290 burst_addon_settings,
3391 capture_current_view_id,
92 clear_cached_settings,
3493 dialog_text,
3594 end_of_directory,
3695 execute_builtin,
3796 finish_action,
38 clear_cached_settings,
3997 get_setting,
4098 is_youtube_addon_enabled,
4199 kodilog,
100 make_list_item,
42101 notification,
43102 play_info_hash,
44103 reset_saved_views,
45104 save_view_id,
105 set_setting,
46106 translation,
47 make_list_item,
48107)
49108from lib.utils.player.utils import resolve_playback_url
50109from lib.utils.torrent.torrserver_init import get_torrserver_api
51110from lib.utils.torrentio.utils import open_providers_selection
52from lib.utils.kodi.settings import addon_settings
53from lib.utils.kodi.settings_backup import (
54 export_settings_backup as kodi_export_settings_backup,
55 factory_reset_action as kodi_factory_reset_action,
56 reset_all_settings_action as kodi_reset_all_settings_action,
57 restore_settings_backup as kodi_restore_settings_backup,
58)
59from lib.utils.general.utils import (
60 build_list_item,
61 clear_all_cache,
62 clear_trakt_db_cache,
63 clear_tmdb_cache as utils_clear_tmdb_cache,
64 clear_stremio_cache as utils_clear_stremio_cache,
65 clear_debrid_cache as utils_clear_debrid_cache,
66 clear_mdblist_cache as utils_clear_mdblist_cache,
67 clear_database_cache as utils_clear_database_cache,
68 make_listing,
69 set_content_type,
70 set_pluging_category,
71 show_log_export_dialog,
72)
73from lib.clients.youtube_resolver import (
74 extract_video_id,
75 resolve_item_trailer,
76)
77import lib.nav.debrid as debrid_navigation
78import lib.nav.library_history as library_history_navigation
79from lib.utils.general.items_menus import (
80 animation_items,
81 anime_items,
82 movie_items,
83 trakt_movie_discovery_items,
84 trakt_movie_library_items,
85 trakt_tv_discovery_items,
86 trakt_tv_library_items,
87 tv_items,
88)
89
90from lib.updater import updates_check_addon, downgrade_addon_menu
91
92from xbmcgui import ListItem
93from xbmcplugin import (
94 addDirectoryItem,
95 setResolvedUrl,
96)
97
98
99from lib.utils.general.items_menus import (
100 root_menu_items,
101)
102111
103112
104113def _menu_condition_signature(items):
……@@ -314,6 +323,12 @@ def settings_menu(params):
314323 build_list_item(translation(90743), "settings.png"),
315324 isFolder=True,
316325 )
326 addDirectoryItem(
327 ADDON_HANDLE,
328 build_url("donate"),
329 build_list_item(translation(90815), "donate.png"),
330 isFolder=False,
331 )
317332 end_of_directory(cache=False)
318333 apply_section_view("view.main")
319334
……@@ -368,6 +383,7 @@ def reset_views(params):
368383
369384
370385def root_menu():
386 maybe_show_donation_prompt()
371387 set_pluging_category(translation(90069))
372388 render_menu(
373389 root_menu_items,
……@@ -377,6 +393,18 @@ def root_menu():
377393 apply_section_view("view.main")
378394
379395
396def maybe_show_donation_prompt():
397 if not ADDON_VERSION:
398 return
399
400 if get_setting("donation_prompt_last_version") == ADDON_VERSION:
401 return
402
403 set_setting("donation_prompt_last_version", ADDON_VERSION)
404 if xbmcgui.Dialog().yesno(translation(90023), translation(90818)):
405 donate({})
406
407
380408def animation_menu(params):
381409 _render_cached_menu_entries(
382410 _get_cached_menu_entries(
……@@ -408,10 +436,26 @@ def telegram_menu(params):
408436 set_pluging_category(translation(90013))
409437 add_directory_items_batch(
410438 [
411 (build_url("search_direct", mode="direct"), build_list_item(translation(90006), "search.png"), True),
412 (build_url("list_jackgram_latest_movies", page=1), build_list_item(translation(90369), "movies.png"), True),
413 (build_url("list_jackgram_latest_series", page=1), build_list_item(translation(90370), "tv.png"), True),
414 (build_url("list_jackgram_raw_files", page=1), build_list_item(translation(90371), "cloud.png"), True),
439 (
440 build_url("search_direct", mode="direct"),
441 build_list_item(translation(90006), "search.png"),
442 True,
443 ),
444 (
445 build_url("list_jackgram_latest_movies", page=1),
446 build_list_item(translation(90369), "movies.png"),
447 True,
448 ),
449 (
450 build_url("list_jackgram_latest_series", page=1),
451 build_list_item(translation(90370), "tv.png"),
452 True,
453 ),
454 (
455 build_url("list_jackgram_raw_files", page=1),
456 build_list_item(translation(90371), "cloud.png"),
457 True,
458 ),
415459 ]
416460 )
417461 end_of_directory()
……@@ -528,17 +572,29 @@ def search_menu(params):
528572 directory_items = []
529573
530574 directory_items.append(
531 (build_url("handle_tmdb_search", mode="multi", page=1), build_list_item(translation(90207), "search.png"), True)
575 (
576 build_url("handle_tmdb_search", mode="multi", page=1),
577 build_list_item(translation(90207), "search.png"),
578 True,
579 )
532580 )
533581
534582 # -- Direct Search --
535583 directory_items.append(
536 (build_url("search_direct", mode="direct"), build_list_item(translation(90011), "search.png"), True)
584 (
585 build_url("search_direct", mode="direct"),
586 build_list_item(translation(90011), "search.png"),
587 True,
588 )
537589 )
538590
539591 # -- Keyword Search --
540592 directory_items.append(
541 (build_url("handle_keyword_search", mode="multi"), build_list_item(translation(90368), "tmdb.png"), True)
593 (
594 build_url("handle_keyword_search", mode="multi"),
595 build_list_item(translation(90368), "tmdb.png"),
596 True,
597 )
542598 )
543599
544600 # -- Recent TMDb Searches --
……@@ -550,11 +606,15 @@ def search_menu(params):
550606
551607 for _, text in tmdb_history[:5]:
552608 list_item = make_list_item(label=f"[I]{text}[/I]")
553 list_item.setArt(
554 {"icon": os.path.join(ADDON_PATH, "resources", "img", "tmdb.png")}
555 )
609 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "tmdb.png")})
556610 list_item.setProperty("IsPlayable", "false")
557 directory_items.append((build_url("handle_tmdb_search", mode="multi", page=1, query=text), list_item, True))
611 directory_items.append(
612 (
613 build_url("handle_tmdb_search", mode="multi", page=1, query=text),
614 list_item,
615 True,
616 )
617 )
558618
559619 # -- Recent Direct Searches --
560620 direct_history = cache.get_list(key="direct")
……@@ -565,18 +625,20 @@ def search_menu(params):
565625
566626 for mode, text in direct_history[:5]:
567627 list_item = make_list_item(label=f"[I]{text}[/I]")
568 list_item.setArt(
569 {"icon": os.path.join(ADDON_PATH, "resources", "img", "search.png")}
570 )
628 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "search.png")})
571629 list_item.setProperty("IsPlayable", "true")
572 directory_items.append((build_url("search", mode=mode, query=text, direct=True), list_item, False))
630 directory_items.append(
631 (
632 build_url("search", mode=mode, query=text, direct=True),
633 list_item,
634 False,
635 )
636 )
573637
574638 # -- Clear All Search History --
575639 if tmdb_history or direct_history:
576640 clear_li = make_list_item(label=translation(90210))
577 clear_li.setArt(
578 {"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")}
579 )
641 clear_li.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")})
580642 directory_items.append((build_url("clear_search_history"), clear_li, False))
581643
582644 add_directory_items_batch(directory_items)
……@@ -714,24 +776,60 @@ def get_tb_downloads(params):
714776 return debrid_navigation.get_tb_downloads(params)
715777
716778
779def get_oc_downloads(params):
780 return debrid_navigation.get_oc_downloads(params)
781
782
717783def torrents(params):
718784 if not JACKTORR_ADDON:
719785 notification(translation(30253))
720786 end_of_directory(cache=False)
721787 return
722788
789 set_pluging_category(translation(90012))
790
723791 for torrent in get_torrserver_api().torrents():
724792 info_hash = torrent.get("hash")
725793
726794 context_menu_items = [(translation(30700), play_info_hash(info_hash))]
727795
728 if torrent.get("stat") in [2, 3]:
796 # Build meta for subtitle download from local cache
797 parsed_data = {}
798 if info_hash:
799 from lib.utils.torrent.torrserver_utils import get_torrent_meta
800
801 parsed_data = get_torrent_meta(info_hash)
802
803 parsed_ids = (
804 parsed_data.get("ids", {}) if isinstance(parsed_data.get("ids", {}), dict) else {}
805 )
806 meta = {
807 "title": parsed_data.get("title") or torrent.get("title", ""),
808 "mode": parsed_data.get("mode", ""),
809 "ids": {
810 "tmdb_id": parsed_ids.get("tmdb_id", ""),
811 "tvdb_id": parsed_ids.get("tvdb_id", ""),
812 "imdb_id": parsed_ids.get("imdb_id"),
813 "original_id": parsed_ids.get("original_id", ""),
814 },
815 "tv_data": parsed_data.get("tv_data", {}),
816 }
817 context_menu_items.append(
818 (
819 translation(90082),
820 action_url_run(
821 "download_torrent_subtitles",
822 hash=info_hash,
823 meta=json.dumps(meta),
824 ),
825 )
826 )
827
828 if torrent.get("stat") not in [0, 4, 8]:
729829 context_menu_items.append(
730830 (
731831 translation(30709),
732 action_url_run(
733 "torrent_action", info_hash=info_hash, action_str="drop"
734 ),
832 action_url_run("torrent_action", info_hash=info_hash, action_str="drop"),
735833 )
736834 )
737835
……@@ -756,7 +854,7 @@ def torrents(params):
756854 ]
757855 )
758856
759 torrent_li = build_list_item(torrent.get("title", ""), "download.png")
857 torrent_li = build_list_item(torrent.get("title", ""), "magnet.png")
760858 torrent_li.addContextMenuItems(context_menu_items)
761859 addDirectoryItem(
762860 ADDON_HANDLE,
……@@ -779,11 +877,69 @@ def play_media(params):
779877 del player
780878
781879
880def play_autoscraped(params):
881 from lib.utils.kodi.settings import auto_play_enabled
882 from lib.utils.player.utils import get_autoscrape_cache_key, get_autoscrape_results_cache_key
883
884 ids = json.loads(params.get("ids", "{}"))
885 tv_data = json.loads(params.get("tv_data", "{}"))
886 autoplay_context = params.get("autoplay_context")
887
888 id_value = ids.get("original_id") or ids.get("imdb_id") or ids.get("tmdb_id")
889 season = tv_data.get("season")
890 episode = tv_data.get("episode")
891
892 if id_value is not None and season is not None and episode is not None:
893 cache_key = get_autoscrape_cache_key(id_value, season, episode)
894 cached_data = cache.get(cache_key)
895 if cached_data:
896 if auto_play_enabled():
897 play_data = dict(cached_data)
898 if autoplay_context:
899 play_data["autoplay"] = True
900 play_data["playnext_context"] = True
901 play_data["direct_play"] = True
902 player = JacktookPLayer()
903 player.run(data=play_data)
904 del player
905 return
906 # Autoplay disabled: show source select with cached raw results
907 results_cache_key = get_autoscrape_results_cache_key(id_value, season, episode)
908 cached_results = cache.get(results_cache_key)
909 if cached_results:
910 mode = params.get("mode", "tv")
911 kodilog(
912 "PlayAutoscraped: showing source select with"
913 f" {len(cached_results)} cached results (mode={mode})"
914 )
915 from lib.search import show_source_select
916
917 show_source_select(
918 cached_results,
919 mode=mode,
920 ids=ids,
921 tv_data=tv_data,
922 query=params.get("query", ""),
923 media_type=params.get("media_type", ""),
924 rescrape=False,
925 direct=False,
926 autoplay_context=autoplay_context,
927 )
928 return
929 kodilog("PlayAutoscraped: no cached results, falling back to search")
930
931 from lib.search import run_search_entry
932
933 run_search_entry(params)
934
935
782936def play_url(params):
783937 url = params.get("url")
938 from xbmc import Player
784939 list_item = ListItem(label=params.get("name"), path=url)
785940 list_item.setPath(url)
786 setResolvedUrl(ADDON_HANDLE, True, list_item)
941 list_item.setProperty("IsPlayable", "true")
942 Player().play(url, list_item)
787943
788944
789945def play_trailer(params):
……@@ -839,7 +995,8 @@ def play_trailer(params):
839995 list_item.setProperty("IsPlayable", "true")
840996 if playback.get("source_type") == "adaptive" and playback.get("audio_url"):
841997 list_item.setProperty("inputstream.adaptive.manifest_type", "mpd")
842 setResolvedUrl(ADDON_HANDLE, True, list_item)
998 from xbmc import Player
999 Player().play(video_url, list_item)
8431000
8441001
8451002def play_from_pack(params):
……@@ -847,8 +1004,10 @@ def play_from_pack(params):
8471004 data = resolve_playback_url(data)
8481005 if not data:
8491006 return
850 list_item = make_listing(data)
851 setResolvedUrl(ADDON_HANDLE, True, list_item)
1007 from lib.player import JacktookPLayer
1008 player = JacktookPLayer()
1009 player.run(data=data)
1010 del player
8521011
8531012
8541013def people_menu(mode):
……@@ -1015,7 +1174,29 @@ def download(magnet, type):
10151174
10161175
10171176def downloads_menu(params):
1018 downloads_viewer(params)
1177 set_pluging_category(translation(90015))
1178 addDirectoryItem(
1179 ADDON_HANDLE,
1180 build_url("downloads_viewer"),
1181 build_list_item(translation(90806), "download2.png"),
1182 isFolder=True,
1183 )
1184 addDirectoryItem(
1185 ADDON_HANDLE,
1186 build_url("open_download_manager"),
1187 build_list_item(translation(90805), "download2.png"),
1188 isFolder=False,
1189 )
1190 end_of_directory(cache=False)
1191 apply_section_view("view.main")
1192
1193
1194def open_download_manager(params):
1195 from lib.gui.download_manager_window import DownloadManagerWindow
1196
1197 window = DownloadManagerWindow("download_manager.xml", ADDON_PATH)
1198 window.doModal()
1199 del window
10191200
10201201
10211202def addon_update(params):
……@@ -1027,7 +1208,7 @@ def downgrade_addon(params):
10271208
10281209
10291210def show_changelog(params):
1030 dialog_text(translation(90577), file=CHANGELOG_PATH)
1211 dialog_text(translation(90577), file=CHANGELOG_PATH)
10311212
10321213
10331214def donate(params):
……@@ -1041,6 +1222,7 @@ def donate(params):
10411222 ADDON_PATH,
10421223 heading=translation(90023),
10431224 text=translation(90022),
1225 call_to_action=translation(90817),
10441226 url=f"[COLOR snow]{donation_url}[/COLOR]",
10451227 qrcode=qr_code,
10461228 )
……@@ -1170,10 +1352,22 @@ def tb_remove_auth(params):
11701352 return debrid_navigation.tb_remove_auth(params)
11711353
11721354
1355def oc_auth(params):
1356 return debrid_navigation.oc_auth(params)
1357
1358
1359def oc_remove_auth(params):
1360 return debrid_navigation.oc_remove_auth(params)
1361
1362
11731363def torbox_info(params):
11741364 return debrid_navigation.torbox_info(params)
11751365
11761366
1367def offcloud_info(params):
1368 return debrid_navigation.offcloud_info(params)
1369
1370
11771371def open_burst_config(params):
11781372 burst_addon_settings()
11791373
plugin.video.jacktook/lib/player.py +627 -112
……@@ -1,8 +1,23 @@
1from datetime import date
2from json import dumps as json_dumps
3from json import loads
14from threading import Thread
5from typing import Optional
6
7import xbmc
8from xbmc import getCondVisibility as get_visibility
9from xbmcgui import Dialog, ListItem
10from xbmcplugin import setResolvedUrl
11
12from lib.api.trakt.trakt import TraktAPI, TraktLists
213from lib.api.trakt.trakt_utils import is_trakt_auth
314from lib.clients.subtitle.utils import get_language_code
4from lib.clients.tmdb.utils.utils import tmdb_get
5from lib.api.trakt.trakt import TraktAPI, TraktLists
15from lib.clients.tmdb.utils.utils import get_movie_keywords, tmdb_get
16from lib.db.cached import cache
17from lib.utils.general.utils import (
18 make_listing,
19 set_watched_file,
20)
621from lib.utils.kodi.utils import (
722 ADDON_HANDLE,
823 PLAYLIST,
……@@ -12,6 +27,7 @@ from lib.utils.kodi.utils import (
1227 close_all_dialog,
1328 close_busy_dialog,
1429 execute_builtin,
30 get_property,
1531 get_setting,
1632 kodilog,
1733 notification,
……@@ -19,24 +35,20 @@ from lib.utils.kodi.utils import (
1935 sleep,
2036 translation,
2137)
22from lib.utils.general.utils import (
23 make_listing,
24 set_watched_file,
38from lib.utils.player.utils import (
39 autoscrape_next_episode,
40 get_autoscrape_cache_key,
41 get_autoscrape_results_cache_key,
2542)
26from lib.utils.player.utils import precache_next_episodes
27
28import xbmc
29from xbmc import getCondVisibility as get_visibility
30from xbmcgui import ListItem, Dialog
31from xbmcplugin import setResolvedUrl
32
33from json import dumps as json_dumps, loads
3443
44AUTOPLAY_CONTEXT_NEXT_EPISODE = 1
3545total_time_errors = ("0.0", "", 0.0, None)
3646video_fullscreen_check = "Window.IsActive(fullscreenvideo)"
3747
3848
3949class JacktookPLayer(xbmc.Player):
50 _nextep_queue: list = []
51
4052 def __init__(self, on_started=None, on_error=None):
4153 xbmc.Player.__init__(self)
4254 self.url = None
……@@ -57,26 +69,81 @@ class JacktookPLayer(xbmc.Player):
5769 self._is_trakt_scrobble_active = False
5870 self._playback_was_paused = False
5971
60 def run(self, data={}):
72 def run(self, data=None):
73 if data is None:
74 data = {}
6175 self.set_constants(data)
6276 self.clear_playback_properties()
6377 self.add_external_trakt_scrolling()
6478 self.mark_watched(data)
6579
66 precaching_thread = Thread(target=precache_next_episodes, args=(self.data,))
67 precaching_thread.start()
68
6980 close_busy_dialog()
81 kodilog(
82 f"[PLAYER] run() started: url_type={'plugin' if self.url.startswith('plugin://') else 'direct'}, "
83 f"mode={data.get('mode')}, url_start={self.url[:80]}"
84 )
7085
7186 try:
7287 self.list_item = make_listing(data)
7388 self.PLAYLIST.add(self.url, self.list_item)
74 if self.data["mode"] == "tv":
75 self.build_playlist()
7689 self.play_video(self.list_item)
7790 except Exception as e:
7891 self.run_error(e)
7992
93 had_nextep = self._drain_nextep_queue()
94 kodilog(f"[PLAYER] _drain_nextep_queue returned had_nextep={had_nextep}")
95 kodilog(f"[PLAYER] run() completed")
96
97 def _drain_nextep_queue(self) -> bool:
98 """Process queued next episodes after current video has stopped"""
99 from lib.utils.kodi.settings import auto_play_enabled as _auto_play_enabled
100
101 if not JacktookPLayer._nextep_queue:
102 return False
103
104 while JacktookPLayer._nextep_queue:
105 entry = JacktookPLayer._nextep_queue.pop(0)
106 data = entry.get("data", {})
107 results = entry.get("results")
108 kodilog(
109 f"[PLAYNEXT] _drain_nextep_queue: "
110 f"title={data.get('title', '')}, "
111 f"has_results={results is not None}, "
112 f"queue_remaining={len(JacktookPLayer._nextep_queue)}"
113 )
114
115 if _auto_play_enabled() or results is None:
116 # Autoplay or resolved data: play directly (video is already stopped)
117 player = JacktookPLayer()
118 player.run(data=data)
119 del player
120 else:
121 # Manual: show source select (safe context — no video playing)
122 from lib.search import show_source_select as _show_source_select
123 resolved = _show_source_select(
124 results,
125 mode=data.get("mode", "tv"),
126 ids=data.get("ids", {}),
127 tv_data=data.get("tv_data", {}),
128 query=data.get("query", ""),
129 media_type=data.get("media_type", ""),
130 rescrape=False,
131 direct=False,
132 autoplay_context=str(AUTOPLAY_CONTEXT_NEXT_EPISODE),
133 )
134 kodilog(f"[PLAYNEXT] source_select returned resolved={resolved}")
135 if not resolved:
136 kodilog("[PLAYNEXT] source_select cancelled; cleanup only")
137 close_busy_dialog()
138 close_all_dialog()
139 clear_property("jacktook_next_dialog_action")
140 try:
141 self.PLAYLIST.clear()
142 except Exception as e:
143 kodilog(f"[PLAYNEXT] failed clearing playlist after cancel: {e}")
144
145 return True
146
80147 def play_video(self, list_item):
81148 close_busy_dialog()
82149
……@@ -87,17 +154,31 @@ class JacktookPLayer(xbmc.Player):
87154 try:
88155 self._handle_trakt_scrobble(list_item)
89156 self.handle_subtitles(list_item)
157 # Signal Kodi that the plugin action is a playback (required to
158 # avoid spinners when run() returns after video stops).
90159 setResolvedUrl(ADDON_HANDLE, True, list_item)
160 # For external addon URLs (plugin://, e.g. Jacktorr/Elementum/
161 # Torrest), setResolvedUrl already handles playback by invoking
162 # the external addon via Kodi's plugin protocol. Calling
163 # Player().play() on top of that triggers a second concurrent
164 # busy dialog, crashing Kodi with "Logic error due to two
165 # concurrent busydialogs".
166 is_plugin = self.url.startswith("plugin://")
167 kodilog(
168 f"[PLAYER] play_video: is_plugin={is_plugin}, "
169 f"calling {'setResolvedUrl only' if is_plugin else 'setResolvedUrl + Player().play()'}"
170 )
171 if not is_plugin:
172 self.play(self.url, list_item)
91173 self.monitor()
174 kodilog("[PLAYER] play_video: monitor() returned")
92175 except Exception as e:
93176 kodilog(f"Error during play_video: {e}")
94177 self.run_error(e)
95178
96179 def _check_volume(self):
97180 try:
98 if not get_setting("volume_check_enabled") or get_visibility(
99 "Player.Muted"
100 ):
181 if not get_setting("volume_check_enabled") or get_visibility("Player.Muted"):
101182 return True
102183
103184 threshold = int(get_setting("volume_check_threshold", 50))
……@@ -126,14 +207,8 @@ class JacktookPLayer(xbmc.Player):
126207 return True
127208
128209 def _handle_trakt_scrobble(self, list_item):
129 if (
130 is_trakt_auth()
131 and get_setting("trakt_scrobbling_enabled")
132 and self.data.get("ids")
133 ):
134 last_position = TraktAPI().scrobble.trakt_get_last_tracked_position(
135 self.data
136 )
210 if is_trakt_auth() and get_setting("trakt_scrobbling_enabled") and self.data.get("ids"):
211 last_position = TraktAPI().scrobble.trakt_get_last_tracked_position(self.data)
137212 if last_position > 0:
138213 list_item.setProperty("StartPercent", str(last_position))
139214 TraktAPI().scrobble.trakt_start_scrobble(self.data)
……@@ -163,15 +238,18 @@ class JacktookPLayer(xbmc.Player):
163238
164239 def monitor(self):
165240 ensure_dialog_closed = False
241 kodilog("[PLAYER] monitor() entered")
166242
167243 try:
168244 while not self.isPlayingVideo():
169245 if self.kodi_monitor.abortRequested():
246 kodilog("[PLAYER] monitor: abort requested while waiting for video to start")
170247 return
171248 if get_visibility("Window.IsTopMost(okdialog)"):
172249 execute_builtin("SendClick(okdialog, 11)")
173250 return
174251 sleep(100)
252 kodilog("[PLAYER] monitor: video started playing, entering main loop")
175253
176254 # Once playing, initialize streams and subtitles
177255 self.select_audio_stream()
……@@ -184,31 +262,51 @@ class JacktookPLayer(xbmc.Player):
184262 introdb_thread.daemon = True
185263 introdb_thread.start()
186264
265 # Fetch stinger info in background if enabled
266 if get_setting("stinger_notifications_enabled") and self.data.get("mode") == "movies":
267 stinger_thread = Thread(target=self.fetch_stinger_info)
268 stinger_thread.daemon = True
269 stinger_thread.start()
270
187271 # Monitor loop
188272 while self.isPlayingVideo():
189273 self.update_playback_progress()
190274 self.handle_trakt_pause_resume()
275 self.check_autoscrape_threshold()
191276
192277 if not ensure_dialog_closed:
193278 ensure_dialog_closed = True
194279 self.playback_close_dialogs()
195280
196 self.check_skip_intro()
197281 self.check_next_dialog()
282 self.check_skip_intro()
283 self.check_stinger_notification()
284
285 # Handle pending next-episode action from dialog
286 if get_property("jacktook_next_dialog_action") == "next_episode":
287 self._handle_next_dialog_action()
288
198289 sleep(1000)
199290
200291 self.handle_playback_stop()
292 kodilog("[PLAYER] monitor: main loop exited, handle_playback_stop completed")
201293
202 except Exception as e:
294 except Exception:
203295 self.kill_dialog()
204296 finally:
205 self._cleanup_playback_session()
297 kodilog("[PLAYER] monitor: finally block - clearing playback properties")
206298 self.clear_playback_properties()
299 kodilog("[PLAYER] monitor: exiting")
207300
208301 def handle_subtitles(self, list_item):
302 # Skip subtitle handling during autoplay (Play Next) — no dialogs
303 if self.data.get("autoplay"):
304 kodilog("Autoplay mode: skipping subtitle handling")
305 return
306
209307 self.subtitles_found = False
210308 subtitles_path = self.data.get("subtitles_path", [])
211 if subtitles_path or get_setting("search_subtitles"):
309 if subtitles_path or get_property("search_subtitles"):
212310 list_item.setSubtitles(subtitles_path)
213311 self.setSubtitleStream(0)
214312 self.subtitles_found = True
……@@ -216,8 +314,9 @@ class JacktookPLayer(xbmc.Player):
216314 try:
217315 from lib.clients.subtitle.submanager import SubtitleManager
218316
317 auto_select = get_setting("auto_subtitle_selection", False)
219318 subtitle_manager = SubtitleManager(self.data, self.notification)
220 subs_paths = subtitle_manager.fetch_subtitles(auto_select=True)
319 subs_paths = subtitle_manager.fetch_subtitles(auto_select=auto_select)
221320 if not subs_paths:
222321 kodilog("No subtitles found, skipping subtitle loading")
223322 self.subtitles_found = False
……@@ -241,23 +340,30 @@ class JacktookPLayer(xbmc.Player):
241340 """
242341 auto_select_enabled = get_setting("auto_subtitle_selection")
243342 stremio_subtitle_enabled = get_setting("stremio_subtitle_enabled")
244 search_subtitles = get_setting("search_subtitles")
245
343 search_subtitles = get_property("search_subtitles")
246344 if self.subtitles_found or stremio_subtitle_enabled or search_subtitles:
247345 self.showSubtitles(True)
248346 if self.subtitles_found:
249347 self.notification(translation(90257), time=2000)
250 set_property("search_subtitles", "false")
251 return
348 clear_property("search_subtitles")
349
350 # Auto-select subtitle by language if enabled (runs BEFORE return)
351 if auto_select_enabled:
352 _, _, subtitles = self.get_player_streams()
353 kodilog(f"Available subtitles: {subtitles}", level=xbmc.LOGDEBUG)
354 for sub in subtitles:
355 if self.lang_code == sub.get("language") and sub.get("isforced") is False:
356 self.setSubtitleStream(sub["index"])
357 self.showSubtitles(True)
358 break
359
360 return
252361
253362 if auto_select_enabled:
254363 _, _, subtitles = self.get_player_streams()
255364 kodilog(f"Available subtitles: {subtitles}", level=xbmc.LOGDEBUG)
256365 for sub in subtitles:
257 if (
258 self.lang_code == sub.get("language")
259 and sub.get("isforced") is False
260 ):
366 if self.lang_code == sub.get("language") and sub.get("isforced") is False:
261367 self.setSubtitleStream(sub["index"])
262368 self.showSubtitles(True)
263369 break
……@@ -272,11 +378,7 @@ class JacktookPLayer(xbmc.Player):
272378 """
273379 auto_audio_enabled = get_setting("auto_audio")
274380 auto_audio_language = str(get_setting("auto_audio_language"))
275 if (
276 auto_audio_enabled
277 and auto_audio_language
278 and auto_audio_language.lower() != "none"
279 ):
381 if auto_audio_enabled and auto_audio_language and auto_audio_language.lower() != "none":
280382 sleep(500)
281383 audio_streams = self.getAvailableAudioStreams()
282384 if audio_streams or len(audio_streams) > 0:
……@@ -294,11 +396,12 @@ class JacktookPLayer(xbmc.Player):
294396
295397 def handle_playback_start(self):
296398 try:
297 if self.getTotalTime() not in total_time_errors and get_visibility(
298 video_fullscreen_check
399 if (
400 self.getTotalTime() not in total_time_errors
401 and get_visibility(video_fullscreen_check)
402 and self.on_started
299403 ):
300 if self.on_started:
301 self.on_started()
404 self.on_started()
302405 except Exception as e:
303406 kodilog(f"Error in handle_playback_start: {e}")
304407
……@@ -307,116 +410,478 @@ class JacktookPLayer(xbmc.Player):
307410 self.total_time = self.getTotalTime()
308411 self.current_time = self.getTime()
309412 if self.total_time and self.total_time > 0:
310 self.watched_percentage = round(
311 float(self.current_time / self.total_time * 100), 1
312 )
413 self.watched_percentage = round(float(self.current_time / self.total_time * 100), 1)
313414 self.data["progress"] = self.watched_percentage
314415 self.data["current_time"] = self.current_time
315416 self.data["total_time"] = self.total_time
316417 except Exception as e:
317418 kodilog(f"Error updating playback progress: {e}")
318419
319 def check_next_dialog(self):
420 def check_autoscrape_threshold(self):
320421 try:
321422 if not getattr(self, "total_time", None) or self.total_time < 60:
322423 return
323424 if not getattr(self, "current_time", None):
324425 return
426 if getattr(self, "autoscrape_started", False) is not False:
427 return
428 if not get_setting("autoscrape_next_episode", False):
429 return
430 if self.data.get("mode") != "tv":
431 return
325432
326 # Stale data check: if getTime() is very high but we just started, skip
433 threshold = int(get_setting("autoscrape_threshold", 70))
434 if self.current_time >= (self.total_time * threshold / 100):
435 self.autoscrape_started = True
436 next_tv_data = self._get_next_episode_data()
437 if not next_tv_data:
438 return
439 thread = Thread(target=autoscrape_next_episode, args=(self.data, next_tv_data))
440 thread.daemon = True
441 thread.start()
442 except Exception as e:
443 kodilog(f"Error in check_autoscrape_threshold: {e}")
444
445 def check_next_dialog(self):
446 try:
447 # Skip dialog entirely during autoplay (Play Next chain) — no interruptions
448 if self.data.get("autoplay"):
449 return
450
451 # Only show PlayNext for TV series, never for movies
452 if self.data.get("mode") != "tv":
453 return
454
455 if not getattr(self, "total_time", None) or self.total_time < 60:
456 return
457 if not getattr(self, "current_time", None):
458 return
327459 if not getattr(self, "playback_started_properly", False):
328460 if self.current_time < 10:
329461 self.playback_started_properly = True
330462 else:
331463 return
332
333464 if self.current_time < (self.total_time * 0.5):
334 # Don't trigger in the first 50% of playback as a safety measure
335465 return
336466
337 time_left = int(self.total_time) - int(self.current_time)
338 if self.next_dialog and time_left <= self.playing_next_time:
339 xbmc.executebuiltin(
340 action_url_run(name="run_next_dialog", item_info=self.data)
341 )
342 self.next_dialog = False
467 use_percentage = get_setting("playnext_use_percentage", False)
468 if use_percentage:
469 percentage = int(get_setting("playnext_percentage", 95))
470 if self.next_dialog and self.watched_percentage >= percentage:
471 self._open_next_dialog()
472 else:
473 time_left = int(self.total_time) - int(self.current_time)
474 if self.next_dialog and time_left <= self.playing_next_time:
475 self._open_next_dialog()
343476 except Exception as e:
344477 kodilog(f"Error in check_next_dialog: {e}")
345478
346 def handle_playback_stop(self):
347 if self._is_trakt_scrobble_enabled():
348 TraktAPI().scrobble.trakt_stop_scrobble(self.data)
349 self._is_trakt_scrobble_active = False
479 def _open_next_dialog(self):
480 """Open PlayNext with one resolved next episode payload."""
481 try:
482 next_tv_data = self._get_next_episode_data()
483 except Exception as e:
484 kodilog(f"[PLAYNEXT] Failed resolving next_tv_data for dialog: {e}")
485 next_tv_data = None
486 item_info = dict(self.data)
487 if self._is_valid_next_tv_data(next_tv_data):
488 self.data["next_tv_data"] = next_tv_data
489 item_info["next_tv_data"] = next_tv_data
490 xbmc.executebuiltin(action_url_run(name="run_next_dialog", item_info=item_info))
491 self.next_dialog = False
492
493 def _is_valid_next_tv_data(self, next_tv_data) -> bool:
494 """Return True when next_tv_data identifies a positive non-current episode."""
495 if not isinstance(next_tv_data, dict):
496 return False
497 try:
498 season = int(next_tv_data.get("season"))
499 episode = int(next_tv_data.get("episode"))
500 except (TypeError, ValueError):
501 return False
502 if season <= 0 or episode <= 0:
503 return False
504
505 current_tv_data = self.data.get("tv_data") or {}
506 try:
507 current_season = int(current_tv_data.get("season"))
508 current_episode = int(current_tv_data.get("episode"))
509 except (TypeError, ValueError):
510 return True
511 return (season, episode) != (current_season, current_episode)
512
513 def _authoritative_next_tv_data(self):
514 """Prefer dialog/player next_tv_data, falling back to recalculation."""
515 next_tv_data = self.data.get("next_tv_data")
516 if self._is_valid_next_tv_data(next_tv_data):
517 kodilog(f"[PLAYNEXT] Using authoritative next_tv_data {next_tv_data}")
518 return next_tv_data
519
520 next_tv_data = self._get_next_episode_data()
521 kodilog(f"[PLAYNEXT] _get_next_episode_data returned {next_tv_data}")
522 if self._is_valid_next_tv_data(next_tv_data):
523 self.data["next_tv_data"] = next_tv_data
524 return next_tv_data
525 return None
526
527 def _check_still_watching_threshold(self) -> bool:
528 """Check consecutive autoplay count and prompt 'Still Watching?' dialog.
529
530 Returns True if the user stopped (caller should abort), False to continue.
531 """
532 threshold = int(get_setting("playnext_threshold", 0))
533 if threshold <= 0:
534 return False
350535
351 # Persist playback progress
352 set_watched_file(self.data)
536 count_str = get_property("jacktook_consecutive_autoplays")
537 try:
538 count = int(count_str) if count_str else 1
539 except (ValueError, TypeError):
540 count = 1
541
542 if count >= threshold:
543 from xbmcgui import Dialog
544
545 confirmed = Dialog().yesno(
546 "Still watching?",
547 "You've watched several episodes in a row. Continue?",
548 yeslabel="Continue",
549 nolabel="Stop",
550 )
551 if not confirmed:
552 clear_property("jacktook_consecutive_autoplays")
553 clear_property("jacktook_next_dialog_action")
554 return True
555 count = 1
556 else:
557 count += 1
353558
354 close_busy_dialog()
559 set_property("jacktook_consecutive_autoplays", str(count))
560 return False
355561
356 def build_playlist(self):
357 if self.data.get("mode") != "tv":
562 def _queue_from_autoscrape_cache(self, next_tv_data: dict, ids: dict) -> bool:
563 """Check autoscrape cache and if hit, queue next episode + STOP player."""
564 id_value = ids.get("original_id") or ids.get("imdb_id") or ids.get("tmdb_id")
565 if id_value is None:
566 return False
567
568 cache_key = get_autoscrape_cache_key(
569 id_value, next_tv_data.get("season"), next_tv_data.get("episode")
570 )
571 cached_data = cache.get(cache_key)
572 if not cached_data:
573 kodilog(f"[PLAYNEXT] Autoscrape cache MISS for key={cache_key}")
574 return False
575
576 kodilog(f"[PLAYNEXT] Autoscrape cache HIT for key={cache_key}")
577 from lib.utils.kodi.settings import auto_play_enabled
578
579 if auto_play_enabled():
580 entry_data = dict(cached_data)
581 entry_data["autoplay"] = True
582 entry_data["playnext_context"] = True
583 JacktookPLayer._nextep_queue.append({"data": entry_data})
584 kodilog("[PLAYNEXT] Queued autoplay from cache, stopping player")
585 self.stop()
586 clear_property("jacktook_next_dialog_action")
587 return True
588
589 # Autoplay disabled: queue cached raw results for source select
590 results_cache_key = get_autoscrape_results_cache_key(
591 id_value, next_tv_data.get("season"), next_tv_data.get("episode")
592 )
593 cached_results = cache.get(results_cache_key)
594
595 entry_data = {
596 "mode": self.data.get("mode", "tv"),
597 "ids": ids,
598 "tv_data": next_tv_data,
599 "return_tv_data": self.data.get("tv_data", {}),
600 "query": self.data.get("title", ""),
601 "media_type": self.data.get("media_type", ""),
602 "playnext_context": True,
603 }
604 JacktookPLayer._nextep_queue.append({
605 "data": entry_data,
606 "results": cached_results,
607 })
608 kodilog(
609 f"[PLAYNEXT] Queued source select from cache"
610 f" (results: {len(cached_results) if cached_results else 0}),"
611 f" stopping player"
612 )
613 self.stop()
614 clear_property("jacktook_next_dialog_action")
615 return True
616
617 def _handle_next_dialog_action(self):
618 """Handle PlayNext using the dialog's logical next episode as authority.
619
620 Kodi's PLAYLIST may still exist for playback mechanics, but it must not
621 decide the PlayNext target here: a stale playlist item can drift from
622 the `next_tv_data` shown by the dialog. Resolve the logical next episode
623 first, then queue it from cache or via the silent background search.
624 """
625
626 from json import dumps as json_dumps
627
628 kodilog(
629 f"[PLAYNEXT] _handle_next_dialog_action:"
630 f" tv_data={self.data.get('tv_data')},"
631 f" ids={self.data.get('ids')}"
632 )
633
634 if self._check_still_watching_threshold():
358635 return
359636
360 ids = self.data.get("ids")
361 if not ids:
637 next_tv_data = self._authoritative_next_tv_data()
638 if not next_tv_data:
639 clear_property("jacktook_next_dialog_action")
362640 return
363641
642 ids = self.data.get("ids", {})
643
644 # Prefer the logical PlayNext queue paths over Kodi PLAYLIST advance.
645 # PLAYLIST fallback is intentionally not consulted before next_tv_data.
646 if self._queue_from_autoscrape_cache(next_tv_data, ids):
647 return # Cache hit → queued → player.stop() called
648
649 # No cache: fire silent background search thread for the same target.
650 kodilog("[PLAYNEXT] No cache, firing silent background search")
651 item_data = {
652 "ids": ids,
653 "title": self.data.get("title", ""),
654 "mode": self.data.get("mode", ""),
655 "media_type": self.data.get("media_type", ""),
656 }
657 Thread(target=self._background_search_and_queue, args=(item_data, next_tv_data)).start()
658 clear_property("jacktook_next_dialog_action")
659
660 def _background_search_and_queue(self, item_data: dict, next_tv_data: dict) -> None:
661 """Search silently in background, queue result, then stop player"""
662 from lib.utils.player.utils import autoscrape_next_episode
663
664 try:
665 kodilog("[PLAYNEXT] Background search starting (silent)")
666 autoscrape_next_episode(item_data, next_tv_data)
667 kodilog("[PLAYNEXT] Background search complete, checking cache")
668
669 ids = item_data.get("ids", {})
670 # Check cache again — autoscrape_next_episode populates it
671 self._queue_from_autoscrape_cache(next_tv_data, ids)
672 except Exception as e:
673 kodilog(f"[PLAYNEXT] Background search error: {e}")
674
675 def _validate_next_episode_inputs(self):
676 """Validate that we have the data needed to find the next episode.
677
678 Returns (tmdb_id, season, episode) or None.
679 """
680 if self.data.get("mode") != "tv":
681 return None
682
683 ids = self.data.get("ids") or {}
364684 tmdb_id = ids.get("tmdb_id")
365685 if not tmdb_id:
366 return
686 return None
367687
368 details = tmdb_get("tv_details", tmdb_id)
369 tv_data = self.data.get("tv_data", {})
688 tv_data = self.data.get("tv_data") or {}
370689 season = tv_data.get("season")
371690 episode = tv_data.get("episode")
372
373691 if season is None or episode is None:
374 return
692 return None
375693
376 season_details = tmdb_get("season_details", {"id": tmdb_id, "season": season})
694 try:
695 season = int(season)
696 episode = int(episode)
697 except (TypeError, ValueError):
698 return None
699
700 return tmdb_id, season, episode
377701
702 def _fetch_season_episodes(self, tmdb_id: str, season: int) -> Optional[list]:
703 """Fetch episode list for a given season from TMDB. Returns list or None."""
704 season_details = tmdb_get("season_details", {"id": tmdb_id, "season": season})
378705 if not season_details or not hasattr(season_details, "episodes"):
379 return
706 return None
707 episodes = getattr(season_details, "episodes", [])
708 return episodes or None
380709
381 for e in getattr(season_details, "episodes"):
382 episode_name = getattr(e, "name", "")
383 episode_number = getattr(e, "episode_number", 0)
710 def _advance_across_season_boundary(
711 self, tmdb_id: str, season: int, episode: int, episodes: list, tv_details
712 ) -> Optional[tuple]:
713 """Calculate the next episode position, crossing season boundary if needed.
384714
385 if episode_number <= int(episode):
386 continue
715 Returns (next_season, next_episode, search_episodes) or None if at series end.
716 """
717 max_episode = max(
718 (getattr(ep, "episode_number", 0) for ep in episodes),
719 default=0,
720 )
721
722 next_season = season
723 next_episode = episode + 1
387724
388 label = f"{season}x{episode_number}. {episode_name}"
389 next_tv_data = {
390 "name": episode_name,
391 "episode": episode_number,
392 "season": season,
725 if episode < max_episode:
726 return next_season, next_episode, episodes
727
728 # Season boundary: advance to next season
729 # TMDB's number_of_seasons INCLUDES season 0 (specials), so we count
730 # only seasons with season_number > 0 from the seasons list instead.
731 all_seasons = getattr(tv_details, "seasons", None)
732 if all_seasons:
733 real_seasons = {
734 s.season_number for s in all_seasons if getattr(s, "season_number", 0) > 0
393735 }
736 max_season = max(real_seasons) if real_seasons else 1
737 else:
738 max_season = getattr(tv_details, "number_of_seasons", 1) or 1
739 if season >= max_season:
740 return None
741
742 next_season = season + 1
743 next_episode = 1
744 next_episodes = self._fetch_season_episodes(tmdb_id, next_season)
745 if not next_episodes:
746 return None
747
748 return next_season, next_episode, next_episodes
749
750 def _find_next_episode_by_number(self, episodes: list, target_episode: int):
751 """Find episode by number, checking air_date is not in the future.
752
753 Returns the episode object or None.
754 """
755 for ep in episodes:
756 if getattr(ep, "episode_number", 0) != target_episode:
757 continue
758 air_date = getattr(ep, "air_date", None)
759 if air_date:
760 try:
761 parts = air_date.split("-")
762 ep_date = date(int(parts[0]), int(parts[1]), int(parts[2]))
763 if date.today() < ep_date:
764 return None
765 except (ValueError, IndexError):
766 pass
767 return ep
768 return None
769
770 def _get_next_episode_data(self):
771 """Return next_tv_data dict for the next aired episode, or None."""
772 kodilog(
773 f"[PLAYNEXT] _get_next_episode_data: mode={self.data.get('mode')}, "
774 f"tv_data={self.data.get('tv_data')}, ids={self.data.get('ids')}"
775 )
776
777 inputs = self._validate_next_episode_inputs()
778 if not inputs:
779 kodilog("[PLAYNEXT] _get_next_episode_data: missing required inputs, returning None")
780 return None
781 tmdb_id, season, episode = inputs
782 kodilog(
783 f"[PLAYNEXT] _get_next_episode_data: tmdb_id={tmdb_id},"
784 f" season={season}, episode={episode}"
785 )
786
787 tv_details = tmdb_get("tv_details", tmdb_id)
788 if not tv_details:
789 kodilog("[PLAYNEXT] _get_next_episode_data: tv_details fetch failed, returning None")
790 return None
791
792 episodes = self._fetch_season_episodes(tmdb_id, season)
793 if not episodes:
794 kodilog(
795 "[PLAYNEXT] _get_next_episode_data: no episodes found for"
796 " current season, returning None"
797 )
798 return None
799
800 pos = self._advance_across_season_boundary(tmdb_id, season, episode, episodes, tv_details)
801 if not pos:
802 kodilog(
803 "[PLAYNEXT] _get_next_episode_data: at series end or"
804 " boundary fetch failed, returning None"
805 )
806 return None
807 next_season, next_episode, search_episodes = pos
808
809 next_ep = self._find_next_episode_by_number(search_episodes, next_episode)
810 if not next_ep:
811 kodilog(
812 "[PLAYNEXT] _get_next_episode_data: next episode not found"
813 " or not yet aired, returning None"
814 )
815 return None
816
817 result = {
818 "name": getattr(next_ep, "name", ""),
819 "episode": next_episode,
820 "season": next_season,
821 }
822 kodilog(f"[PLAYNEXT] _get_next_episode_data returning: {result}")
823 return result
394824
825 def handle_playback_stop(self):
826 kodilog("[PLAYER] handle_playback_stop entered")
827 if self._is_trakt_scrobble_enabled():
828 TraktAPI().scrobble.trakt_stop_scrobble(self.data)
829 self._is_trakt_scrobble_active = False
830
831 # Persist playback progress
832 set_watched_file(self.data)
833
834 close_busy_dialog()
835 clear_property("jacktook_next_dialog_action")
836 kodilog("[PLAYER] handle_playback_stop completed")
837
838 def build_playlist(self):
839 next_tv_data = self._get_next_episode_data()
840 kodilog(f"[PLAYNEXT] build_playlist: next_tv_data={next_tv_data}")
841 if not next_tv_data:
842 return
843
844 ids = self.data.get("ids") or {}
845 episode_name = next_tv_data.get("name", "")
846 label = f"{next_tv_data.get('season')}x{next_tv_data.get('episode')}. {episode_name}"
847 query = self.data.get("title", "")
848
849 if get_setting("autoscrape_next_episode", False):
850 url = build_url(
851 "play_autoscraped",
852 mode=self.data["mode"],
853 query=query,
854 ids=ids,
855 tv_data=next_tv_data,
856 preferred_group=self.preferred_group,
857 autoplay_context=str(AUTOPLAY_CONTEXT_NEXT_EPISODE),
858 )
859 else:
395860 url = build_url(
396861 "search",
397862 mode=self.data["mode"],
398 query=getattr(details, "name", ""),
863 query=query,
399864 ids=ids,
400865 tv_data=next_tv_data,
401866 rescrape=True,
402867 preferred_group=self.preferred_group,
868 autoplay_context=str(AUTOPLAY_CONTEXT_NEXT_EPISODE),
869 skip_cancel_on_back=True,
403870 )
404871
405 # Deduplication: Check if this URL is already in the playlist
406 is_in_playlist = False
407 for i in range(self.PLAYLIST.size()):
408 if self.PLAYLIST[i].getPath() == url:
409 is_in_playlist = True
410 break
872 # Deduplication: Check if this URL is already in the playlist
873 for i in range(self.PLAYLIST.size()):
874 if self.PLAYLIST[i].getPath() == url:
875 kodilog(f"[PLAYNEXT] build_playlist: duplicate URL, skipping: {url}")
876 return
411877
412 if is_in_playlist:
413 continue
878 kodilog(f"[PLAYNEXT] build_playlist: adding to PLAYLIST label={label}, url={url}")
414879
415 list_item = ListItem(label=label)
416 list_item.setPath(url)
417 list_item.setProperty("IsPlayable", "true")
880 list_item = ListItem(label=label)
881 list_item.setPath(url)
882 list_item.setProperty("IsPlayable", "true")
418883
419 self.PLAYLIST.add(url=url, listitem=list_item)
884 self.PLAYLIST.add(url=url, listitem=list_item)
420885
421886 def kill_dialog(self):
422887 close_all_dialog()
……@@ -426,12 +891,15 @@ class JacktookPLayer(xbmc.Player):
426891
427892 def set_constants(self, data):
428893 self.PLAYLIST.clear()
894 if not (data.get("playnext_context") or data.get("autoplay")):
895 clear_property("jacktook_consecutive_autoplays")
429896 self.data = data
430897 self.url = self.data["url"]
431898 self.watched_percentage = self.data.get("progress", 0.0)
432899 self.total_time = 0
433900 self.current_time = 0
434901 self.playback_started_properly = False
902 self.autoscrape_started = False
435903 self.next_dialog = get_setting("playnext_dialog_enabled")
436904 self._is_trakt_scrobble_active = False
437905 self._playback_was_paused = False
……@@ -445,6 +913,11 @@ class JacktookPLayer(xbmc.Player):
445913 self.skip_intro_segments = None
446914 self.skip_intro_handled = {"intro": False, "recap": False}
447915
916 # Stinger notification state
917 self.stinger_notified = False
918 self.stinger_keywords = []
919 self.has_stinger = False
920
448921 def fetch_introdb_segments(self):
449922 """Fetch segment data from IntroDB in a background thread."""
450923 try:
……@@ -492,9 +965,7 @@ class JacktookPLayer(xbmc.Player):
492965 if self.skip_intro_auto:
493966 self.seekTime(end_sec)
494967 else:
495 label = (
496 "Skip Intro" if segment_type == "intro" else "Skip Recap"
497 )
968 label = "Skip Intro" if segment_type == "intro" else "Skip Recap"
498969 xbmc.executebuiltin(
499970 action_url_run(
500971 name="run_skip_intro_dialog",
……@@ -507,6 +978,49 @@ class JacktookPLayer(xbmc.Player):
507978 except Exception as e:
508979 kodilog(f"Error in check_skip_intro: {e}")
509980
981 def fetch_stinger_info(self):
982 """Fetch stinger keywords from TMDB in a background thread."""
983 try:
984 if not get_setting("stinger_notifications_enabled"):
985 return
986 if self.data.get("mode") != "movies":
987 return
988
989 ids = self.data.get("ids", {})
990 tmdb_id = ids.get("tmdb_id")
991 if not tmdb_id:
992 kodilog("Stinger: Missing TMDB ID")
993 return
994
995 self.stinger_keywords = get_movie_keywords(tmdb_id)
996 self.has_stinger = any(
997 k.lower() in ("aftercreditsstinger", "duringcreditsstinger")
998 for k in self.stinger_keywords
999 )
1000 kodilog(f"Stinger keywords: {self.stinger_keywords}, has_stinger: {self.has_stinger}")
1001 except Exception as e:
1002 kodilog(f"Error fetching stinger info: {e}")
1003
1004 def check_stinger_notification(self):
1005 try:
1006 if not get_setting("stinger_notifications_enabled"):
1007 return
1008 if not self.has_stinger or self.stinger_notified:
1009 return
1010 if not getattr(self, "total_time", None) or self.total_time < 60:
1011 return
1012 if not getattr(self, "current_time", None):
1013 return
1014
1015 time_left = int(self.total_time) - int(self.current_time)
1016 stinger_notification_time = int(get_setting("stinger_notification_time", 180))
1017
1018 if time_left <= stinger_notification_time:
1019 notification(translation(90183), time=5000)
1020 self.stinger_notified = True
1021 except Exception as e:
1022 kodilog(f"Error in check_stinger_notification: {e}")
1023
5101024 def clear_playback_properties(self):
5111025 clear_property("script.trakt.ids")
5121026
……@@ -526,9 +1040,7 @@ class JacktookPLayer(xbmc.Player):
5261040 set_property("script.trakt.ids", json_dumps(trakt_ids))
5271041
5281042 def get_player_streams(self):
529 activePlayers = (
530 '{"jsonrpc": "2.0", "method": "Player.GetActivePlayers", "id": 1}'
531 )
1043 activePlayers = '{"jsonrpc": "2.0", "method": "Player.GetActivePlayers", "id": 1}'
5321044 json_query = xbmc.executeJSONRPC(activePlayers)
5331045 json_response = loads(json_query)
5341046 if not json_response.get("result"):
……@@ -573,6 +1085,7 @@ class JacktookPLayer(xbmc.Player):
5731085 set_watched_file(data)
5741086
5751087 def cancel_playback(self):
1088 kodilog("[PLAYER] cancel_playback called")
5761089 self._cleanup_playback_session()
5771090 try:
5781091 setResolvedUrl(ADDON_HANDLE, False, ListItem(offscreen=True))
……@@ -580,8 +1093,10 @@ class JacktookPLayer(xbmc.Player):
5801093 kodilog(
5811094 f"setResolvedUrl failed in cancel_playback (expected when using direct play): {e}"
5821095 )
1096 self.stop()
5831097
5841098 def _cleanup_playback_session(self):
1099 kodilog("[PLAYER] _cleanup_playback_session: clearing PLAYLIST, closing dialogs")
5851100 self.PLAYLIST.clear()
5861101 close_busy_dialog()
5871102 close_all_dialog()
plugin.video.jacktook/lib/router.py +152 -68
……@@ -1,4 +1,3 @@
1# -*- coding: utf-8 -*-
21"""
32Jacktook addon router — lazy-loading dispatcher.
43
……@@ -6,10 +5,10 @@ Uses prefix-based routing with lazy imports to avoid loading all addon
65modules on every invocation. Only the modules needed for the current
76action are imported.
87"""
8
99import sys
1010from urllib import parse
1111
12
1312# ---------------------------------------------------------------------------
1413# Action sets for set-based lookups
1514# ---------------------------------------------------------------------------
……@@ -35,13 +34,17 @@ _DEBRID_ACTIONS = frozenset(
3534 "debrider_info",
3635 "get_rd_downloads",
3736 "get_tb_downloads",
37 "get_oc_downloads",
3838 "cloud",
3939 "cloud_details",
4040 "pm_auth",
4141 "pm_remove_auth",
4242 "tb_auth",
4343 "tb_remove_auth",
44 "oc_auth",
45 "oc_remove_auth",
4446 "torbox_info",
47 "offcloud_info",
4548 "easynews_info",
4649 }
4750)
……@@ -54,6 +57,8 @@ _TORRSERVER_ACTIONS = frozenset(
5457 "torrentio_selection",
5558 "display_picture",
5659 "display_text",
60 "download_torrent_subtitles",
61 "download_and_play_subtitles",
5762 }
5863)
5964
……@@ -71,8 +76,13 @@ _DOWNLOAD_ACTIONS = frozenset(
7176 "download_video",
7277 "handle_download_file",
7378 "handle_cancel_download",
79 "handle_pause_download",
7480 "handle_delete_file",
7581 "downloads_menu",
82 "resume_download",
83 "download_cloud_file",
84 "open_download_manager",
85 "downloads_viewer",
7686 }
7787)
7888
……@@ -106,13 +116,18 @@ def _is_stremio_action(action):
106116 "search_catalog",
107117 "add_custom_stremio_addon",
108118 "remove_custom_stremio_addon",
119 "rename_stremio_addon",
120 "rename_stremio_catalog",
109121 "torrentio_toggle_providers",
110122 "stremio_bypass_addons_select",
111123 }
112124
113125
114126def _is_tmdb_action(action):
115 return action in ("tmdb_search_modes", "tmdb_episode_search_modes") or action.startswith(
127 return action in (
128 "tmdb_search_modes",
129 "tmdb_episode_search_modes",
130 ) or action.startswith(
116131 (
117132 "handle_tmdb",
118133 "search_tmdb",
……@@ -163,6 +178,10 @@ def _is_gui_action(action):
163178 return action in _GUI_ACTIONS
164179
165180
181def _is_source_manager_action(action):
182 return action == "source_manager_toggle"
183
184
166185def _get_route_handler(action):
167186 for matcher, handler in ROUTE_GROUPS:
168187 if matcher(action):
……@@ -208,16 +227,20 @@ def _route_stremio(action, params):
208227 "stremio_filtered_selection",
209228 "add_custom_stremio_addon",
210229 "remove_custom_stremio_addon",
230 "rename_stremio_addon",
231 "rename_stremio_catalog",
211232 "stremio_bypass_addons_select",
212233 ):
213234 from lib.clients.stremio.addon_selection import (
214 stremio_toggle_addons,
215 stremio_toggle_catalogs,
216 stremio_toggle_tv_addons,
217 stremio_filtered_selection,
218235 add_custom_stremio_addon,
219236 remove_custom_stremio_addon,
237 rename_stremio_addon,
238 rename_stremio_catalog,
220239 stremio_bypass_addons_select,
240 stremio_filtered_selection,
241 stremio_toggle_addons,
242 stremio_toggle_catalogs,
243 stremio_toggle_tv_addons,
221244 )
222245
223246 actions = {
……@@ -227,6 +250,8 @@ def _route_stremio(action, params):
227250 "stremio_filtered_selection": stremio_filtered_selection,
228251 "add_custom_stremio_addon": add_custom_stremio_addon,
229252 "remove_custom_stremio_addon": remove_custom_stremio_addon,
253 "rename_stremio_addon": rename_stremio_addon,
254 "rename_stremio_catalog": rename_stremio_catalog,
230255 "stremio_bypass_addons_select": stremio_bypass_addons_select,
231256 }
232257 actions[action](params)
……@@ -377,11 +402,11 @@ def _route_trakt(action, params):
377402 actions[action](params)
378403 else:
379404 from lib.navigation import (
405 list_trakt_page,
380406 trakt_auth,
381407 trakt_auth_revoke,
382408 trakt_group_menu,
383409 trakt_list_content,
384 list_trakt_page,
385410 )
386411
387412 actions = {
……@@ -398,25 +423,29 @@ def _route_trakt(action, params):
398423
399424def _route_debrid(action, params):
400425 from lib.navigation import (
401 rd_auth,
402 rd_remove_auth,
403426 ad_auth,
404427 ad_remove_auth,
405 real_debrid_info,
406428 alldebrid_info,
429 cloud,
430 cloud_details,
407431 debrider_auth,
408 debrider_remove_auth,
409432 debrider_info,
433 debrider_remove_auth,
434 easynews_info,
435 get_oc_downloads,
410436 get_rd_downloads,
411437 get_tb_downloads,
412 cloud,
413 cloud_details,
438 offcloud_info,
439 oc_auth,
440 oc_remove_auth,
414441 pm_auth,
415442 pm_remove_auth,
443 rd_auth,
444 rd_remove_auth,
445 real_debrid_info,
416446 tb_auth,
417447 tb_remove_auth,
418448 torbox_info,
419 easynews_info,
420449 )
421450
422451 actions = {
……@@ -431,13 +460,17 @@ def _route_debrid(action, params):
431460 "debrider_info": debrider_info,
432461 "get_rd_downloads": get_rd_downloads,
433462 "get_tb_downloads": get_tb_downloads,
463 "get_oc_downloads": get_oc_downloads,
434464 "cloud": cloud,
435465 "cloud_details": cloud_details,
436466 "pm_auth": pm_auth,
437467 "pm_remove_auth": pm_remove_auth,
438468 "tb_auth": tb_auth,
439469 "tb_remove_auth": tb_remove_auth,
470 "oc_auth": oc_auth,
471 "oc_remove_auth": oc_remove_auth,
440472 "torbox_info": torbox_info,
473 "offcloud_info": offcloud_info,
441474 "easynews_info": easynews_info,
442475 }
443476 actions[action](params)
……@@ -450,9 +483,9 @@ def _route_telegram(action, params):
450483 telegram_menu(params)
451484 else:
452485 from lib.clients.jackgram.utils import (
453 list_jackgram_raw_files,
454486 list_jackgram_latest_movies,
455487 list_jackgram_latest_series,
488 list_jackgram_raw_files,
456489 list_jackgram_title_sources,
457490 )
458491
……@@ -470,10 +503,10 @@ def _route_telegram(action, params):
470503def _route_torrserver(action, params):
471504 if action in ("torrent_action", "torrent_files", "display_picture", "display_text"):
472505 from lib.utils.torrent.torrserver_utils import (
473 torrent_action,
474 torrent_files,
475506 display_picture,
476507 display_text,
508 torrent_action,
509 torrent_files,
477510 )
478511
479512 actions = {
……@@ -483,6 +516,17 @@ def _route_torrserver(action, params):
483516 "display_text": display_text,
484517 }
485518 actions[action](params)
519 elif action in ("download_torrent_subtitles", "download_and_play_subtitles"):
520 from lib.utils.torrent.torrserver_utils import (
521 download_and_play_subtitles,
522 download_torrent_subtitles,
523 )
524
525 actions = {
526 "download_torrent_subtitles": download_torrent_subtitles,
527 "download_and_play_subtitles": download_and_play_subtitles,
528 }
529 actions[action](params)
486530 elif action == "torrents":
487531 from lib.navigation import torrents
488532
……@@ -495,10 +539,10 @@ def _route_torrserver(action, params):
495539
496540def _route_webdav(action, params):
497541 from lib.clients.webdav.client import (
498 list_webdav,
499 webdav_provider_test,
500542 display_text_webdav,
543 list_webdav,
501544 show_picture_webdav,
545 webdav_provider_test,
502546 )
503547
504548 actions = {
……@@ -515,26 +559,42 @@ def _route_downloads(action, params):
515559 "download_video",
516560 "handle_download_file",
517561 "handle_cancel_download",
562 "handle_pause_download",
518563 "handle_delete_file",
564 "resume_download",
565 "download_cloud_file",
566 "downloads_viewer",
519567 ):
520568 from lib.downloader import (
569 download_cloud_file,
521570 download_video,
522 handle_download_file,
571 downloads_viewer,
523572 handle_cancel_download,
524573 handle_delete_file,
574 handle_download_file,
575 handle_pause_download,
576 resume_download,
525577 )
526578
527579 actions = {
528580 "download_video": download_video,
529581 "handle_download_file": handle_download_file,
530582 "handle_cancel_download": handle_cancel_download,
583 "handle_pause_download": handle_pause_download,
531584 "handle_delete_file": handle_delete_file,
585 "resume_download": resume_download,
586 "download_cloud_file": download_cloud_file,
587 "downloads_viewer": downloads_viewer,
532588 }
533589 actions[action](params)
534590 elif action == "downloads_menu":
535591 from lib.navigation import downloads_menu
536592
537593 downloads_menu(params)
594 elif action == "open_download_manager":
595 from lib.navigation import open_download_manager
596
597 open_download_manager(params)
538598
539599
540600def _route_mdblist(action, params):
……@@ -557,14 +617,14 @@ def _route_mdblist(action, params):
557617def _route_cache(action, params):
558618 from lib.navigation import (
559619 clear_all_cached,
560 clear_trakt_cache,
561 clear_tmdb_cache,
562 clear_stremio_cache,
563 clear_debrid_cache,
564 clear_mdblist_cache,
565620 clear_database_cache,
621 clear_debrid_cache,
566622 clear_history,
623 clear_mdblist_cache,
567624 clear_search_history,
625 clear_stremio_cache,
626 clear_tmdb_cache,
627 clear_trakt_cache,
568628 )
569629
570630 actions = {
……@@ -618,10 +678,10 @@ def _route_gui(action, params):
618678 del window
619679 else:
620680 from lib.navigation import (
621 test_source_select,
622 test_run_next,
623 test_resume_dialog,
624681 test_download_dialog,
682 test_resume_dialog,
683 test_run_next,
684 test_source_select,
625685 )
626686
627687 actions = {
……@@ -635,7 +695,28 @@ def _route_gui(action, params):
635695 action_func(params)
636696
637697
698def _route_source_manager(action, params):
699 from lib.gui.source_manager_dialog import open_source_manager_dialog
700
701 open_source_manager_dialog()
702
703
638704def _route_core(action, params):
705 if action == "open_excluded_languages_dialog":
706 from lib.gui.excluded_languages_dialog import open_excluded_languages_dialog
707
708 open_excluded_languages_dialog()
709 return
710 if action == "choose_external_scraper":
711 from lib.gui.external_scraper_dialog import choose_external_scraper
712
713 choose_external_scraper()
714 return
715 if action == "open_external_scraper_settings":
716 from lib.gui.external_scraper_dialog import open_external_scraper_settings
717
718 open_external_scraper_settings()
719 return
639720 if action in ("resolve_for_pack_selection", "resolve_for_subtitles"):
640721 from lib.actions import resolve_for_pack_selection, resolve_for_subtitles
641722
……@@ -661,54 +742,55 @@ def _route_core(action, params):
661742 delete_last_title_entry(params)
662743 else:
663744 from lib.navigation import (
664 tv_shows_items,
665 movies_items,
666 trakt_group_menu,
667 direct_menu,
668 search_menu,
669 anime_menu,
745 add_to_library,
746 addon_update,
747 animation_item,
748 animation_menu,
670749 anime_item,
750 anime_menu,
671751 anime_search,
672 animation_menu,
673 animation_item,
674 tv_menu,
675 search,
676 search_direct,
677 search_item,
678 next_page_anime,
679 play_media,
680 play_from_pack,
681 play_trailer,
682 play_url,
683 settings_menu,
684 settings,
752 choose_view,
753 clear_library,
754 continue_watching_menu,
755 direct_menu,
756 donate,
757 downgrade_addon,
758 easynews_info,
685759 export_settings_backup,
686 restore_settings_backup,
687 reset_all_settings,
688760 factory_reset,
689 history_menu,
690761 files_history,
691 titles_history,
692 titles_calendar,
762 history_menu,
763 kodi_logs,
764 library_calendar,
693765 library_menu,
694 continue_watching_menu,
695 library_shows,
696766 library_movies,
697 library_calendar,
698 remove_from_library,
699 clear_library,
700 remove_from_continue_watching,
701 add_to_library,
702 donate,
703 addon_update,
704 downgrade_addon,
705 show_changelog,
767 library_shows,
768 movies_items,
769 next_page_anime,
706770 open_burst_config,
707 kodi_logs,
708 easynews_info,
709 choose_view,
710 save_view,
771 play_autoscraped,
772 play_from_pack,
773 play_media,
774 play_trailer,
775 play_url,
776 remove_from_continue_watching,
777 remove_from_library,
778 reset_all_settings,
711779 reset_views,
780 restore_settings_backup,
781 save_view,
782 search,
783 search_direct,
784 search_item,
785 search_menu,
786 settings,
787 settings_menu,
788 show_changelog,
789 titles_calendar,
790 titles_history,
791 trakt_group_menu,
792 tv_menu,
793 tv_shows_items,
712794 views_menu,
713795 )
714796
……@@ -729,6 +811,7 @@ def _route_core(action, params):
729811 "search_item": search_item,
730812 "next_page_anime": next_page_anime,
731813 "play_media": play_media,
814 "play_autoscraped": play_autoscraped,
732815 "play_from_pack": play_from_pack,
733816 "play_trailer": play_trailer,
734817 "play_url": play_url,
……@@ -780,4 +863,5 @@ ROUTE_GROUPS = (
780863 (_is_mdblist_action, _route_mdblist),
781864 (_is_cache_action, _route_cache),
782865 (_is_gui_action, _route_gui),
866 (_is_source_manager_action, _route_source_manager),
783867)
plugin.video.jacktook/lib/search.py +593 -327
……@@ -1,75 +1,156 @@
1from concurrent.futures import ThreadPoolExecutor, as_completed
1import json
2from concurrent.futures import Future, ThreadPoolExecutor, as_completed
23from typing import List, Optional
34
5import xbmc
6from xbmcgui import Dialog
7
8from lib.clients.stremio.addon_client import StremioAddonClient
9from lib.clients.stremio.constants import STREMIO_ADDON_ALIASES_KEY, STREMIO_ADDONS_KEY
410from lib.clients.stremio.helpers import (
511 get_addon_by_base_url,
12 get_addon_display_name,
613 get_selected_stream_addons,
714)
8from lib.clients.stremio.constants import STREMIO_ADDONS_KEY
9from lib.clients.stremio.addon_client import StremioAddonClient
1015from lib.db.cached import cache
1116from lib.domain.torrent import TorrentStream
1217from lib.gui.custom_dialogs import source_select
18from lib.gui.search_status_window import SearchStatusWindow, SearchTaskManager
1319from lib.player import JacktookPLayer
1420from lib.utils.clients.utils import get_client, update_dialog
21from lib.utils.debrid.debrid_utils import check_debrid_cached
1522from lib.utils.general.utils import (
1623 DialogListener,
1724 Indexer,
1825 SearchVariant,
26 build_media_metadata,
1927 cache_results,
28 clean_auto_play_undesired,
2029 get_cached_results,
21 pre_process,
30 normalize_tv_data,
2231 post_process,
23 build_media_metadata,
32 pre_process,
33 safe_json_loads,
2434 set_content_type,
2535 set_watched_title,
26 clean_auto_play_undesired,
27 normalize_tv_data,
28 safe_json_loads,
2936)
30from lib.utils.debrid.debrid_utils import check_debrid_cached
3137from lib.utils.kodi.settings import auto_play_enabled, get_setting
32from lib.utils.player.utils import resolve_playback_url
3338from lib.utils.kodi.utils import (
34 notification,
39 ADDON_PATH,
3540 cancel_playback,
41 close_busy_dialog,
3642 kodilog,
43 notification,
3744 translation,
38 close_busy_dialog,
39 ADDON_PATH,
4045)
41
42from lib.gui.search_status_window import SearchTaskManager, SearchStatusWindow
43
44import xbmc
45from xbmcgui import Dialog
46
46from lib.utils.player.utils import resolve_playback_url
4747
4848TITLE_LANGUAGE_LOCALIZED_FIRST = "localized_first"
4949TITLE_LANGUAGE_ENGLISH_FIRST = "english_first"
5050TITLE_LANGUAGE_ENGLISH_ONLY = "english_only"
5151
5252
53class SearchCancelled(Exception):
54 """Raised when the user cancels an interactive search flow."""
55
56 def __init__(self, results: Optional[List[TorrentStream]] = None):
57 super().__init__("Search cancelled")
58 self.results = results or []
59
60
61class SilentProgressDialog:
62 """No-op progress dialog for flows that should not show Kodi progress UI."""
63
64 def create(self, *args, **kwargs):
65 pass
66
67 def update(self, *args, **kwargs):
68 pass
69
70 def close(self):
71 pass
72
73
5374def _build_search_cache_scope(scoped_addon_url: str = "") -> str:
5475 flags = {
5576 "torrent": bool(get_setting("torrent_enable")),
77 "external_scraper": bool(get_setting("external_scraper_enabled")),
5678 "stremio": bool(get_setting("stremio_enabled")),
5779 "rd": bool(get_setting("real_debrid_enabled")),
5880 "ad": bool(get_setting("alldebrid_enabled")),
5981 "tb": bool(get_setting("torbox_enabled")),
82 "oc": bool(get_setting("offcloud_enabled")),
6083 "pm": bool(get_setting("premiumize_enabled")),
6184 "db": bool(get_setting("debrider_enabled")),
6285 "ed": bool(get_setting("easydebrid_enabled")),
6386 }
6487 selected_stream_addons = str(cache.get(STREMIO_ADDONS_KEY) or "")
88 stremio_addon_aliases = json.dumps(
89 cache.get(STREMIO_ADDON_ALIASES_KEY) or {}, sort_keys=True
90 )
91 source_manager_selection = str(cache.get("source_manager_selection") or "")
92 external_scraper_module = str(get_setting("external_scraper_module") or "")
6593 normalized_scoped_url = str(scoped_addon_url or "")
66 return "{}|{}|{}".format(
94 return "{}|{}|{}|{}|{}|{}".format(
6795 "|".join([f"{key}:{int(value)}" for key, value in sorted(flags.items())]),
6896 selected_stream_addons,
97 stremio_addon_aliases,
98 source_manager_selection,
99 external_scraper_module,
69100 normalized_scoped_url,
70101 )
71102
72103
104BUILTIN_INDEXER_SETTINGS = {
105 Indexer.JACKETT: "jackett_enabled",
106 Indexer.PROWLARR: "prowlarr_enabled",
107 Indexer.BURST: "jacktookburst_enabled",
108 Indexer.JACKGRAM: "jackgram_enabled",
109 Indexer.EASYNEWS: "easynews_enabled",
110 Indexer.STREMIO: "stremio_enabled",
111 Indexer.EXTERNAL_SCRAPER: "external_scraper_enabled",
112}
113
114
115def _get_source_manager_selection() -> set:
116 """Return the cached source-manager selection as a set of strings.
117
118 If the cache is empty, missing, or corrupt, returns an empty set so
119 that callers treat every source as enabled (open fallback).
120 """
121 raw = cache.get("source_manager_selection")
122 if not raw:
123 return set()
124 try:
125 parsed = json.loads(raw) if isinstance(raw, str) else list(raw)
126 except (ValueError, TypeError):
127 return set()
128 return set(parsed) if parsed else set()
129
130
131def _is_source_enabled(indexer_key, stremio_addon_key=None):
132 selected = _get_source_manager_selection()
133
134 # Empty cache → everything is enabled (no whitelist restriction)
135 if not selected:
136 return True
137
138 # Stremio addons are keyed with a "Stremio:" prefix
139 if stremio_addon_key:
140 return f"Stremio:{stremio_addon_key}" in selected
141
142 # External scraper uses its configured module name as the cache key.
143 # The fallback literal "External Scraper" supports older caches.
144 if indexer_key == Indexer.EXTERNAL_SCRAPER:
145 if not get_setting("external_scraper_enabled"):
146 return False
147 module_name = get_setting("external_scraper_module_name") or "External Scraper"
148 return module_name in selected or "External Scraper" in selected
149
150 # Managed builtin sources are enabled only when explicitly selected.
151 return str(indexer_key) in selected
152
153
73154def _clean_title_candidate(value) -> str:
74155 if value is None:
75156 return ""
……@@ -133,6 +214,27 @@ def _extract_english_tmdb_title(details, mode: str) -> str:
133214 return ""
134215
135216
217def _infer_tmdb_year(ids: dict, mode: str) -> Optional[int]:
218 if not ids or mode not in ("movies", "tv"):
219 return None
220 tmdb_id = ids.get("tmdb_id")
221 if not tmdb_id:
222 return None
223 try:
224 from lib.clients.tmdb.utils.utils import get_tmdb_media_details
225
226 details = get_tmdb_media_details(tmdb_id, mode)
227 date_value = (
228 getattr(details, "release_date", None)
229 if mode == "movies"
230 else getattr(details, "first_air_date", None)
231 )
232 return int(str(date_value)[:4]) if date_value else None
233 except Exception as e:
234 kodilog(f"Failed to infer TMDB year: {e}")
235 return None
236
237
136238def _build_title_fallback_queries(
137239 query: str,
138240 ids: dict,
……@@ -180,9 +282,7 @@ def _build_title_fallback_queries(
180282 return _unique_title_candidates(candidates)
181283
182284 english_title = _extract_english_tmdb_title(details, mode)
183 original_title = getattr(details, "original_title", "") or getattr(
184 details, "original_name", ""
185 )
285 original_title = getattr(details, "original_title", "") or getattr(details, "original_name", "")
186286
187287 english_candidate = ""
188288 original_candidate = ""
……@@ -195,16 +295,7 @@ def _build_title_fallback_queries(
195295 if original_title:
196296 original_candidate = f"{original_title} {cleaned_year}"
197297
198 if variant == SearchVariant.DEFAULT:
199 if title_language_mode == TITLE_LANGUAGE_ENGLISH_ONLY:
200 candidates = [english_candidate, original_candidate]
201 if not any(candidates) and localized_candidate:
202 candidates.append(localized_candidate)
203 elif title_language_mode == TITLE_LANGUAGE_ENGLISH_FIRST:
204 candidates = [english_candidate, original_candidate, localized_candidate]
205 else:
206 candidates = [localized_candidate, english_candidate, original_candidate]
207 elif variant == SearchVariant.TITLE_YEAR:
298 if variant == SearchVariant.DEFAULT or variant == SearchVariant.TITLE_YEAR:
208299 if title_language_mode == TITLE_LANGUAGE_ENGLISH_ONLY:
209300 candidates = [english_candidate, original_candidate]
210301 if not any(candidates) and localized_candidate:
……@@ -262,7 +353,14 @@ def _perform_search_with_title_fallback(
262353 )
263354
264355 results = _perform_search(
265 indexer_key, dialog, candidate, mode, *args, variant=variant, year=year, **kwargs
356 indexer_key,
357 dialog,
358 candidate,
359 mode,
360 *args,
361 variant=variant,
362 year=year,
363 **kwargs,
266364 )
267365 if results:
268366 return results
……@@ -281,7 +379,7 @@ def _handle_super_quick_play(params: dict) -> bool:
281379 if key:
282380 tv_data = safe_json_loads(params.get("tv_data") or "{}")
283381 if tv_data and "season" in tv_data and "episode" in tv_data:
284 key += f'_{tv_data["season"]}_{tv_data["episode"]}'
382 key += f"_{tv_data['season']}_{tv_data['episode']}"
285383
286384 if not key:
287385 kodilog("Super quick play: No valid key found from ids")
……@@ -332,6 +430,7 @@ def _process_search_results(
332430 query,
333431 media_type,
334432 rescrape,
433 suppress_debrid_dialog=False,
335434):
336435 bypassed_streams = []
337436 other_results = results
……@@ -344,19 +443,11 @@ def _process_search_results(
344443 if result.addonKey and result.addonKey in bypass_addons:
345444 return True
346445
347 legacy_name = (result.addonName or result.indexer or "").lower()
446 legacy_name = (result.addonSourceName or result.indexer or "").lower()
348447 return any(addon == legacy_name for addon in bypass_addons)
349448
350 bypassed_streams = [
351 res
352 for res in results
353 if is_bypassed(res)
354 ]
355 other_results = [
356 res
357 for res in results
358 if not is_bypassed(res)
359 ]
449 bypassed_streams = [res for res in results if is_bypassed(res)]
450 other_results = [res for res in results if not is_bypassed(res)]
360451
361452 pre_results = []
362453 if other_results:
……@@ -372,7 +463,13 @@ def _process_search_results(
372463 post_results = []
373464 if pre_results:
374465 post_results = process_results(
375 pre_results, query, mode, media_type, rescrape, episode
466 pre_results,
467 query,
468 mode,
469 media_type,
470 rescrape,
471 episode,
472 suppress_dialog=suppress_debrid_dialog,
376473 )
377474
378475 # Combine results, prioritizing bypassed streams exact native sorting
……@@ -390,6 +487,7 @@ def run_search_entry(params: dict):
390487 tv_data = normalize_tv_data(safe_json_loads(params.get("tv_data")))
391488 direct = params.get("direct", False)
392489 rescrape = params.get("rescrape", False)
490 skip_cancel = params.get("skip_cancel_on_back", False)
393491
394492 variant = _normalize_search_variant(params.get("search_variant", SearchVariant.DEFAULT))
395493 title_language_mode = _normalize_title_language_mode(
……@@ -406,8 +504,11 @@ def run_search_entry(params: dict):
406504 year = None
407505
408506 kodilog(
409 f"run_search_entry received: query={query}, mode={mode}, media_type={media_type}, variant={variant}, title_language_mode={title_language_mode}, year={year}, rescrape={rescrape}, force_select={params.get('force_select', False)}"
507 f"run_search_entry received: query={query}, mode={mode}, media_type={media_type}, "
508 f"variant={variant}, title_language_mode={title_language_mode}, year={year}, "
509 f"rescrape={rescrape}, force_select={params.get('force_select', False)}"
410510 )
511 kodilog(f"[PLAYNEXT] run_search_entry: tv_data from params={tv_data}, ids={ids}")
411512
412513 library_data = None
413514 if params.get("stremio_addon_url") and params.get("stremio_catalog_type"):
……@@ -427,22 +528,30 @@ def run_search_entry(params: dict):
427528
428529 scoped_addon_url = params.get("scoped_addon_url", "")
429530
430 results = search_client(
431 query,
432 ids,
433 mode,
434 media_type,
435 rescrape,
436 season,
437 episode,
438 scoped_addon_url=scoped_addon_url,
439 variant=variant,
440 title_language_mode=title_language_mode,
441 year=year,
442 )
531 suppress_debrid_dialog = False
532 try:
533 results = search_client(
534 query,
535 ids,
536 mode,
537 media_type,
538 rescrape,
539 season,
540 episode,
541 scoped_addon_url=scoped_addon_url,
542 variant=variant,
543 title_language_mode=title_language_mode,
544 year=year,
545 )
546 except SearchCancelled as exc:
547 kodilog("Search cancelled from detailed status window")
548 results = exc.results
549 suppress_debrid_dialog = True
550
443551 if not results:
444552 notification("No results found")
445 cancel_playback()
553 if not skip_cancel:
554 cancel_playback()
446555 return
447556
448557 final_results = _process_search_results(
……@@ -455,18 +564,28 @@ def run_search_entry(params: dict):
455564 query,
456565 media_type,
457566 rescrape,
567 suppress_debrid_dialog=suppress_debrid_dialog,
458568 )
459569
460570 if not final_results:
461571 notification(translation(90358))
462 cancel_playback()
572 if not skip_cancel:
573 cancel_playback()
463574 return
464575
465576 preferred_group = params.get("preferred_group")
466577 force_select = params.get("force_select", False)
578 autoplay_context = params.get("autoplay_context")
467579
468580 if auto_play_enabled() and not force_select:
469 if not auto_play(final_results, ids, tv_data, mode, preferred_group):
581 if not auto_play(
582 final_results,
583 ids,
584 tv_data,
585 mode,
586 preferred_group,
587 autoplay_context=autoplay_context,
588 ) and not skip_cancel:
470589 cancel_playback()
471590 return
472591
……@@ -479,7 +598,8 @@ def run_search_entry(params: dict):
479598 media_type,
480599 rescrape,
481600 direct,
482 ):
601 autoplay_context=autoplay_context,
602 ) and not skip_cancel:
483603 cancel_playback()
484604
485605
……@@ -521,9 +641,9 @@ def _perform_search(indexer_key, dialog, *args, **kwargs):
521641
522642 # Try TMDB ID for addons that declare tmdb: prefix
523643 if not video_id and ids_dict.get("tmdb_id"):
524 if addon.isSupported("stream", media_kind, "tmdb:"):
525 video_id = f"tmdb:{ids_dict['tmdb_id']}"
526 elif addon.isSupported("stream", media_kind, "tmdb"):
644 if addon.isSupported("stream", media_kind, "tmdb:") or addon.isSupported(
645 "stream", media_kind, "tmdb"
646 ):
527647 video_id = f"tmdb:{ids_dict['tmdb_id']}"
528648
529649 if video_id:
……@@ -535,11 +655,13 @@ def _perform_search(indexer_key, dialog, *args, **kwargs):
535655
536656 return results
537657
658 kodilog(f"[ExternalScraper] _perform_search for {indexer_key}, getting client...")
538659 client = get_client(indexer_key)
660 kodilog(f"[ExternalScraper] client={client}")
539661 if not client:
540662 return []
541663
542 if indexer_key == Indexer.BURST and not show_dialog:
664 if indexer_key == Indexer.BURST:
543665 kwargs["silent"] = True
544666
545667 return client.search(*args, **kwargs)
……@@ -562,6 +684,7 @@ def _submit_search_tasks(
562684 variant: str = SearchVariant.DEFAULT,
563685 title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST,
564686 year: Optional[int] = None,
687 title_aliases: Optional[List[str]] = None,
565688):
566689 def submit_performer(*args, **kwargs):
567690 if "show_dialog" not in kwargs:
……@@ -581,49 +704,54 @@ def _submit_search_tasks(
581704 )
582705
583706 if scoped_addon_url:
584 if ids.get("imdb_id") or ids.get("original_id") or ids.get("tmdb_id"):
707 addon = get_addon_by_base_url(scoped_addon_url)
708 if (
709 addon
710 and (ids.get("imdb_id") or ids.get("original_id") or ids.get("tmdb_id"))
711 and _is_source_enabled(Indexer.STREMIO, addon.key())
712 ):
585713 tasks.append(
586 submit_performer(
587 Indexer.STREMIO, dialog, ids, mode, media_type, season, episode
588 )
714 submit_performer(Indexer.STREMIO, dialog, ids, mode, media_type, season, episode)
589715 )
590716 else:
591 add_task_if_enabled(
592 executor,
593 tasks,
594 "easynews_enabled",
595 Indexer.EASYNEWS,
596 _perform_search_with_title_fallback,
597 dialog,
598 query,
599 ids,
600 mode,
601 media_type,
602 season,
603 episode,
604 show_dialog=show_dialog,
605 scoped_addon_url=scoped_addon_url,
606 variant=variant,
607 title_language_mode=title_language_mode,
608 year=year,
609 )
610 add_task_if_enabled(
611 executor,
612 tasks,
613 "jacktookburst_enabled",
614 Indexer.BURST,
615 _perform_search,
616 dialog,
617 imdb_id,
618 query,
619 mode,
620 media_type,
621 season,
622 episode,
623 show_dialog=show_dialog,
624 scoped_addon_url=scoped_addon_url,
625 )
626 if get_setting("prowlarr_enabled"):
717 if _is_source_enabled(Indexer.EASYNEWS):
718 add_task_if_enabled(
719 executor,
720 tasks,
721 "easynews_enabled",
722 Indexer.EASYNEWS,
723 _perform_search_with_title_fallback,
724 dialog,
725 query,
726 ids,
727 mode,
728 media_type,
729 season,
730 episode,
731 show_dialog=show_dialog,
732 scoped_addon_url=scoped_addon_url,
733 variant=variant,
734 title_language_mode=title_language_mode,
735 year=year,
736 )
737 if _is_source_enabled(Indexer.BURST):
738 add_task_if_enabled(
739 executor,
740 tasks,
741 "jacktookburst_enabled",
742 Indexer.BURST,
743 _perform_search,
744 dialog,
745 imdb_id or "",
746 query,
747 mode,
748 media_type,
749 season,
750 episode,
751 show_dialog=show_dialog,
752 scoped_addon_url=scoped_addon_url,
753 )
754 if get_setting("prowlarr_enabled") and _is_source_enabled(Indexer.PROWLARR):
627755 indexers_ids = get_setting("prowlarr_indexer_ids")
628756 tasks.append(
629757 executor.submit(
……@@ -643,47 +771,70 @@ def _submit_search_tasks(
643771 year=year,
644772 )
645773 )
646 add_task_if_enabled(
647 executor,
648 tasks,
649 "jackett_enabled",
650 Indexer.JACKETT,
651 _perform_search_with_title_fallback,
652 dialog,
653 query,
654 ids,
655 mode,
656 season,
657 episode,
658 show_dialog=show_dialog,
659 scoped_addon_url=scoped_addon_url,
660 variant=variant,
661 title_language_mode=title_language_mode,
662 year=year,
663 )
664 add_task_if_enabled(
665 executor,
666 tasks,
667 "jackgram_enabled",
668 Indexer.JACKGRAM,
669 _perform_search,
670 dialog,
671 tmdb_id,
672 query,
673 mode,
674 media_type,
675 season,
676 episode,
677 show_dialog=show_dialog,
678 scoped_addon_url=scoped_addon_url,
679 )
680 if get_setting("stremio_enabled") and (
681 ids.get("imdb_id") or ids.get("original_id")
774 if _is_source_enabled(Indexer.JACKETT):
775 add_task_if_enabled(
776 executor,
777 tasks,
778 "jackett_enabled",
779 Indexer.JACKETT,
780 _perform_search_with_title_fallback,
781 dialog,
782 query,
783 ids,
784 mode,
785 season,
786 episode,
787 show_dialog=show_dialog,
788 scoped_addon_url=scoped_addon_url,
789 variant=variant,
790 title_language_mode=title_language_mode,
791 year=year,
792 )
793 if _is_source_enabled(Indexer.JACKGRAM):
794 add_task_if_enabled(
795 executor,
796 tasks,
797 "jackgram_enabled",
798 Indexer.JACKGRAM,
799 _perform_search,
800 dialog,
801 tmdb_id,
802 query,
803 mode,
804 media_type,
805 season,
806 episode,
807 show_dialog=show_dialog,
808 scoped_addon_url=scoped_addon_url,
809 )
810 if _is_source_enabled(Indexer.EXTERNAL_SCRAPER):
811 add_task_if_enabled(
812 executor,
813 tasks,
814 "external_scraper_enabled",
815 Indexer.EXTERNAL_SCRAPER,
816 _perform_search,
817 dialog,
818 tmdb_id,
819 query,
820 mode,
821 media_type,
822 season,
823 episode,
824 show_dialog=show_dialog,
825 scoped_addon_url=scoped_addon_url,
826 imdb_id=imdb_id or "",
827 tvdb_id=(ids or {}).get("tvdb_id", ""),
828 year=str(year) if year else "",
829 aliases=title_aliases,
830 )
831 if (
832 get_setting("stremio_enabled")
833 and (ids.get("imdb_id") or ids.get("original_id"))
834 and _is_source_enabled(Indexer.STREMIO)
682835 ):
683836 tasks.append(
684 submit_performer(
685 Indexer.STREMIO, dialog, ids, mode, media_type, season, episode
686 )
837 submit_performer(Indexer.STREMIO, dialog, ids, mode, media_type, season, episode)
687838 )
688839
689840
……@@ -697,9 +848,7 @@ def _collect_search_results(tasks, listener, show_dialog) -> List[TorrentStream]
697848 completed_tasks += 1
698849 if show_dialog and total_tasks > 0:
699850 percent = int(completed_tasks / total_tasks * 100)
700 update_dialog(
701 "Searching", f"Searching... {percent}%", listener.dialog, percent
702 )
851 update_dialog("Searching", f"Searching... {percent}%", listener.dialog, percent)
703852
704853 results = future.result()
705854 kodilog(f"Results from {future}: {results}", level=xbmc.LOGDEBUG)
……@@ -709,9 +858,7 @@ def _collect_search_results(tasks, listener, show_dialog) -> List[TorrentStream]
709858 import traceback
710859
711860 error_details = traceback.format_exc()
712 kodilog(
713 f"Error resolving future result in thread pool: {e}\n{error_details}"
714 )
861 kodilog(f"Error resolving future result in thread pool: {e}\n{error_details}")
715862
716863 return total_results
717864
……@@ -731,6 +878,7 @@ def _submit_search_tasks_managed(
731878 variant: str = SearchVariant.DEFAULT,
732879 title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST,
733880 year: Optional[int] = None,
881 title_aliases: Optional[List[str]] = None,
734882):
735883 def submit_performer_managed(name, indexer_key, *args, **kwargs):
736884 kwargs["show_dialog"] = False
……@@ -753,11 +901,13 @@ def _submit_search_tasks_managed(
753901
754902 if scoped_addon_url:
755903 addon = get_addon_by_base_url(scoped_addon_url)
756 if addon and (
757 ids.get("imdb_id") or ids.get("original_id") or ids.get("tmdb_id")
904 if (
905 addon
906 and (ids.get("imdb_id") or ids.get("original_id") or ids.get("tmdb_id"))
907 and _is_source_enabled(Indexer.STREMIO, addon.key())
758908 ):
759909 submit_performer_managed(
760 addon.manifest.name,
910 get_addon_display_name(addon),
761911 Indexer.STREMIO,
762912 dialog,
763913 ids,
……@@ -768,40 +918,42 @@ def _submit_search_tasks_managed(
768918 scoped_addon_url=scoped_addon_url,
769919 )
770920 else:
771 add_task_if_enabled_managed(
772 manager,
773 "easynews_enabled",
774 Indexer.EASYNEWS,
775 _perform_search_with_title_fallback,
776 dialog,
777 query,
778 ids,
779 mode,
780 media_type,
781 season,
782 episode,
783 show_dialog=False,
784 scoped_addon_url=scoped_addon_url,
785 variant=variant,
786 title_language_mode=title_language_mode,
787 year=year,
788 )
789 add_task_if_enabled_managed(
790 manager,
791 "jacktookburst_enabled",
792 Indexer.BURST,
793 _perform_search,
794 dialog,
795 imdb_id,
796 query,
797 mode,
798 media_type,
799 season,
800 episode,
801 show_dialog=False,
802 scoped_addon_url=scoped_addon_url,
803 )
804 if get_setting("prowlarr_enabled"):
921 if _is_source_enabled(Indexer.EASYNEWS):
922 add_task_if_enabled_managed(
923 manager,
924 "easynews_enabled",
925 Indexer.EASYNEWS,
926 _perform_search_with_title_fallback,
927 dialog,
928 query,
929 ids,
930 mode,
931 media_type,
932 season,
933 episode,
934 show_dialog=False,
935 scoped_addon_url=scoped_addon_url,
936 variant=variant,
937 title_language_mode=title_language_mode,
938 year=year,
939 )
940 if _is_source_enabled(Indexer.BURST):
941 add_task_if_enabled_managed(
942 manager,
943 "jacktookburst_enabled",
944 Indexer.BURST,
945 _perform_search,
946 dialog,
947 imdb_id or "",
948 query,
949 mode,
950 media_type,
951 season,
952 episode,
953 show_dialog=False,
954 scoped_addon_url=scoped_addon_url,
955 )
956 if get_setting("prowlarr_enabled") and _is_source_enabled(Indexer.PROWLARR):
805957 indexers_ids = get_setting("prowlarr_indexer_ids")
806958 manager.submit_task(
807959 "Prowlarr",
……@@ -821,45 +973,71 @@ def _submit_search_tasks_managed(
821973 title_language_mode=title_language_mode,
822974 year=year,
823975 )
824 add_task_if_enabled_managed(
825 manager,
826 "jackett_enabled",
827 Indexer.JACKETT,
828 _perform_search_with_title_fallback,
829 dialog,
830 query,
831 ids,
832 mode,
833 season,
834 episode,
835 show_dialog=False,
836 scoped_addon_url=scoped_addon_url,
837 variant=variant,
838 title_language_mode=title_language_mode,
839 year=year,
840 )
841 add_task_if_enabled_managed(
842 manager,
843 "jackgram_enabled",
844 Indexer.JACKGRAM,
845 _perform_search,
846 dialog,
847 tmdb_id,
848 query,
849 mode,
850 media_type,
851 season,
852 episode,
853 show_dialog=False,
854 scoped_addon_url=scoped_addon_url,
855 )
856 if get_setting("stremio_enabled") and (
857 ids.get("imdb_id") or ids.get("original_id")
858 ):
976 if _is_source_enabled(Indexer.JACKETT):
977 add_task_if_enabled_managed(
978 manager,
979 "jackett_enabled",
980 Indexer.JACKETT,
981 _perform_search_with_title_fallback,
982 dialog,
983 query,
984 ids,
985 mode,
986 season,
987 episode,
988 show_dialog=False,
989 scoped_addon_url=scoped_addon_url,
990 variant=variant,
991 title_language_mode=title_language_mode,
992 year=year,
993 )
994 if _is_source_enabled(Indexer.JACKGRAM):
995 add_task_if_enabled_managed(
996 manager,
997 "jackgram_enabled",
998 Indexer.JACKGRAM,
999 _perform_search,
1000 dialog,
1001 tmdb_id,
1002 query,
1003 mode,
1004 media_type,
1005 season,
1006 episode,
1007 show_dialog=False,
1008 scoped_addon_url=scoped_addon_url,
1009 )
1010 if _is_source_enabled(Indexer.EXTERNAL_SCRAPER):
1011 module_display_name = get_setting("external_scraper_module_name") or "External Scraper"
1012 manager.submit_task(
1013 module_display_name,
1014 Indexer.EXTERNAL_SCRAPER,
1015 _perform_search,
1016 Indexer.EXTERNAL_SCRAPER,
1017 dialog,
1018 tmdb_id,
1019 query,
1020 mode,
1021 media_type,
1022 season,
1023 episode,
1024 show_dialog=False,
1025 scoped_addon_url=scoped_addon_url,
1026 imdb_id=imdb_id or "",
1027 tvdb_id=(ids or {}).get("tvdb_id", ""),
1028 year=str(year) if year else "",
1029 aliases=title_aliases,
1030 )
1031 if get_setting("stremio_enabled") and (ids.get("imdb_id") or ids.get("original_id")):
8591032 stremio_addons = get_selected_stream_addons()
860 for addon in stremio_addons:
1033 filtered_addons = [
1034 addon
1035 for addon in stremio_addons
1036 if _is_source_enabled(Indexer.STREMIO, addon.key())
1037 ]
1038 for addon in filtered_addons:
8611039 submit_performer_managed(
862 addon.manifest.name,
1040 get_addon_display_name(addon),
8631041 Indexer.STREMIO,
8641042 dialog,
8651043 ids,
……@@ -871,54 +1049,73 @@ def _submit_search_tasks_managed(
8711049 )
8721050
8731051
874def search_client(
1052def _check_search_caches(
8751053 query: str,
8761054 ids: dict,
8771055 mode: str,
8781056 media_type: str,
879 rescrape: bool,
880 season: int,
8811057 episode: int,
882 show_dialog: bool = True,
883 scoped_addon_url: str = "",
884 variant: str = SearchVariant.DEFAULT,
885 title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST,
886 year: Optional[int] = None,
887) -> List[TorrentStream]:
888 close_busy_dialog()
889 cache_scope = _build_search_cache_scope(scoped_addon_url)
1058 season: int,
1059 cache_scope: str,
1060) -> Optional[List[TorrentStream]]:
1061 """Check standard cache then autoscrape fallback cache.
1062
1063 Returns cached results if found (even an empty list), or None if
1064 no cache entry exists (caller should perform a fresh search).
1065 """
1066 cached = get_cached_results(query, mode, media_type, episode, cache_scope=cache_scope)
1067 if cached is not None:
1068 return cached
1069
1070 # Fallback: autoscrape cache (PlayNext background scrape) under
1071 # ``as_results:{id}_{season}_{episode}``.
1072 if mode != "tv" and media_type != "tv":
1073 return None
1074 id_value = (
1075 (ids or {}).get("original_id")
1076 or (ids or {}).get("imdb_id")
1077 or (ids or {}).get("tmdb_id")
1078 )
1079 if id_value is None or season is None or episode is None:
1080 return None
8901081
891 if not rescrape:
892 cached_results = get_cached_results(
893 query, mode, media_type, episode, cache_scope=cache_scope
894 )
895 if cached_results:
896 return cached_results
1082 from lib.db.cached import cache as _cache
1083 from lib.utils.player.utils import get_autoscrape_results_cache_key
8971084
898 tmdb_id, imdb_id = (ids.get("tmdb_id"), ids.get("imdb_id")) if ids else (None, None)
899 total_results = []
900 tasks = []
1085 as_key = get_autoscrape_results_cache_key(id_value, season, episode)
1086 as_results = _cache.get(as_key)
1087 if as_results is None:
1088 return None
1089
1090 kodilog(f"[CACHE] Autoscrape cache HIT for {as_key}, reusing {len(as_results)} results")
1091 cache_results(as_results, query, mode, media_type, episode, cache_scope=cache_scope)
1092 return as_results
9011093
902 use_detailed_status = show_dialog and get_setting("search_dialog_style", "0") == "1"
9031094
904 if use_detailed_status:
905 executor = ThreadPoolExecutor(max_workers=int(get_setting("thread_number", 6)))
906 manager = SearchTaskManager(executor)
1095def _run_detailed_search(
1096 query: str,
1097 ids: dict,
1098 mode: str,
1099 media_type: str,
1100 season: int,
1101 episode: int,
1102 scoped_addon_url: str,
1103 tmdb_id: Optional[int],
1104 imdb_id: Optional[str],
1105 variant: str,
1106 title_language_mode: str,
1107 year: Optional[int],
1108 title_aliases: List[str],
1109) -> List[TorrentStream]:
1110 """Search with SearchStatusWindow (``search_dialog_style=1``)."""
1111 executor = ThreadPoolExecutor(max_workers=int(get_setting("thread_number", 6)))
1112 manager = SearchTaskManager(executor)
1113 try:
9071114 _submit_search_tasks_managed(
908 manager,
909 None,
910 query,
911 mode,
912 media_type,
913 season,
914 episode,
915 ids,
916 scoped_addon_url,
917 tmdb_id,
918 imdb_id,
919 variant=variant,
920 title_language_mode=title_language_mode,
921 year=year,
1115 manager, None, query, mode, media_type, season, episode, ids,
1116 scoped_addon_url, tmdb_id, imdb_id,
1117 variant=variant, title_language_mode=title_language_mode,
1118 year=year, title_aliases=title_aliases,
9221119 )
9231120
9241121 item_info = {"ids": ids, "mode": mode}
……@@ -926,57 +1123,116 @@ def search_client(
9261123 item_info.update(build_media_metadata(ids, mode))
9271124
9281125 window = SearchStatusWindow(
929 "search_status.xml",
930 ADDON_PATH,
931 task_manager=manager,
932 item_information=item_info,
1126 "search_status.xml", ADDON_PATH,
1127 task_manager=manager, item_information=item_info,
9331128 )
934 window.doModal()
935 del window
936
937 total_results = manager.collect_results()
1129 try:
1130 window.show()
1131 window.run_until_complete()
1132 finally:
1133 del window
9381134
9391135 if manager.is_cancelled:
940 executor.shutdown(wait=False)
941 else:
942 executor.shutdown(wait=False)
943 else:
944 with DialogListener() as listener:
945 if show_dialog:
946 listener.dialog.create("")
947
948 with ThreadPoolExecutor(
949 max_workers=int(get_setting("thread_number", 6))
950 ) as executor:
951 _submit_search_tasks(
952 executor,
953 tasks,
954 listener.dialog,
955 query,
956 mode,
957 media_type,
958 season,
959 episode,
960 ids,
961 scoped_addon_url,
962 tmdb_id,
963 imdb_id,
964 show_dialog,
965 variant=variant,
966 title_language_mode=title_language_mode,
967 year=year,
968 )
1136 manager.cancel_pending()
1137 raise SearchCancelled(manager.collect_results())
9691138
970 total_results = _collect_search_results(tasks, listener, show_dialog)
1139 return manager.collect_results()
1140 except Exception:
1141 manager.cancel_pending()
1142 raise
1143 finally:
1144 executor.shutdown(wait=False)
9711145
972 cache_results(
973 total_results,
974 query,
975 mode,
976 media_type,
977 episode,
978 cache_scope=cache_scope,
1146
1147def _run_simple_search(
1148 query: str,
1149 ids: dict,
1150 mode: str,
1151 media_type: str,
1152 season: int,
1153 episode: int,
1154 scoped_addon_url: str,
1155 tmdb_id: Optional[int],
1156 imdb_id: Optional[str],
1157 show_dialog: bool,
1158 variant: str,
1159 title_language_mode: str,
1160 year: Optional[int],
1161 title_aliases: List[str],
1162) -> List[TorrentStream]:
1163 """Search with a simple progress dialog (``search_dialog_style=0``)."""
1164 tasks: List[Future] = []
1165 with DialogListener() as listener:
1166 if show_dialog:
1167 listener.dialog.create("")
1168
1169 with ThreadPoolExecutor(max_workers=int(get_setting("thread_number", 6))) as executor:
1170 _submit_search_tasks(
1171 executor, tasks, listener.dialog,
1172 query, mode, media_type, season, episode, ids,
1173 scoped_addon_url, tmdb_id, imdb_id, show_dialog,
1174 variant=variant, title_language_mode=title_language_mode,
1175 year=year, title_aliases=title_aliases,
1176 )
1177 total_results = _collect_search_results(tasks, listener, show_dialog)
1178
1179 return total_results
1180
1181
1182def search_client(
1183 query: str,
1184 ids: dict,
1185 mode: str,
1186 media_type: str,
1187 rescrape: bool,
1188 season: int,
1189 episode: int,
1190 show_dialog: bool = True,
1191 scoped_addon_url: str = "",
1192 variant: str = SearchVariant.DEFAULT,
1193 title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST,
1194 year: Optional[int] = None,
1195) -> List[TorrentStream]:
1196 close_busy_dialog()
1197 if year is None:
1198 year = _infer_tmdb_year(ids, mode)
1199
1200 title_aliases = _build_title_fallback_queries(
1201 query, ids, mode, SearchVariant.DEFAULT, year,
1202 title_language_mode=title_language_mode,
9791203 )
1204 cache_scope = _build_search_cache_scope(scoped_addon_url)
1205
1206 if not rescrape:
1207 cached = _check_search_caches(query, ids, mode, media_type, episode, season, cache_scope)
1208 if cached is not None:
1209 return cached
1210
1211 tmdb_id, imdb_id = (ids.get("tmdb_id"), ids.get("imdb_id")) if ids else (None, None)
1212
1213 if show_dialog and get_setting("search_dialog_style", "0") == "1":
1214 try:
1215 total_results = _run_detailed_search(
1216 query, ids, mode, media_type, season, episode, scoped_addon_url,
1217 tmdb_id, imdb_id, variant, title_language_mode, year, title_aliases,
1218 )
1219 except SearchCancelled:
1220 raise
1221 except Exception as e:
1222 kodilog(f"Detailed search status failed, falling back to simple search: {e}")
1223 total_results = _run_simple_search(
1224 query, ids, mode, media_type, season, episode, scoped_addon_url,
1225 tmdb_id, imdb_id, True, variant, title_language_mode, year,
1226 title_aliases,
1227 )
1228 else:
1229 total_results = _run_simple_search(
1230 query, ids, mode, media_type, season, episode, scoped_addon_url,
1231 tmdb_id, imdb_id, show_dialog, variant, title_language_mode, year,
1232 title_aliases,
1233 )
1234
1235 cache_results(total_results, query, mode, media_type, episode, cache_scope=cache_scope)
9801236 return total_results
9811237
9821238
……@@ -998,15 +1254,21 @@ def process_results(
9981254 media_type: str,
9991255 rescrape: bool,
10001256 episode: int,
1257 suppress_dialog: bool = False,
10011258) -> List[TorrentStream]:
10021259 torrent_results = []
10031260 if get_setting("torrent_enable"):
10041261 torrent_results = post_process(pre_results)
10051262 close_busy_dialog()
1006 with DialogListener() as listener:
1263 if suppress_dialog:
10071264 debrid_results = check_debrid_cached(
1008 query, pre_results, mode, media_type, listener.dialog, rescrape, episode
1265 query, pre_results, mode, media_type, SilentProgressDialog(), rescrape, episode
10091266 )
1267 else:
1268 with DialogListener() as listener:
1269 debrid_results = check_debrid_cached(
1270 query, pre_results, mode, media_type, listener.dialog, rescrape, episode
1271 )
10101272 return debrid_results + torrent_results
10111273
10121274
……@@ -1019,6 +1281,7 @@ def show_source_select(
10191281 media_type: str,
10201282 rescrape: bool,
10211283 direct: bool = False,
1284 autoplay_context: Optional[str] = None,
10221285) -> bool:
10231286 item_info = {
10241287 "tv_data": tv_data,
……@@ -1028,17 +1291,20 @@ def show_source_select(
10281291 "media_type": media_type,
10291292 "rescrape": rescrape,
10301293 }
1294 if autoplay_context:
1295 item_info["playnext_context"] = True
1296 item_info["direct_play"] = True
10311297
10321298 if not direct and ids:
10331299 item_info.update(build_media_metadata(ids, mode))
10341300
10351301 kodilog(
1036 f"show_source_select context: query={query}, mode={mode}, media_type={media_type}, year={item_info.get('year')}, ids={ids}"
1302 "show_source_select context: "
1303 f"query={query}, mode={mode}, media_type={media_type}, "
1304 f"year={item_info.get('year')}, ids={ids}"
10371305 )
10381306
1039 xml_file_string = (
1040 "source_select_direct.xml" if mode == "direct" else "source_select.xml"
1041 )
1307 xml_file_string = "source_select_direct.xml" if mode == "direct" else "source_select.xml"
10421308
10431309 return source_select(item_info, xml_file=xml_file_string, sources=results)
10441310
……@@ -1050,6 +1316,7 @@ def auto_play(
10501316 mode,
10511317 preferred_group=None,
10521318 force_select=False,
1319 autoplay_context: Optional[str] = None,
10531320) -> bool:
10541321 if force_select:
10551322 return False
……@@ -1070,9 +1337,7 @@ def auto_play(
10701337
10711338 selected_result = quality_matches[0]
10721339 if preferred_group:
1073 group_matches = [
1074 r for r in quality_matches if preferred_group.lower() in r.title.lower()
1075 ]
1340 group_matches = [r for r in quality_matches if preferred_group.lower() in r.title.lower()]
10761341 if group_matches:
10771342 selected_result = group_matches[0]
10781343
……@@ -1086,7 +1351,7 @@ def auto_play(
10861351 "ids": ids,
10871352 "info_hash": selected_result.infoHash,
10881353 "url": selected_result.url,
1089 # getattr — see prepare_source_data: unpickled cached results predate
1354 # getattr -- see prepare_source_data: unpickled cached results predate
10901355 # this field and pickle does not apply dataclass defaults.
10911356 "proxyHeaders": getattr(selected_result, "proxyHeaders", None) or {},
10921357 "tv_data": tv_data,
……@@ -1097,6 +1362,11 @@ def auto_play(
10971362 if not playback_info:
10981363 return False
10991364
1365 playback_info["autoplay"] = True
1366 if autoplay_context:
1367 playback_info["playnext_context"] = True
1368 playback_info["direct_play"] = True
1369
11001370 player = JacktookPLayer()
11011371 player.run(data=playback_info)
11021372 del player
……@@ -1115,9 +1385,7 @@ def add_task_if_enabled(
11151385):
11161386 """Add a search task to the task list if the corresponding setting is enabled."""
11171387 if get_setting(setting_key):
1118 tasks.append(
1119 executor.submit(perform_search, indexer_key, dialog, *args, **kwargs)
1120 )
1388 tasks.append(executor.submit(perform_search, indexer_key, dialog, *args, **kwargs))
11211389
11221390
11231391def add_task_if_enabled_managed(
……@@ -1126,6 +1394,4 @@ def add_task_if_enabled_managed(
11261394 """Add a managed search task if the corresponding setting is enabled."""
11271395 if get_setting(setting_key):
11281396 name = str(indexer_key).title()
1129 manager.submit_task(
1130 name, indexer_key, perform_search, indexer_key, dialog, *args, **kwargs
1131 )
1397 manager.submit_task(name, indexer_key, perform_search, indexer_key, dialog, *args, **kwargs)
plugin.video.jacktook/lib/services/autostart.py +13 -0
……@@ -0,0 +1,13 @@
1import xbmc
2
3from lib.utils.kodi.logging import kodilog
4from lib.utils.kodi.utils import execute_builtin, get_setting
5
6
7class AutoStartService:
8 def run(self):
9 if get_setting("auto_start_jacktook", False):
10 kodilog("AutoStart: Launching Jacktook on boot...", level=xbmc.LOGINFO)
11 execute_builtin("RunAddon(plugin.video.jacktook)")
12 else:
13 kodilog("AutoStart: Disabled by user.", level=xbmc.LOGINFO)
plugin.video.jacktook/lib/services/background_service.py +25 -14
……@@ -1,6 +1,6 @@
1import os
12from threading import Thread
23from time import time
3import os
44
55import xbmc
66import xbmcaddon
……@@ -21,11 +21,10 @@ from lib.utils.kodi.utils import (
2121 get_setting,
2222 kodilog,
2323 set_property_no_fallback,
24 translation,
2524 translatePath,
25 translation,
2626)
2727
28
2928first_run_update_prop = "jacktook.first_run_update"
3029pause_services_prop = "jacktook.pause_services"
3130
……@@ -72,9 +71,7 @@ class UpdateCheck:
7271
7372
7473def TMDBHelperAutoInstall():
75 jacktook_select_path = (
76 "special://home/addons/plugin.video.jacktook/jacktook.select.json"
77 )
74 jacktook_select_path = "special://home/addons/plugin.video.jacktook/jacktook.select.json"
7875
7976 if not xbmcvfs.exists(jacktook_select_path):
8077 kodilog("jacktook.select.json file not found!", level=xbmc.LOGERROR)
……@@ -134,7 +131,7 @@ def _read_text_file(path):
134131 return None
135132
136133 try:
137 with open(translated_path, "r", encoding="utf-8") as file_obj:
134 with open(translated_path, encoding="utf-8") as file_obj:
138135 return file_obj.read()
139136 except OSError as error:
140137 kodilog(f"Failed to read {path}: {error}", level=xbmc.LOGERROR)
……@@ -227,22 +224,36 @@ class JacktookMOnitor(xbmc.Monitor):
227224 StartupPreloader().run()
228225 DownloaderSetup().run()
229226 TMDBHelperAutoInstall()
227 try:
228 from lib.services.autostart import AutoStartService
229
230 AutoStartService().run()
231 except Exception as e:
232 kodilog(f"AutoStart failed: {e}", level=xbmc.LOGERROR)
233 try:
234 from lib.services.widget_refresh import WidgetRefreshService
235
236 Thread(target=WidgetRefreshService().run).start()
237 except Exception as e:
238 kodilog(f"WidgetRefresh failed: {e}", level=xbmc.LOGERROR)
230239
231240 def onScreensaverActivated(self):
232241 set_property_no_fallback(pause_services_prop, "true")
233 kodilog(
234 "PAUSING Jacktook Services Due to Device Sleep", level=xbmc.LOGINFO
235 )
242 kodilog("PAUSING Jacktook Services Due to Device Sleep", level=xbmc.LOGINFO)
236243
237244 def onScreensaverDeactivated(self):
238245 clear_property(pause_services_prop)
239 kodilog(
240 "UNPAUSING Jacktook Services Due to Device Awake", level=xbmc.LOGINFO
241 )
246 kodilog("UNPAUSING Jacktook Services Due to Device Awake", level=xbmc.LOGINFO)
242247
243248 def onSettingsChanged(self):
244249 clear_cached_settings()
245 kodilog("Cleared cached settings after Kodi settings change", level=xbmc.LOGINFO)
250 from lib.utils.tmdb_init import ensure_tmdb_init
251
252 ensure_tmdb_init()
253 kodilog(
254 "Cleared cached settings and refreshed TMDB language after Kodi settings change",
255 level=xbmc.LOGINFO,
256 )
246257
247258 def onNotification(self, sender, method, data):
248259 if method == "System.OnSleep":
plugin.video.jacktook/lib/services/debrid/auth.py +86 -5
……@@ -1,3 +1,4 @@
1import contextlib
12from time import time
23
34from lib.gui.qr_progress_dialog import QRProgressDialog
……@@ -9,9 +10,11 @@ from lib.utils.kodi.utils import (
910 dialog_ok,
1011 progressDialog,
1112 set_setting,
12 sleep as ksleep,
1313 translation,
1414)
15from lib.utils.kodi.utils import (
16 sleep as ksleep,
17)
1518
1619
1720def run_realdebrid_auth(client):
……@@ -136,7 +139,7 @@ def run_premiumize_auth(client):
136139 response = client.get_device_code()
137140 user_code = response["user_code"]
138141 copy2clip(user_code)
139 content = "%s[CR]%s[CR]%s" % (
142 content = "{}[CR]{}[CR]{}".format(
140143 translation(90540),
141144 translation(90541) % response.get("verification_uri"),
142145 translation(90542) % user_code,
……@@ -179,10 +182,8 @@ def run_premiumize_auth(client):
179182 dialog_ok(translation(90546), translation(90547) % error)
180183 break
181184
182 try:
185 with contextlib.suppress(Exception):
183186 progressDialog.close()
184 except Exception:
185 pass
186187
187188
188189def run_torbox_auth(client):
……@@ -250,6 +251,86 @@ def run_torbox_auth(client):
250251 return
251252
252253
254def run_offcloud_auth(client):
255 response = client.get_device_code()
256 if not response:
257 return
258
259 sleep_interval = int(response.get("interval", 5))
260 expires_in = int(response.get("expires_in", 600))
261 device_code = response.get("device_code")
262 user_code = response.get("user_code")
263 auth_url = response.get("verification_uri")
264 qr_url = response.get("verification_uri_complete") or auth_url
265 if not device_code or not user_code or not auth_url:
266 return
267
268 qr_code = make_qrcode(qr_url)
269 copy2clip(qr_url)
270
271 progress_dialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH)
272 progress_dialog.setup(
273 translation(90604) % DebridType.OC,
274 qr_code,
275 auth_url,
276 user_code,
277 DebridType.OC,
278 )
279 progress_dialog.show_dialog()
280
281 start_time = time()
282 while time() - start_time < expires_in:
283 ksleep(1000 * sleep_interval)
284 if progress_dialog.iscanceled:
285 progress_dialog.close_dialog()
286 return
287
288 auth_response = client.authorize(device_code)
289 error_code = auth_response.get("error") if isinstance(auth_response, dict) else None
290 if error_code == "authorization_pending":
291 elapsed = time() - start_time
292 progress_dialog.update_progress(int((elapsed / expires_in) * 100))
293 continue
294 if error_code == "slow_down":
295 sleep_interval += 5
296 elapsed = time() - start_time
297 progress_dialog.update_progress(int((elapsed / expires_in) * 100))
298 continue
299 if error_code in ("expired_token", "access_denied"):
300 progress_dialog.close_dialog()
301 dialog_ok(translation(90548), auth_response.get("error_description", error_code))
302 return
303
304 try:
305 access_token = auth_response.get("access_token")
306 if access_token:
307 client.token = str(access_token)
308 set_setting("offcloud_token", client.token)
309 set_setting("offcloud_authorized", "true")
310 client.initialize_headers()
311
312 with contextlib.suppress(Exception):
313 account_info = client.get_account_info()
314 user = account_info.get("email") or account_info.get("user_id")
315 if user:
316 set_setting("offcloud_user", str(user))
317
318 progress_dialog.update_progress(100, translation(90545))
319 progress_dialog.close_dialog()
320 dialog_ok(translation(90544), translation(90545))
321 return
322
323 elapsed = time() - start_time
324 progress_dialog.update_progress(int((elapsed / expires_in) * 100))
325 except Exception as error:
326 progress_dialog.close_dialog()
327 dialog_ok(translation(90548), translation(90547) % error)
328 return
329
330 with contextlib.suppress(Exception):
331 progress_dialog.close_dialog()
332
333
253334def run_debrider_auth(client):
254335 response = client.get_device_code()
255336 if not response:
plugin.video.jacktook/lib/services/debrid/download.py +7 -8
……@@ -1,7 +1,10 @@
1from lib.utils.general.utils import supported_video_extensions
2from lib.utils.kodi.utils import dialogyesno, notification, sleep as ksleep, translation
1import contextlib
2
33from xbmcgui import DialogProgress
44
5from lib.utils.general.utils import supported_video_extensions
6from lib.utils.kodi.utils import dialogyesno, notification, translation
7from lib.utils.kodi.utils import sleep as ksleep
58
69DEBRID_ERROR_STATUS = ("magnet_error", "error", "virus", "dead")
710
……@@ -44,16 +47,12 @@ def run_realdebrid_download(client, magnet_url, pack=False):
4447 client, torrent_id, torrent_info, progress_dialog, interval, pack
4548 )
4649
47 try:
50 with contextlib.suppress(Exception):
4851 progress_dialog.close()
49 except Exception:
50 pass
5152
5253 ksleep(500)
5354 if cancelled:
54 response = dialogyesno(
55 translation(32029), translation(90564)
56 )
55 response = dialogyesno(translation(32029), translation(90564))
5756 if response:
5857 notification(translation(90641))
5958 else:
plugin.video.jacktook/lib/services/migrations.py +5 -8
……@@ -1,17 +1,13 @@
11from lib.utils.kodi.utils import (
22 get_setting,
3 set_setting,
43 kodilog,
4 set_setting,
55)
66
77
88def _migrate_trakt_credentials():
9 DEFAULT_CLIENT = (
10 "687c707dc7073be777a6dd6869ffd9034109d75dcda6599a7273f49a68266ff8"
11 )
12 DEFAULT_SECRET = (
13 "3191ae63a3cf60d767b1d6fc1e39b1dedc6f3346b826d62901f9f2c3c9925e26"
14 )
9 DEFAULT_CLIENT = "687c707dc7073be777a6dd6869ffd9034109d75dcda6599a7273f49a68266ff8"
10 DEFAULT_SECRET = "3191ae63a3cf60d767b1d6fc1e39b1dedc6f3346b826d62901f9f2c3c9925e26"
1511
1612 current_client = get_setting("trakt_client", "")
1713 current_secret = get_setting("trakt_secret", "")
……@@ -34,6 +30,7 @@ def _migrate_trakt_credentials():
3430 set_setting("trakt_client", DEFAULT_CLIENT)
3531 set_setting("trakt_secret", DEFAULT_SECRET)
3632
33
3734def run_migrations():
3835 _migrate_trakt_credentials()
39 kodilog(f"Trakt migration complete")
36 kodilog("Trakt migration complete")
plugin.video.jacktook/lib/services/preloader.py +43 -29
……@@ -1,8 +1,10 @@
1from lib.clients.tmdb.utils.utils import tmdb_get
2from threading import Thread
1import contextlib
32from concurrent.futures import ThreadPoolExecutor
4from lib.utils.kodi.utils import kodilog, get_setting
3from threading import Thread
4
55from lib.api.tmdbv3api.tmdb import TMDb
6from lib.clients.tmdb.utils.utils import tmdb_get
7from lib.utils.kodi.utils import get_setting, kodilog
68
79
810class StartupPreloader:
……@@ -12,53 +14,63 @@ class StartupPreloader:
1214 def _preload(self):
1315 tmdb = TMDb()
1416 tmdb.api_key = get_setting("tmdb_api_key", "b70756b7083d9ee60f849d82d94a0d80")
15
17
1618 # Helper to fetch seasons for a list of shows
1719 def _deep_preload_tv(results):
1820 try:
19 if not results:
21 if not results:
2022 return
21
23
2224 # Handle list vs dict (TMDB response structure variants)
2325 items = []
24 if hasattr(results, 'results'):
26 if hasattr(results, "results"):
2527 items = results.results
2628 elif isinstance(results, dict):
27 items = results.get('results', [])
29 items = results.get("results", [])
2830 else:
29 try:
31 with contextlib.suppress(BaseException):
3032 items = list(results)
31 except:
32 pass
33
33
3434 if not isinstance(items, list):
3535 items = list(items)
36
36
3737 top_items = items[:5]
3838
3939 for item in top_items:
4040 try:
41 tmdb_id = getattr(item, 'id', None) if hasattr(item, 'id') else item.get('id')
42 name = getattr(item, 'name', 'Unknown') if hasattr(item, 'name') else item.get('name', 'Unknown')
43
44 if not tmdb_id: continue
45
41 tmdb_id = (
42 getattr(item, "id", None) if hasattr(item, "id") else item.get("id")
43 )
44 (
45 getattr(item, "name", "Unknown")
46 if hasattr(item, "name")
47 else item.get("name", "Unknown")
48 )
49
50 if not tmdb_id:
51 continue
52
4653 # 1. Fetch Show Details
4754 details = tmdb_get("tv_details", tmdb_id)
48 if not details:
55 if not details:
4956 continue
50
57
5158 # 2. Fetch Season 1
52 tmdb_get("season_details", {'id': tmdb_id, 'season': 1})
53
59 tmdb_get("season_details", {"id": tmdb_id, "season": 1})
60
5461 # 3. Fetch Latest Season
55 num_seasons = getattr(details, 'number_of_seasons', 0) if hasattr(details, 'number_of_seasons') else details.get('number_of_seasons', 0)
62 num_seasons = (
63 getattr(details, "number_of_seasons", 0)
64 if hasattr(details, "number_of_seasons")
65 else details.get("number_of_seasons", 0)
66 )
5667 if num_seasons > 1:
57 tmdb_get("season_details", {'id': tmdb_id, 'season': num_seasons})
58 except Exception as e:
68 tmdb_get("season_details", {"id": tmdb_id, "season": num_seasons})
69 except Exception:
5970 pass
6071 except Exception as e:
6172 import traceback
73
6274 kodilog(f"[StartupPreloader] CRASH in _deep_preload_tv: {e}")
6375 kodilog(traceback.format_exc())
6476
……@@ -68,13 +80,15 @@ class StartupPreloader:
6880 f_trend_tv = executor.submit(tmdb_get, "trending_tv", 1)
6981 executor.submit(tmdb_get, "popular_movie", 1)
7082 f_pop_tv = executor.submit(tmdb_get, "popular_shows", 1)
71
83
7284 # Get results (this waits for the specific future, but others run in parallel)
7385 # We treat TV lists specially to trigger deep preload
7486 trending_tv = f_trend_tv.result()
7587 popular_tv = f_pop_tv.result()
76
88
7789 # Submit deep preload tasks
7890 # This will run in the same pool, reusing workers as they free up
79 if trending_tv: executor.submit(_deep_preload_tv, trending_tv)
80 if popular_tv: executor.submit(_deep_preload_tv, popular_tv)
91 if trending_tv:
92 executor.submit(_deep_preload_tv, trending_tv)
93 if popular_tv:
94 executor.submit(_deep_preload_tv, popular_tv)
plugin.video.jacktook/lib/services/subtitle_server.py +82 -70
……@@ -1,21 +1,19 @@
1# -*- coding: utf-8 -*-
21"""
32Local web server for uploading subtitle files from a browser.
43
54Serves a web UI with drag & drop file upload and exposes an API endpoint
65to receive the subtitle file via HTTP POST.
76"""
7import json
88import os
99import socket
1010import threading
11import json
1211import uuid
1312from datetime import datetime
14from http.server import HTTPServer, BaseHTTPRequestHandler
13from http.server import BaseHTTPRequestHandler, HTTPServer
1514
1615from lib.utils.kodi.utils import ADDON_PROFILE_PATH, kodilog
1716
18
1917_WEB_DIR = os.path.join(
2018 os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
2119 "resources",
……@@ -23,12 +21,13 @@ _WEB_DIR = os.path.join(
2321)
2422
2523# Supported subtitle extensions
26SUBTITLE_EXTENSIONS = {'.srt', '.ass', '.ssa', '.sub', '.vtt', '.txt'}
24SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".sub", ".vtt", ".txt"}
2725
2826
2927def _ensure_upload_dir():
3028 """Create upload directory if it doesn't exist."""
3129 upload_dir = os.path.join(ADDON_PROFILE_PATH, "Subtitles", "uploads")
30
3231 os.makedirs(upload_dir, exist_ok=True)
3332 return upload_dir
3433
……@@ -36,17 +35,19 @@ def _ensure_upload_dir():
3635def _is_valid_extension(filename):
3736 """Check if file has a supported subtitle extension."""
3837 ext = os.path.splitext(filename)[1].lower()
38
3939 return ext in SUBTITLE_EXTENSIONS
4040
4141
4242class SubtitleUploadHandler(BaseHTTPRequestHandler):
4343 """Handles HTTP requests for subtitle upload."""
44
4544 @property
45
4646 def wrapper_server(self):
4747 """Get the SubtitleUploadServer wrapper instance."""
4848 # The wrapper is stored as an attribute on the HTTPServer
49 return getattr(self.server, 'wrapper_server', None)
49
50 return getattr(self.server, "wrapper_server", None)
5051
5152 def log_message(self, format, *args):
5253 kodilog(f"[SubtitleServer] {format % args}")
……@@ -77,9 +78,9 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler):
7778 <meta name="viewport" content="width=device-width, initial-scale=1.0">
7879 <title>Upload Error</title>
7980 <style>
80 body {{ font-family: sans-serif; background: #0d1117; color: #e6edf3;
81 body {{ font-family: sans-serif; background: #0d1117; color: #e6edf3;
8182 display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }}
82 .error-box {{ background: #161b22; border: 1px solid #f85149; border-radius: 12px;
83 .error-box {{ background: #161b22; border: 1px solid #f85149; border-radius: 12px;
8384 padding: 30px; text-align: center; max-width: 400px; }}
8485 h1 {{ color: #f85149; margin: 0 0 15px 0; }}
8586 p {{ color: #8b949e; margin: 0; }}
……@@ -99,7 +100,14 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler):
99100 @staticmethod
100101 def _html_escape(text):
101102 """Escape HTML special characters to prevent XSS."""
102 return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;").replace("'", "&#39;")
103 return (
104
105 text.replace("&", "&amp;")
106 .replace("<", "&lt;")
107 .replace(">", "&gt;")
108 .replace('"', "&quot;")
109 .replace("'", "&#39;")
110 )
103111
104112 def _send_success_page(self, filename):
105113 safe_filename = self._html_escape(filename)
……@@ -110,9 +118,9 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler):
110118 <meta name="viewport" content="width=device-width, initial-scale=1.0">
111119 <title>Upload Successful</title>
112120 <style>
113 body {{ font-family: sans-serif; background: #0d1117; color: #e6edf3;
121 body {{ font-family: sans-serif; background: #0d1117; color: #e6edf3;
114122 display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }}
115 .success-box {{ background: #161b22; border: 1px solid #3fb950; border-radius: 12px;
123 .success-box {{ background: #161b22; border: 1px solid #3fb950; border-radius: 12px;
116124 padding: 30px; text-align: center; max-width: 400px; }}
117125 h1 {{ color: #3fb950; margin: 0 0 15px 0; }}
118126 p {{ color: #8b949e; margin: 0; }}
……@@ -155,7 +163,7 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler):
155163 def _serve_index(self):
156164 index_path = os.path.join(_WEB_DIR, "upload.html")
157165 try:
158 with open(index_path, "r", encoding="utf-8") as f:
166 with open(index_path, encoding="utf-8") as f:
159167 self._send_html(f.read())
160168 except FileNotFoundError:
161169 self.send_error(500, "Upload page not found")
……@@ -163,40 +171,39 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler):
163171 def _serve_status(self):
164172 """Return current upload status."""
165173 wrapper = self.wrapper_server
174
166175 uploaded_file = wrapper.get_uploaded_file() if wrapper else None
167 self._send_json({
168 "uploaded": uploaded_file is not None,
169 "path": uploaded_file
170 })
176 self._send_json({"uploaded": uploaded_file is not None, "path": uploaded_file})
171177
172178 def _parse_multipart(self):
173179 """
174180 Parse multipart/form-data without cgi module.
181
175182 Returns (filename, file_content) tuple or (None, error_message).
176183 """
177 content_type = self.headers.get('Content-Type', '')
178
179 if not content_type.startswith('multipart/form-data'):
184 content_type = self.headers.get("Content-Type", "")
185
186 if not content_type.startswith("multipart/form-data"):
180187 return None, "Invalid content type"
181188
182189 # Extract boundary from content-type
183 boundary_marker = 'boundary='
190 boundary_marker = "boundary="
184191 boundary_idx = content_type.find(boundary_marker)
185192 if boundary_idx == -1:
186193 return None, "No boundary in content type"
187
188 boundary = content_type[boundary_idx + len(boundary_marker):]
194
195 boundary = content_type[boundary_idx + len(boundary_marker) :]
189196 boundary = boundary.strip('"')
190 boundary_bytes = ('--' + boundary).encode('utf-8')
191 end_boundary_bytes = ('--' + boundary + '--').encode('utf-8')
197 boundary_bytes = ("--" + boundary).encode("utf-8")
198 end_boundary_bytes = ("--" + boundary + "--").encode("utf-8")
192199
193 content_length = int(self.headers.get('Content-Length', 0))
200 content_length = int(self.headers.get("Content-Length", 0))
194201 if content_length == 0:
195202 return None, "No data received"
196203
197204 # Read raw POST data
198205 post_data = self.rfile.read(content_length)
199
206
200207 # Find first boundary
201208 start_idx = post_data.find(boundary_bytes)
202209 if start_idx == -1:
……@@ -204,49 +211,49 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler):
204211
205212 # Parse each part
206213 pos = start_idx + len(boundary_bytes)
207
214
208215 while pos < len(post_data):
209216 # Check for end boundary
210 if post_data[pos:pos + 2] == b'--':
217 if post_data[pos : pos + 2] == b"--":
211218 break
212
219
213220 # Skip CRLF after boundary
214 if post_data[pos:pos + 2] == b'\r\n':
221 if post_data[pos : pos + 2] == b"\r\n":
215222 pos += 2
216 elif post_data[pos:pos + 1] == b'\n':
223 elif post_data[pos : pos + 1] == b"\n":
217224 pos += 1
218
225
219226 # Find headers end (double CRLF)
220 header_end = post_data.find(b'\r\n\r\n', pos)
227 header_end = post_data.find(b"\r\n\r\n", pos)
221228 if header_end == -1:
222 header_end = post_data.find(b'\n\n', pos)
229 header_end = post_data.find(b"\n\n", pos)
223230 header_end_len = 2
224231 if header_end == -1:
225232 continue
226233 else:
227234 header_end_len = 4
228
229 headers = post_data[pos:header_end].decode('utf-8', errors='replace')
235
236 headers = post_data[pos:header_end].decode("utf-8", errors="replace")
230237 pos = header_end + header_end_len
231
238
232239 # Check if this is a file field
233 if 'Content-Disposition' not in headers:
240 if "Content-Disposition" not in headers:
234241 continue
235
242
236243 # Find next boundary
237244 next_boundary = post_data.find(boundary_bytes, pos)
238245 if next_boundary == -1:
239246 next_boundary = post_data.find(end_boundary_bytes, pos)
240247 if next_boundary == -1:
241248 break
242
249
243250 # Extract field data (remove trailing CRLF)
244251 field_data = post_data[pos:next_boundary]
245 if field_data.endswith(b'\r\n'):
252 if field_data.endswith(b"\r\n"):
246253 field_data = field_data[:-2]
247 elif field_data.endswith(b'\n'):
254 elif field_data.endswith(b"\n"):
248255 field_data = field_data[:-1]
249
256
250257 # Parse Content-Disposition for filename
251258 if 'filename="' in headers or "filename='" in headers:
252259 # Extract filename
……@@ -256,23 +263,24 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler):
256263 quote_char = "'"
257264 else:
258265 quote_char = '"'
259
266
260267 if filename_start != -1:
261268 filename_start += 10 # len('filename="')
262269 filename_end = headers.find(quote_char, filename_start)
263270 if filename_end != -1:
264271 filename = headers[filename_start:filename_end]
265272 return filename, field_data
266
273
267274 pos = next_boundary
268
275
269276 return None, "No file found in upload"
270277
271278 def _handle_upload(self):
272279 """Handle multipart form data upload."""
273280 try:
281
274282 filename, file_content = self._parse_multipart()
275
283
276284 if filename is None:
277285 error_msg = file_content
278286 kodilog(f"[SubtitleServer] Upload error: {error_msg}")
……@@ -280,13 +288,16 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler):
280288 return
281289
282290 filename = os.path.basename(filename)
283
291
284292 # Validate extension
285293 if not _is_valid_extension(filename):
286294 ext = os.path.splitext(filename)[1].lower() or "unknown"
287 self._send_json({
288 "error": f"Invalid file type: {ext}. Supported: {', '.join(SUBTITLE_EXTENSIONS)}"
289 }, 400)
295 self._send_json(
296 {
297 "error": f"Invalid file type: {ext}. Supported: {', '.join(SUBTITLE_EXTENSIONS)}"
298 },
299 400,
300 )
290301 return
291302
292303 # Save the file with unique name to avoid collisions
……@@ -296,40 +307,41 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler):
296307 safe_filename = f"{timestamp}_{unique_id}_{filename}"
297308 file_path = os.path.join(upload_dir, safe_filename)
298309
299 with open(file_path, 'wb') as f:
300 f.write(file_content if isinstance(file_content, bytes) else file_content.encode('utf-8'))
310 with open(file_path, "wb") as f:
311 f.write(
312 file_content
313 if isinstance(file_content, bytes)
314 else file_content.encode("utf-8")
315 )
301316
302317 kodilog(f"[SubtitleServer] File uploaded: {file_path}")
303
318
304319 # Notify server instance
305320 wrapper = self.wrapper_server
306321 if wrapper:
307322 wrapper.set_uploaded_file(file_path)
308323 else:
309324 kodilog("[SubtitleServer] Warning: wrapper_server not available")
310
325
311326 # Send success response (HTML page for browser, JSON for API)
312 accept = self.headers.get('Accept', '')
313 if 'application/json' in accept:
314 self._send_json({
315 "success": True,
316 "filename": filename,
317 "path": file_path
318 })
327 accept = self.headers.get("Accept", "")
328 if "application/json" in accept:
329 self._send_json({"success": True, "filename": filename, "path": file_path})
319330 else:
320331 self._send_success_page(filename)
321332
322333 except Exception as e:
323334 kodilog(f"[SubtitleServer] Upload error: {e}")
324335 import traceback
336
325337 kodilog(traceback.format_exc())
326338 self._send_json({"error": str(e)}, 500)
327339
328340
329341class SubtitleUploadServer:
330342 """Threaded HTTP server for subtitle uploads."""
331
332343 def __init__(self, port=8082):
344
333345 self.port = port
334346 self._server = None
335347 self._thread = None
……@@ -343,9 +355,7 @@ class SubtitleUploadServer:
343355 self._server = HTTPServer(("0.0.0.0", self.port), SubtitleUploadHandler)
344356 # Store reference to this wrapper so handlers can access it
345357 self._server.wrapper_server = self
346 self._thread = threading.Thread(
347 target=self._server.serve_forever, daemon=True
348 )
358 self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
349359 self._thread.start()
350360 kodilog(f"[SubtitleServer] Started on port {self.port}")
351361 except OSError:
……@@ -354,9 +364,7 @@ class SubtitleUploadServer:
354364 try:
355365 self._server = HTTPServer(("0.0.0.0", self.port), SubtitleUploadHandler)
356366 self._server.wrapper_server = self
357 self._thread = threading.Thread(
358 target=self._server.serve_forever, daemon=True
359 )
367 self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
360368 self._thread.start()
361369 kodilog(f"[SubtitleServer] Started on fallback port {self.port}")
362370 except Exception as e:
……@@ -380,6 +388,7 @@ class SubtitleUploadServer:
380388 def set_uploaded_file(self, file_path):
381389 """Called by handler when file is uploaded."""
382390 self._uploaded_file = file_path
391
383392 self._upload_event.set()
384393
385394 def get_uploaded_file(self):
……@@ -398,6 +407,7 @@ class SubtitleUploadServer:
398407def _is_port_available(port):
399408 """Check if a port is available for binding."""
400409 try:
410
401411 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
402412 s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
403413 s.bind(("0.0.0.0", port))
……@@ -410,6 +420,7 @@ def _is_port_available(port):
410420def find_available_port(preferred=8082, max_attempts=10):
411421 """Find an available port, starting from the preferred one."""
412422 for offset in range(max_attempts):
423
413424 port = preferred + offset
414425 if _is_port_available(port):
415426 return port
……@@ -427,6 +438,7 @@ def find_available_port(preferred=8082, max_attempts=10):
427438def get_local_ip():
428439 """Get the machine's LAN IP address."""
429440 try:
441
430442 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
431443 s.connect(("8.8.8.8", 80))
432444 ip = s.getsockname()[0]
plugin.video.jacktook/lib/services/trakt_sync.py +7 -19
……@@ -1,8 +1,9 @@
11from time import monotonic
22
33import xbmc
4from lib.api.trakt.trakt import TraktAPI
4
55from lib.api.trakt.lists_cache import lists_cache
6from lib.api.trakt.trakt import TraktAPI
67from lib.api.trakt.trakt_cache import (
78 clear_trakt_calendar,
89 clear_trakt_favorites,
……@@ -13,13 +14,13 @@ from lib.api.trakt.trakt_cache import (
1314 reset_activity,
1415 trakt_watched_cache,
1516)
16from lib.utils.kodi.utils import get_property_no_fallback, kodilog, get_setting
17
17from lib.utils.kodi.utils import get_property_no_fallback, get_setting, kodilog
1818
1919PAUSE_SERVICES_PROP = "jacktook.pause_services"
2020DEFAULT_SYNC_INTERVAL_MINUTES = 15
2121WAIT_STEP_SECONDS = 5
2222
23
2324class TraktSyncService:
2425 def __init__(self, api=None, monitor=None):
2526 self.api = api or TraktAPI()
……@@ -48,22 +49,12 @@ class TraktSyncService:
4849 ):
4950 duration_ms = int((monotonic() - started_at) * 1000)
5051 kodilog(
51 "Trakt sync[%s]: completed outcome=%s force=%s forced_defaults=%s detected_buckets=%s applied_buckets=%s duration_ms=%s"
52 % (
53 cycle_context,
54 outcome,
55 force,
56 forced_defaults,
57 self._format_buckets(detected_buckets),
58 self._format_buckets(applied_buckets),
59 duration_ms,
60 )
52 f"Trakt sync[{cycle_context}]: completed outcome={outcome} force={force} forced_defaults={forced_defaults} detected_buckets={self._format_buckets(detected_buckets)} applied_buckets={self._format_buckets(applied_buckets)} duration_ms={duration_ms}"
6153 )
6254
6355 def _log_sync_failure(self, cycle_context, force, error):
6456 kodilog(
65 "Trakt sync[%s]: failed force=%s error=%s"
66 % (cycle_context, force, error),
57 f"Trakt sync[{cycle_context}]: failed force={force} error={error}",
6758 level=xbmc.LOGERROR,
6859 )
6960
……@@ -173,10 +164,7 @@ class TraktSyncService:
173164 )
174165 return applied_buckets
175166
176 kodilog(
177 "Trakt sync: changed buckets = %s"
178 % ", ".join(sorted(applied_buckets))
179 )
167 kodilog("Trakt sync: changed buckets = {}".format(", ".join(sorted(applied_buckets))))
180168 self._apply_activity_changes(applied_buckets)
181169 self._log_sync_summary(
182170 cycle_context,
plugin.video.jacktook/lib/services/webserver.py +24 -37
……@@ -1,33 +1,33 @@
1# -*- coding: utf-8 -*-
21"""
32Local web server for managing Stremio addon sources from a phone/browser.
43
54Serves a web UI and exposes small JSON endpoints for CRUD operations
65on the Stremio custom addons stored in the Kodi addon cache.
76"""
7
8import ipaddress
89import json
910import os
1011import socket
1112import threading
12import ipaddress
1313from datetime import timedelta
14from http.server import HTTPServer, BaseHTTPRequestHandler
14from http.server import BaseHTTPRequestHandler, HTTPServer
1515from urllib.parse import urlsplit, urlunsplit
1616
1717import requests
1818
19from lib.db.cached import cache
20from lib.utils.general.utils import USER_AGENT_HEADER
21from lib.utils.kodi.utils import kodilog
19from lib.api.stremio.addon_manager import build_addon_instance_key
2220from lib.clients.stremio.constants import (
23 STREMIO_ADDONS_KEY,
2421 STREMIO_ADDONS_CATALOGS_KEY,
22 STREMIO_ADDONS_KEY,
2523 STREMIO_TV_ADDONS_KEY,
2624 STREMIO_USER_ADDONS,
27 encode_selected_ids,
2825 decode_selected_ids,
26 encode_selected_ids,
2927)
30from lib.api.stremio.addon_manager import build_addon_instance_key
28from lib.db.cached import cache
29from lib.utils.general.utils import USER_AGENT_HEADER
30from lib.utils.kodi.utils import kodilog
3131
3232_CACHE_EXPIRY = timedelta(days=365 * 20)
3333_WEB_DIR = os.path.join(
……@@ -44,11 +44,7 @@ _WEB_DIR = os.path.join(
4444
4545def _get_user_addons():
4646 """Return list of custom addon dicts from cache."""
47 return [
48 a
49 for a in (cache.get(STREMIO_USER_ADDONS) or [])
50 if a.get("transportName") == "custom"
51 ]
47 return [a for a in (cache.get(STREMIO_USER_ADDONS) or []) if a.get("transportName") == "custom"]
5248
5349
5450def _get_selected_ids():
……@@ -65,6 +61,7 @@ def _get_selected_ids():
6561
6662def _addon_capabilities(manifest):
6763 """Determine stream/catalog/tv capabilities from a manifest dict."""
64
6865 def supports_searchable_stream(prefixes):
6966 normalized = {str(prefix).rstrip(":") for prefix in (prefixes or []) if prefix}
7067 return bool(normalized.intersection({"tt", "tmdb"}))
……@@ -84,9 +81,10 @@ def _addon_capabilities(manifest):
8481
8582 if res_name == "stream":
8683 # Stremio addon providing streams
87 if any(t in res_types for t in ("movie", "series", "anime")) and supports_searchable_stream(res_prefixes):
88 is_stream = True
89 elif supports_searchable_stream(res_prefixes):
84 if (
85 any(t in res_types for t in ("movie", "series", "anime"))
86 and supports_searchable_stream(res_prefixes)
87 ) or supports_searchable_stream(res_prefixes):
9088 is_stream = True
9189
9290 if any(t in res_types for t in ("tv", "channel")):
……@@ -97,7 +95,9 @@ def _addon_capabilities(manifest):
9795
9896 elif isinstance(res, str):
9997 if res == "stream":
100 if any(t in types for t in ("movie", "series", "anime")) and supports_searchable_stream(manifest_prefixes):
98 if any(
99 t in types for t in ("movie", "series", "anime")
100 ) and supports_searchable_stream(manifest_prefixes):
101101 is_stream = True
102102 if any(t in types for t in ("tv", "channel")):
103103 is_tv = True
……@@ -178,16 +178,11 @@ def _sync_add_addon(url):
178178 kodilog(f"[WebServer] Failed to add addon: {error}")
179179 return None, f"Failed to fetch manifest: {error or 'unknown error'}"
180180
181 addon_key = build_addon_instance_key(
182 {"manifest": manifest, "transportUrl": resp.url}
183 )
181 addon_key = build_addon_instance_key({"manifest": manifest, "transportUrl": resp.url})
184182
185183 # Check duplicate
186184 user_addons = list(cache.get(STREMIO_USER_ADDONS) or [])
187 if any(
188 build_addon_instance_key(a) == addon_key
189 for a in user_addons
190 ):
185 if any(build_addon_instance_key(a) == addon_key for a in user_addons):
191186 return None, "This addon is already added."
192187
193188 caps = _addon_capabilities(manifest)
……@@ -214,11 +209,7 @@ def _sync_add_addon(url):
214209def _sync_remove_addon(addon_id):
215210 """Remove addon by manifest ID from all caches."""
216211 user_addons = list(cache.get(STREMIO_USER_ADDONS) or [])
217 new_addons = [
218 a
219 for a in user_addons
220 if build_addon_instance_key(a) != addon_id
221 ]
212 new_addons = [a for a in user_addons if build_addon_instance_key(a) != addon_id]
222213 cache.set(STREMIO_USER_ADDONS, new_addons, _CACHE_EXPIRY)
223214
224215 for cache_key in [
……@@ -348,9 +339,7 @@ class StremioRequestHandler(BaseHTTPRequestHandler):
348339 def do_OPTIONS(self):
349340 self.send_response(200)
350341 self.send_header("Access-Control-Allow-Origin", "*")
351 self.send_header(
352 "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"
353 )
342 self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
354343 self.send_header("Access-Control-Allow-Headers", "Content-Type")
355344 self.end_headers()
356345
……@@ -359,7 +348,7 @@ class StremioRequestHandler(BaseHTTPRequestHandler):
359348 def _serve_index(self):
360349 index_path = os.path.join(_WEB_DIR, "index.html")
361350 try:
362 with open(index_path, "r", encoding="utf-8") as f:
351 with open(index_path, encoding="utf-8") as f:
363352 self._send_html(f.read())
364353 except FileNotFoundError:
365354 self.send_error(500, "Web UI not found")
……@@ -500,9 +489,7 @@ class StremioWebServer:
500489 return
501490 try:
502491 self._server = HTTPServer(("0.0.0.0", self.port), StremioRequestHandler)
503 self._thread = threading.Thread(
504 target=self._server.serve_forever, daemon=True
505 )
492 self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
506493 self._thread.start()
507494 kodilog(f"[WebServer] Started on port {self.port}")
508495 except Exception as e:
plugin.video.jacktook/lib/services/widget_refresh.py +78 -0
……@@ -0,0 +1,78 @@
1import contextlib
2from time import time
3
4import xbmc
5
6from lib.utils.kodi.utils import (
7 execute_builtin,
8 get_property,
9 get_setting,
10 kodilog,
11 notification,
12)
13
14
15class WidgetRefreshService:
16 def run(self):
17 kodilog("WidgetRefresh Service Starting...", level=xbmc.LOGINFO)
18 monitor = xbmc.Monitor()
19 player = xbmc.Player()
20 wait_for_abort = monitor.waitForAbort
21
22 wait_for_abort(10)
23 self.set_next_refresh(time())
24
25 while not monitor.abortRequested():
26 try:
27 wait_for_abort(10)
28 offset = int(get_setting("widget_refresh_timer", 60))
29 if offset != getattr(self, "offset", None):
30 self.set_next_refresh(time())
31 continue
32 if self.condition_check(player):
33 continue
34 if self.next_refresh < time():
35 kodilog(
36 "WidgetRefresh Service - Refreshing widgets...",
37 level=xbmc.LOGINFO,
38 )
39 self.refresh_widgets()
40 self.set_next_refresh(time())
41 except Exception as e:
42 kodilog(f"WidgetRefresh error: {e}", level=xbmc.LOGERROR)
43
44 with contextlib.suppress(BaseException):
45 del monitor
46 with contextlib.suppress(BaseException):
47 del player
48 kodilog("WidgetRefresh Service Finished", level=xbmc.LOGINFO)
49
50 def condition_check(self, player):
51 container_plugin = xbmc.getInfoLabel("Container.PluginName")
52
53 # No refrescar si estamos dentro de Jacktook
54 if "jacktook" in container_plugin:
55 return True
56
57 # No refrescar si está reproduciendo vídeo
58 if player.isPlayingVideo():
59 return True
60
61 # No refrescar si los servicios están pausados (screensaver/sleep)
62 if get_property("jacktook.pause_services") == "true":
63 return True
64
65 # No refrescar si el timer es 0 (desactivado)
66 return self.next_refresh is None
67
68 def set_next_refresh(self, _time):
69 self.offset = int(get_setting("widget_refresh_timer", 60))
70 if self.offset:
71 self.next_refresh = _time + (self.offset * 60)
72 else:
73 self.next_refresh = None
74
75 def refresh_widgets(self):
76 execute_builtin("UpdateLibrary(video,special://skin/foo)")
77 if get_setting("widget_refresh_notification", True):
78 notification("Widgets Refreshed", time=2500)
plugin.video.jacktook/lib/updater.py +29 -38
……@@ -1,32 +1,32 @@
1# -*- coding: utf-8 -*-
2import shutil
3import requests
1import contextlib
42import os
5from zipfile import ZipFile, BadZipFile
3import shutil
64import xml.etree.ElementTree as ET
5from zipfile import BadZipFile, ZipFile
6
7import requests
78import xbmc
89from xbmcvfs import translatePath as translate_path
910
11from lib.utils.general.utils import unzip
1012from lib.utils.kodi.utils import (
1113 ADDON_VERSION,
12 notification,
13 dialog_ok,
14 dialogyesno,
1514 close_all_dialog,
16 execute_builtin,
15 close_busy_dialog,
1716 delete_file,
18 update_local_addons,
19 disable_enable_addon,
20 update_kodi_addons_db,
21 dialog_text,
17 dialog_ok,
2218 dialog_select,
23 show_busy_dialog,
24 close_busy_dialog,
19 dialog_text,
20 dialogyesno,
21 disable_enable_addon,
22 execute_builtin,
2523 kodilog,
24 notification,
25 show_busy_dialog,
2626 translation,
27 update_kodi_addons_db,
28 update_local_addons,
2729)
28from lib.utils.general.utils import unzip
29
3030
3131# =========================
3232# Constants
……@@ -44,9 +44,10 @@ CHANGELOG_PATH = translate_path(f"special://home/addons/{ADDON_ID}/CHANGELOG.md"
4444#
4545# This tree carries local patches (Stremio behaviorHints.proxyHeaders plumbing),
4646# and update_addon() replaces the WHOLE addon directory -- so an upstream update
47# silently destroys them. Pointing the updater at a build we control means the
48# package always contains the patches, and upstream changes get merged
49# deliberately rather than landing unannounced.
47# silently destroys them. That is not hypothetical: on 2026-07-28 an upstream
48# 1.15.0 update was accepted and wiped every patch. Pointing the updater at a
49# build we control means the package always carries them, and upstream changes
50# get merged deliberately rather than landing unannounced.
5051#
5152# Served by the kodi-repo unit on stortron, which tracks the addon repo from the
5253# pear-git forge; the last hop is plain HTTP because that is all Kodi speaks.
……@@ -127,17 +128,16 @@ def _validate_downloaded_zip(zip_path, expected_version):
127128
128129 xml_data = zip_file.read(addon_xml)
129130 except (BadZipFile, KeyError, ValueError) as exc:
130 raise ValueError(str(exc))
131 raise ValueError(str(exc)) from exc
131132
132133 try:
133134 version = ET.fromstring(xml_data).attrib.get("version")
134135 except ET.ParseError as exc:
135 raise ValueError(f"Invalid addon.xml: {exc}")
136 raise ValueError(f"Invalid addon.xml: {exc}") from exc
136137
137138 if version != expected_version:
138139 raise ValueError(
139 "Package version mismatch: expected %s, found %s"
140 % (expected_version, version)
140 f"Package version mismatch: expected {expected_version}, found {version}"
141141 )
142142
143143
……@@ -149,8 +149,7 @@ def _validate_installed_version(destination_dir, expected_version):
149149 version = _read_addon_version_from_xml(addon_xml_path)
150150 if version != expected_version:
151151 raise ValueError(
152 "Installed version mismatch: expected %s, found %s"
153 % (expected_version, version)
152 f"Installed version mismatch: expected {expected_version}, found {version}"
154153 )
155154
156155
……@@ -264,9 +263,7 @@ def downgrade_addon_menu():
264263
265264 versions.sort(key=n, reverse=True)
266265
267 selected_index = dialog_select(
268 heading=translation(90585), _list=versions
269 )
266 selected_index = dialog_select(heading=translation(90585), _list=versions)
270267
271268 if selected_index == -1:
272269 return
……@@ -341,13 +338,9 @@ def update_addon(new_version):
341338 except Exception as e:
342339 kodilog(f"Error extracting update to staging: {e}")
343340 delete_file(zip_path)
344 try:
341 with contextlib.suppress(Exception):
345342 _safe_remove_path(staging_dir)
346 except Exception:
347 pass
348 dialog_ok(
349 heading=HEADING, line1=translation(90590)
350 )
343 dialog_ok(heading=HEADING, line1=translation(90590))
351344 return
352345
353346 replaced_existing_install = False
……@@ -374,10 +367,8 @@ def update_addon(new_version):
374367 kodilog(f"Error restoring previous addon version: {restore_error}")
375368
376369 delete_file(zip_path)
377 try:
370 with contextlib.suppress(Exception):
378371 _safe_remove_path(staging_dir)
379 except Exception:
380 pass
381372 dialog_ok(heading=HEADING, line1=translation(90590))
382373 return
383374
……@@ -399,7 +390,7 @@ def update_addon(new_version):
399390 disable_enable_addon()
400391 update_kodi_addons_db()
401392 notification(heading=HEADING, message="Reloading Kodi profile to apply addon changes")
402 execute_builtin('LoadProfile(%s)' % xbmc.getInfoLabel("System.ProfileName"), True)
393 execute_builtin("LoadProfile({})".format(xbmc.getInfoLabel("System.ProfileName")), True)
403394
404395 notification(heading=HEADING, message=translation(90593))
405396 kodilog("Update process finished successfully.")
plugin.video.jacktook/lib/utils/clients/utils.py +28 -7
……@@ -1,7 +1,8 @@
11from typing import Any, Callable, Dict, Optional
2from lib.utils.kodi.utils import get_setting, notification, translation
2
33from lib.utils.general.utils import Indexer
44from lib.utils.kodi.settings import get_int_setting
5from lib.utils.kodi.utils import get_setting, notification, translation
56
67
78def validate_host(host: Optional[str], indexer: str) -> Optional[bool]:
……@@ -23,17 +24,13 @@ def update_dialog(title: str, message: str, dialog: Any, percent: int = 0) -> No
2324 dialog.update(percent, translation(90661) % title, message)
2425
2526
26def validate_credentials(
27 indexer: str, host: Optional[str], api_key: Optional[str] = None
28) -> bool:
27def validate_credentials(indexer: str, host: Optional[str], api_key: Optional[str] = None) -> bool:
2928 """
3029 Validates the host and API key for a given indexer.
3130 """
3231 if not validate_host(host, indexer):
3332 return False
34 if api_key is not None and not validate_key(api_key, indexer):
35 return False
36 return True
33 return not (api_key is not None and not validate_key(api_key, indexer))
3734
3835
3936def build_jackett_client() -> Optional[object]:
……@@ -100,12 +97,36 @@ def build_easynews_client() -> Optional[object]:
10097 return Easynews(user, password, timeout, notification)
10198
10299
100def build_external_scraper_client() -> Optional[object]:
101 from lib.clients.external_scraper import ExternalScraperClient
102 from lib.utils.kodi.utils import kodilog
103
104 kodilog("[ExternalScraper] build_external_scraper_client() called")
105
106 module_id = get_setting("external_scraper_module")
107 if not module_id:
108 return None
109
110 module_display_name = (
111 get_setting("external_scraper_module_name") or module_id.split(".")[-1].title()
112 )
113 client = ExternalScraperClient(module_id, module_display_name, notification)
114 kodilog(
115 f"[ExternalScraper] initialized={client.initialized}, "
116 f"providers={len(client._providers) if client._providers else 0}"
117 )
118 if not client.initialized:
119 return None
120 return client
121
122
103123CLIENT_BUILDERS: Dict[str, Callable[[], Optional[object]]] = {
104124 Indexer.JACKETT: build_jackett_client,
105125 Indexer.PROWLARR: build_prowlarr_client,
106126 Indexer.JACKGRAM: build_jackgram_client,
107127 Indexer.BURST: build_burst_client,
108128 Indexer.EASYNEWS: build_easynews_client,
129 Indexer.EXTERNAL_SCRAPER: build_external_scraper_client,
109130}
110131
111132
plugin.video.jacktook/lib/utils/debrid/debrid_utils.py +76 -68
……@@ -1,23 +1,26 @@
11import base64
2from concurrent.futures import ThreadPoolExecutor
32import copy
43import json
5import requests
4from concurrent.futures import ThreadPoolExecutor
65from threading import Lock
76from typing import Any, Dict, List, Optional, Tuple
87from urllib.parse import urljoin
98
9import requests
10from xbmc import LOGDEBUG
11from xbmcgui import Dialog
12
1013from lib.api.debrid.base import ProviderException
11from lib.db.cached import cache
1214from lib.clients.debrid.alldebrid import AllDebridHelper
1315from lib.clients.debrid.debrider import DebriderHelper
1416from lib.clients.debrid.easydebrid import EasyDebridHelper
17from lib.clients.debrid.offcloud import OffcloudHelper
1518from lib.clients.debrid.premiumize import PremiumizeHelper
16from lib.clients.debrid.torbox import TorboxHelper
1719from lib.clients.debrid.realdebrid import RealDebridHelper
18from lib.clients.stremio.constants import STREMIO_ADDONS_KEY
19from lib.utils.kodi.utils import get_setting, kodilog, notification, translation
20from lib.utils.torrent.torrserver_utils import extract_torrent_metadata
20from lib.clients.debrid.torbox import TorboxHelper
21from lib.clients.stremio.constants import STREMIO_ADDON_ALIASES_KEY, STREMIO_ADDONS_KEY
22from lib.db.cached import cache
23from lib.domain.torrent import TorrentStream
2124from lib.utils.general.utils import (
2225 USER_AGENT_HEADER,
2326 DebridType,
……@@ -29,23 +32,22 @@ from lib.utils.general.utils import (
2932 is_ad_enabled,
3033 is_debrider_enabled,
3134 is_ed_enabled,
35 is_oc_enabled,
3236 is_pm_enabled,
3337 is_rd_enabled,
3438 is_tb_enabled,
3539 is_url,
3640 set_cached,
3741)
38from lib.domain.torrent import TorrentStream
3942from lib.utils.kodi.logging import summarize_locator_for_log
40
41from xbmcgui import Dialog
42from xbmc import LOGDEBUG
43
43from lib.utils.kodi.utils import get_setting, kodilog, notification, translation
44from lib.utils.torrent.torrserver_utils import extract_torrent_metadata
4445
4546DEBRID_HELPERS = {
4647 DebridType.RD: RealDebridHelper,
4748 DebridType.PM: PremiumizeHelper,
4849 DebridType.TB: TorboxHelper,
50 DebridType.OC: OffcloudHelper,
4951 DebridType.DB: DebriderHelper,
5052 DebridType.AD: AllDebridHelper,
5153 DebridType.ED: EasyDebridHelper,
……@@ -54,17 +56,19 @@ DEBRID_HELPERS = {
5456DEBRID_CHECKS = {
5557 DebridType.RD: is_rd_enabled,
5658 DebridType.TB: is_tb_enabled,
59 DebridType.OC: is_oc_enabled,
5760 DebridType.PM: is_pm_enabled,
5861 DebridType.DB: is_debrider_enabled,
5962 DebridType.AD: is_ad_enabled,
6063}
6164
62PACK_DIRECT_DEBRID_TYPES = {DebridType.RD, DebridType.TB, DebridType.AD}
65PACK_DIRECT_DEBRID_TYPES = {DebridType.RD, DebridType.TB, DebridType.OC, DebridType.AD}
6366
6467SUPPORTED_CLOUD_TRANSFER_DEBRIDS = (
6568 DebridType.RD,
6669 DebridType.AD,
6770 DebridType.TB,
71 DebridType.OC,
6872 DebridType.DB,
6973)
7074
……@@ -76,14 +80,19 @@ def _build_debrid_cache_scope() -> str:
7680 "rd": bool(get_setting("real_debrid_enabled")),
7781 "ad": bool(get_setting("alldebrid_enabled")),
7882 "tb": bool(get_setting("torbox_enabled")),
83 "oc": bool(get_setting("offcloud_enabled")),
7984 "pm": bool(get_setting("premiumize_enabled")),
8085 "db": bool(get_setting("debrider_enabled")),
8186 "ed": bool(get_setting("easydebrid_enabled")),
8287 }
8388 selected_stream_addons = str(cache.get(STREMIO_ADDONS_KEY) or "")
84 return "{}|{}".format(
89 stremio_addon_aliases = json.dumps(
90 cache.get(STREMIO_ADDON_ALIASES_KEY) or {}, sort_keys=True
91 )
92 return "{}|{}|{}".format(
8593 "|".join([f"{key}:{int(value)}" for key, value in sorted(flags.items())]),
8694 selected_stream_addons,
95 stremio_addon_aliases,
8796 )
8897
8998
……@@ -125,7 +134,7 @@ def _is_torrent_ready_in_debrid(debrid_type: str, info_hash: str) -> bool:
125134 """Check if a torrent is ready/cached in the specified debrid service."""
126135 if not info_hash:
127136 return False
128
137
129138 try:
130139 if debrid_type == DebridType.RD:
131140 torrent_info = RealDebridHelper().client.get_available_torrent(info_hash)
……@@ -138,9 +147,17 @@ def _is_torrent_ready_in_debrid(debrid_type: str, info_hash: str) -> bool:
138147 return True
139148 elif debrid_type == DebridType.TB:
140149 torrent_info = TorboxHelper().client.get_available_torrent(info_hash)
141 if (torrent_info and
142 torrent_info.get("download_finished") and
143 torrent_info.get("download_present")):
150 if (
151 torrent_info
152 and torrent_info.get("download_finished")
153 and torrent_info.get("download_present")
154 ):
155 return True
156 elif debrid_type == DebridType.OC:
157 response = OffcloudHelper().client.get_cache_info(
158 [info_hash_to_magnet(info_hash)], include_files=False
159 )
160 if isinstance(response, list) and response and response[0].get("cached"):
144161 return True
145162 elif debrid_type == DebridType.DB:
146163 info = DebriderHelper().get_info(info_hash)
……@@ -148,7 +165,7 @@ def _is_torrent_ready_in_debrid(debrid_type: str, info_hash: str) -> bool:
148165 return True
149166 except Exception as e:
150167 kodilog(f"Error checking if torrent is ready in {debrid_type}: {e}")
151
168
152169 return False
153170
154171
……@@ -185,6 +202,14 @@ def _add_to_torbox(info_hash: str, torrent_data: bytes, torrent_name: str):
185202 return None
186203
187204
205def _add_to_offcloud(info_hash: str, torrent_data: bytes, torrent_name: str):
206 if info_hash:
207 kodilog("Debrid transfer path: sending magnet/info_hash to OC")
208 return OffcloudHelper().add_cloud_download(info_hash)
209 notification(translation(90361))
210 return None
211
212
188213def _add_to_debrider(info_hash: str):
189214 if not info_hash:
190215 notification(translation(90361))
……@@ -197,6 +222,7 @@ _DEBRID_ADDERS = {
197222 DebridType.RD: _add_to_realdebrid,
198223 DebridType.AD: _add_to_alldebrid,
199224 DebridType.TB: _add_to_torbox,
225 DebridType.OC: _add_to_offcloud,
200226 DebridType.DB: _add_to_debrider,
201227}
202228
……@@ -215,23 +241,23 @@ def add_source_to_debrid(
215241 adder = _DEBRID_ADDERS.get(debrid_type)
216242 if not adder:
217243 raise ValueError(f"Unsupported debrid cloud transfer type: {debrid_type}")
218
244
219245 if debrid_type == DebridType.DB:
220246 result = adder(info_hash)
221247 else:
222248 result = adder(info_hash, torrent_data, torrent_name)
223
249
224250 if not result:
225251 raise ProviderException(f"Failed to add torrent to {debrid_type}: no response data")
226252
227253 kodilog(f"Debrid transfer succeeded: debrid={debrid_type}, result={result is not None}")
228
254
229255 is_ready = _is_torrent_ready_in_debrid(debrid_type, info_hash)
230256 if is_ready:
231257 notification(translation(90362) % debrid_type)
232258 else:
233259 notification(translation(90694) % debrid_type)
234
260
235261 return debrid_type
236262 except Exception as exc:
237263 kodilog(f"Failed to add source to debrid cloud: {exc}")
……@@ -270,9 +296,7 @@ def check_debrid_cached(
270296 kodilog("No debrid services enabled for caching check.")
271297 return direct_results
272298
273 execute_debrid_checks(
274 check_functions, results, cached_results, uncached_results, dialog, lock
275 )
299 execute_debrid_checks(check_functions, results, cached_results, uncached_results, dialog, lock)
276300
277301 cached_results.extend(direct_results)
278302 if should_include_uncached():
……@@ -315,9 +339,7 @@ def execute_debrid_checks(
315339 dialog: Dialog,
316340 lock: Lock,
317341):
318 with ThreadPoolExecutor(
319 max_workers=int(get_setting("thread_number", 6))
320 ) as executor:
342 with ThreadPoolExecutor(max_workers=int(get_setting("thread_number", 6))) as executor:
321343 futures = [
322344 executor.submit(
323345 fn,
……@@ -338,7 +360,7 @@ def execute_debrid_checks(
338360
339361
340362def should_include_uncached() -> bool:
341 return any([is_tb_enabled(), is_pm_enabled(), is_ed_enabled()]) and bool(
363 return any([is_tb_enabled(), is_oc_enabled(), is_pm_enabled(), is_ed_enabled()]) and bool(
342364 get_setting("show_uncached")
343365 )
344366
……@@ -366,18 +388,18 @@ def get_source_status(res: TorrentStream) -> str:
366388
367389 if res.isPack:
368390 if is_cached:
369 label = f"[B]Cached-[Pack][/B]"
391 label = "[B]Cached-[Pack][/B]"
370392 elif res.addedToDebrid:
371393 label = f"[B]{added_label}-[Pack][/B]"
372394 else:
373 label = f"[B]Download-[Pack][/B]"
395 label = "[B]Download-[Pack][/B]"
374396 else:
375397 if is_cached:
376 label = f"[B]Cached[/B]"
398 label = "[B]Cached[/B]"
377399 elif res.addedToDebrid:
378400 label = f"[B]{added_label}[/B]"
379401 else:
380 label = f"[B]Download[/B]"
402 label = "[B]Download[/B]"
381403 return label
382404
383405
……@@ -385,9 +407,7 @@ def get_pack_info(debrid_type, info_hash):
385407 return get_debrid_helper(debrid_type).get_pack_info(info_hash)
386408
387409
388def filter_results(
389 results: List[TorrentStream], direct_results: List[TorrentStream]
390) -> None:
410def filter_results(results: List[TorrentStream], direct_results: List[TorrentStream]) -> None:
391411 filtered_results = []
392412 dropped_results = []
393413
……@@ -412,11 +432,10 @@ def extract_info_hash(res: TorrentStream) -> Optional[str]:
412432 if res.infoHash:
413433 return res.infoHash.lower()
414434
415 if res.guid:
416 if res.guid.startswith("magnet:?") or len(res.guid) == 40:
417 info_hash = get_info_hash_from_magnet(res.guid)
418 if info_hash:
419 return info_hash.lower()
435 if res.guid and (res.guid.startswith("magnet:?") or len(res.guid) == 40):
436 info_hash = get_info_hash_from_magnet(res.guid)
437 if info_hash:
438 return info_hash.lower()
420439
421440 if res.url and res.url.startswith("magnet:?"):
422441 info_hash = get_info_hash_from_magnet(res.url)
……@@ -440,7 +459,7 @@ def get_torrent_data_from_uri(uri: str) -> Tuple[bytes, str, str, str]:
440459
441460 try:
442461 current_uri = uri
443 for redirect_count in range(5):
462 for _redirect_count in range(5):
444463 res = requests.get(
445464 current_uri,
446465 allow_redirects=False,
……@@ -448,10 +467,7 @@ def get_torrent_data_from_uri(uri: str) -> Tuple[bytes, str, str, str]:
448467 headers=USER_AGENT_HEADER,
449468 )
450469 kodilog(
451 "get_torrent_data_from_uri: GET request to {} returned status code: {}".format(
452 summarize_locator_for_log(current_uri),
453 res.status_code,
454 )
470 f"get_torrent_data_from_uri: GET request to {summarize_locator_for_log(current_uri)} returned status code: {res.status_code}"
455471 )
456472
457473 if 300 <= res.status_code < 400:
……@@ -460,10 +476,7 @@ def get_torrent_data_from_uri(uri: str) -> Tuple[bytes, str, str, str]:
460476 break
461477
462478 kodilog(
463 "get_torrent_data_from_uri: Redirect {} -> {}".format(
464 summarize_locator_for_log(current_uri),
465 summarize_locator_for_log(location),
466 )
479 f"get_torrent_data_from_uri: Redirect {summarize_locator_for_log(current_uri)} -> {summarize_locator_for_log(location)}"
467480 )
468481
469482 if location.startswith("magnet:?"):
……@@ -476,9 +489,7 @@ def get_torrent_data_from_uri(uri: str) -> Tuple[bytes, str, str, str]:
476489
477490 if res.status_code == 200:
478491 kodilog(
479 "get_torrent_data_from_uri: Processing content from {}".format(
480 summarize_locator_for_log(current_uri)
481 )
492 f"get_torrent_data_from_uri: Processing content from {summarize_locator_for_log(current_uri)}"
482493 )
483494 if res.url.startswith("magnet:"):
484495 magnet = str(res.url or "")
……@@ -487,21 +498,20 @@ def get_torrent_data_from_uri(uri: str) -> Tuple[bytes, str, str, str]:
487498
488499 magnet = extract_torrent_metadata(res.content)
489500 kodilog(
490 "get_torrent_data_from_uri: Extracted magnet: {}".format(
491 summarize_locator_for_log(magnet)
492 )
501 f"get_torrent_data_from_uri: Extracted magnet: {summarize_locator_for_log(magnet)}"
493502 )
494503 if magnet:
495504 torrent_data = bytes(res.content or b"")
496505 info_hash = get_info_hash_from_magnet(magnet).lower()
497506 torrent_url = current_uri
498507 kodilog(
499 "get_torrent_data_from_uri: Preserving torrent URL for playback: {}".format(
500 summarize_locator_for_log(torrent_url)
501 )
508 f"get_torrent_data_from_uri: Preserving torrent URL for playback: {summarize_locator_for_log(torrent_url)}"
502509 )
503 return torrent_data, str(magnet or ""), str(info_hash or ""), str(
504 torrent_url or ""
510 return (
511 torrent_data,
512 str(magnet or ""),
513 str(info_hash or ""),
514 str(torrent_url or ""),
505515 )
506516
507517 break
……@@ -570,9 +580,9 @@ def process_external_cache(data: dict, debrid: str, token: str, url: str):
570580 },
571581 }
572582 headers = {
573 "encoded_user_data": base64.b64encode(
574 json.dumps(params).encode("utf-8")
575 ).decode("utf-8")
583 "encoded_user_data": base64.b64encode(json.dumps(params).encode("utf-8")).decode(
584 "utf-8"
585 )
576586 }
577587
578588 elif service == "comet":
……@@ -593,9 +603,7 @@ def process_external_cache(data: dict, debrid: str, token: str, url: str):
593603 "remove_unknown_languages": False,
594604 },
595605 }
596 params_encoded = base64.b64encode(
597 json.dumps(params).encode("utf-8")
598 ).decode("utf-8")
606 params_encoded = base64.b64encode(json.dumps(params).encode("utf-8")).decode("utf-8")
599607 base_link = f"https://comet.elfhosted.com/{params_encoded}"
600608 headers = {}
601609
plugin.video.jacktook/lib/utils/debrid/qrcode_utils.py +3 -2
……@@ -1,10 +1,11 @@
11from os import path
22
3import xbmcgui
4import xbmcvfs
5
36from lib.jacktook.utils import ADDON_PATH, kodilog
47from lib.utils.kodi.utils import ADDON_PROFILE_PATH
58from lib.vendor.segno import make as segnomake
6import xbmcvfs
7import xbmcgui
89
910
1011def make_qrcode(url):
plugin.video.jacktook/lib/utils/general/items_menus.py +227 -40
……@@ -1,52 +1,243 @@
1from lib.utils.kodi.utils import translation, get_setting
1from lib.utils.kodi.utils import get_setting, translation
22from lib.utils.views.continue_watching import has_continue_watching_items
33
4
54trakt_tv_library_items = [
6 {"name": translation(90035), "mode": "tv", "api": "trakt", "query": "trakt_watchlist", "icon": "trakt.png"},
7 {"name": translation(90294), "mode": "tv", "api": "trakt", "query": "trakt_collection", "icon": "trakt.png"},
8 {"name": translation(90295), "mode": "tv", "api": "trakt", "query": "trakt_favorites", "icon": "trakt.png"},
9 {"name": translation(90296), "mode": "tv", "api": "trakt", "query": "trakt_up_next", "icon": "trakt.png"},
10 {"name": translation(90034), "mode": "tv", "api": "trakt", "query": "trakt_watched_history", "icon": "trakt.png"},
11 {"name": translation(90297), "mode": "tv", "api": "trakt", "query": "trakt_my_lists", "icon": "trakt.png"},
12 {"name": translation(90298), "mode": "tv", "api": "trakt", "query": "trakt_liked_lists", "icon": "trakt.png"},
13 {"name": translation(90076), "mode": "tv", "api": "trakt", "query": "trakt_search_lists", "icon": "trakt.png"},
14 {"name": translation(30916), "mode": "tv", "api": "trakt", "query": "trakt_account_info", "icon": "trakt.png"},
5 {
6 "name": translation(90035),
7 "mode": "tv",
8 "api": "trakt",
9 "query": "trakt_watchlist",
10 "icon": "trakt.png",
11 },
12 {
13 "name": translation(90294),
14 "mode": "tv",
15 "api": "trakt",
16 "query": "trakt_collection",
17 "icon": "trakt.png",
18 },
19 {
20 "name": translation(90295),
21 "mode": "tv",
22 "api": "trakt",
23 "query": "trakt_favorites",
24 "icon": "trakt.png",
25 },
26 {
27 "name": translation(90296),
28 "mode": "tv",
29 "api": "trakt",
30 "query": "trakt_up_next",
31 "icon": "trakt.png",
32 },
33 {
34 "name": translation(90034),
35 "mode": "tv",
36 "api": "trakt",
37 "query": "trakt_watched_history",
38 "icon": "trakt.png",
39 },
40 {
41 "name": translation(90297),
42 "mode": "tv",
43 "api": "trakt",
44 "query": "trakt_my_lists",
45 "icon": "trakt.png",
46 },
47 {
48 "name": translation(90298),
49 "mode": "tv",
50 "api": "trakt",
51 "query": "trakt_liked_lists",
52 "icon": "trakt.png",
53 },
54 {
55 "name": translation(90076),
56 "mode": "tv",
57 "api": "trakt",
58 "query": "trakt_search_lists",
59 "icon": "trakt.png",
60 },
61 {
62 "name": translation(30916),
63 "mode": "tv",
64 "api": "trakt",
65 "query": "trakt_account_info",
66 "icon": "trakt.png",
67 },
1568]
1669
1770
1871trakt_tv_discovery_items = [
19 {"name": translation(90028), "mode": "tv", "api": "trakt", "query": "trakt_trending", "icon": "trakt.png"},
20 {"name": translation(90029), "mode": "tv", "api": "trakt", "query": "trakt_watched", "icon": "trakt.png"},
21 {"name": translation(90030), "mode": "tv", "api": "trakt", "query": "trakt_favorited", "icon": "trakt.png"},
22 {"name": translation(90033), "mode": "tv", "api": "trakt", "query": "trakt_recommendations", "icon": "trakt.png"},
23 {"name": translation(90032), "mode": "tv", "api": "trakt", "query": "trakt_trending_lists", "icon": "trakt.png"},
24 {"name": translation(90031), "mode": "tv", "api": "trakt", "query": "trakt_popular_lists", "icon": "trakt.png"},
25 {"name": translation(30896), "mode": "tv", "api": "trakt", "query": "trakt_calendar", "icon": "trakt.png"},
72 {
73 "name": translation(90028),
74 "mode": "tv",
75 "api": "trakt",
76 "query": "trakt_trending",
77 "icon": "trakt.png",
78 },
79 {
80 "name": translation(90029),
81 "mode": "tv",
82 "api": "trakt",
83 "query": "trakt_watched",
84 "icon": "trakt.png",
85 },
86 {
87 "name": translation(90030),
88 "mode": "tv",
89 "api": "trakt",
90 "query": "trakt_favorited",
91 "icon": "trakt.png",
92 },
93 {
94 "name": translation(90033),
95 "mode": "tv",
96 "api": "trakt",
97 "query": "trakt_recommendations",
98 "icon": "trakt.png",
99 },
100 {
101 "name": translation(90032),
102 "mode": "tv",
103 "api": "trakt",
104 "query": "trakt_trending_lists",
105 "icon": "trakt.png",
106 },
107 {
108 "name": translation(90031),
109 "mode": "tv",
110 "api": "trakt",
111 "query": "trakt_popular_lists",
112 "icon": "trakt.png",
113 },
114 {
115 "name": translation(30896),
116 "mode": "tv",
117 "api": "trakt",
118 "query": "trakt_calendar",
119 "icon": "trakt.png",
120 },
26121]
27122
28123
29124trakt_movie_library_items = [
30 {"name": translation(90035), "mode": "movies", "api": "trakt", "query": "trakt_watchlist", "icon": "trakt.png"},
31 {"name": translation(90294), "mode": "movies", "api": "trakt", "query": "trakt_collection", "icon": "trakt.png"},
32 {"name": translation(90295), "mode": "movies", "api": "trakt", "query": "trakt_favorites", "icon": "trakt.png"},
33 {"name": translation(90200), "mode": "movies", "api": "trakt", "query": "trakt_movies_progress", "icon": "trakt.png"},
34 {"name": translation(90034), "mode": "movies", "api": "trakt", "query": "trakt_watched_history", "icon": "trakt.png"},
35 {"name": translation(90297), "mode": "movies", "api": "trakt", "query": "trakt_my_lists", "icon": "trakt.png"},
36 {"name": translation(90298), "mode": "movies", "api": "trakt", "query": "trakt_liked_lists", "icon": "trakt.png"},
37 {"name": translation(90076), "mode": "movies", "api": "trakt", "query": "trakt_search_lists", "icon": "trakt.png"},
38 {"name": translation(30916), "mode": "movies", "api": "trakt", "query": "trakt_account_info", "icon": "trakt.png"},
125 {
126 "name": translation(90035),
127 "mode": "movies",
128 "api": "trakt",
129 "query": "trakt_watchlist",
130 "icon": "trakt.png",
131 },
132 {
133 "name": translation(90294),
134 "mode": "movies",
135 "api": "trakt",
136 "query": "trakt_collection",
137 "icon": "trakt.png",
138 },
139 {
140 "name": translation(90295),
141 "mode": "movies",
142 "api": "trakt",
143 "query": "trakt_favorites",
144 "icon": "trakt.png",
145 },
146 {
147 "name": translation(90200),
148 "mode": "movies",
149 "api": "trakt",
150 "query": "trakt_movies_progress",
151 "icon": "trakt.png",
152 },
153 {
154 "name": translation(90034),
155 "mode": "movies",
156 "api": "trakt",
157 "query": "trakt_watched_history",
158 "icon": "trakt.png",
159 },
160 {
161 "name": translation(90297),
162 "mode": "movies",
163 "api": "trakt",
164 "query": "trakt_my_lists",
165 "icon": "trakt.png",
166 },
167 {
168 "name": translation(90298),
169 "mode": "movies",
170 "api": "trakt",
171 "query": "trakt_liked_lists",
172 "icon": "trakt.png",
173 },
174 {
175 "name": translation(90076),
176 "mode": "movies",
177 "api": "trakt",
178 "query": "trakt_search_lists",
179 "icon": "trakt.png",
180 },
181 {
182 "name": translation(30916),
183 "mode": "movies",
184 "api": "trakt",
185 "query": "trakt_account_info",
186 "icon": "trakt.png",
187 },
39188]
40189
41190
42191trakt_movie_discovery_items = [
43 {"name": translation(90028), "mode": "movies", "api": "trakt", "query": "trakt_trending", "icon": "trakt.png"},
44 {"name": translation(90036), "mode": "movies", "api": "trakt", "query": "trakt_top10", "icon": "trakt.png"},
45 {"name": translation(90029), "mode": "movies", "api": "trakt", "query": "trakt_watched", "icon": "trakt.png"},
46 {"name": translation(90030), "mode": "movies", "api": "trakt", "query": "trakt_favorited", "icon": "trakt.png"},
47 {"name": translation(90033), "mode": "movies", "api": "trakt", "query": "trakt_recommendations", "icon": "trakt.png"},
48 {"name": translation(90032), "mode": "movies", "api": "trakt", "query": "trakt_trending_lists", "icon": "trakt.png"},
49 {"name": translation(90031), "mode": "movies", "api": "trakt", "query": "trakt_popular_lists", "icon": "trakt.png"},
192 {
193 "name": translation(90028),
194 "mode": "movies",
195 "api": "trakt",
196 "query": "trakt_trending",
197 "icon": "trakt.png",
198 },
199 {
200 "name": translation(90036),
201 "mode": "movies",
202 "api": "trakt",
203 "query": "trakt_top10",
204 "icon": "trakt.png",
205 },
206 {
207 "name": translation(90029),
208 "mode": "movies",
209 "api": "trakt",
210 "query": "trakt_watched",
211 "icon": "trakt.png",
212 },
213 {
214 "name": translation(90030),
215 "mode": "movies",
216 "api": "trakt",
217 "query": "trakt_favorited",
218 "icon": "trakt.png",
219 },
220 {
221 "name": translation(90033),
222 "mode": "movies",
223 "api": "trakt",
224 "query": "trakt_recommendations",
225 "icon": "trakt.png",
226 },
227 {
228 "name": translation(90032),
229 "mode": "movies",
230 "api": "trakt",
231 "query": "trakt_trending_lists",
232 "icon": "trakt.png",
233 },
234 {
235 "name": translation(90031),
236 "mode": "movies",
237 "api": "trakt",
238 "query": "trakt_popular_lists",
239 "icon": "trakt.png",
240 },
50241]
51242
52243
……@@ -344,17 +535,13 @@ library_menu_items = [
344535 "name": 90202,
345536 "icon": "tv.png",
346537 "action": "library_shows",
347 "context_menu": [
348 {"label": 90690, "action": "library_shows", "params": {"clear": 1}}
349 ],
538 "context_menu": [{"label": 90690, "action": "library_shows", "params": {"clear": 1}}],
350539 }, # My Shows
351540 {
352541 "name": 90203,
353542 "icon": "movies.png",
354543 "action": "library_movies",
355 "context_menu": [
356 {"label": 90690, "action": "library_movies", "params": {"clear": 1}}
357 ],
544 "context_menu": [{"label": 90690, "action": "library_movies", "params": {"clear": 1}}],
358545 }, # My Movies
359546 {
360547 "name": 90021,
plugin.video.jacktook/lib/utils/general/processors.py +64 -14
……@@ -1,9 +1,10 @@
1from enum import Enum
21import re
3from typing import List, Dict, Optional
2from enum import Enum
3from typing import Dict, List, Optional
44
5from lib.utils.kodi.utils import get_setting, kodilog
65from lib.clients.base import TorrentStream
6from lib.utils.kodi.utils import get_setting, kodilog
7from lib.utils.parsers.title_parser import extract_codec_hdr
78
89
910class Quality(Enum):
……@@ -166,11 +167,7 @@ class PreProcessBuilder(BaseProcessBuilder):
166167
167168 def filter_season_packs(self, season_num: int) -> List[TorrentStream]:
168169 season_patterns = self.get_season_pack_patterns(season_num)
169 return [
170 res
171 for res in self.results
172 if re.search("|".join(season_patterns), res.title)
173 ]
170 return [res for res in self.results if re.search("|".join(season_patterns), res.title)]
174171
175172 def filter_sources(
176173 self, episode_name: str, episode_num: int, season_num: int
……@@ -198,17 +195,13 @@ class PreProcessBuilder(BaseProcessBuilder):
198195 patterns.append(re.escape(episode_name))
199196
200197 episode_results = [
201 res
202 for res in self.results
203 if re.search("|".join(patterns), res.title, re.IGNORECASE)
198 res for res in self.results if re.search("|".join(patterns), res.title, re.IGNORECASE)
204199 ]
205200 self.results = season_pack_results + episode_results
206201 return self
207202
208203 def filter_by_quality(self) -> "PreProcessBuilder":
209 quality_buckets: Dict[Quality, List[TorrentStream]] = {
210 quality: [] for quality in Quality
211 }
204 quality_buckets: Dict[Quality, List[TorrentStream]] = {quality: [] for quality in Quality}
212205 for res in self.results:
213206 title = res.title
214207 matched_quality = False
……@@ -235,9 +228,11 @@ class PreProcessBuilder(BaseProcessBuilder):
235228 def filter_by_source(self) -> "PreProcessBuilder":
236229 """
237230 Categorize torrents into SourceCategory buckets and filter them
231
238232 depending on Kodi settings (quality_filter group).
239233 """
240234 source_buckets: Dict[SourceCategory, List[TorrentStream]] = {
235
241236 cat: [] for cat in SourceCategory
242237 }
243238
……@@ -291,3 +286,58 @@ class PreProcessBuilder(BaseProcessBuilder):
291286
292287 self.results = filtered
293288 return self
289
290 def extract_codec_hdr(self) -> "PreProcessBuilder":
291 for res in self.results:
292 codec, hdr = extract_codec_hdr(res.title)
293 res.codec = codec
294 res.hdr = hdr
295 return self
296
297 def filter_by_codec(self) -> "PreProcessBuilder":
298 hevc_enabled = get_setting("codec_hevc_enabled", True)
299 av1_enabled = get_setting("codec_av1_enabled", True)
300 h264_enabled = get_setting("codec_h264_enabled", True)
301
302 if hevc_enabled and av1_enabled and h264_enabled:
303 return self
304
305 filtered = []
306 for res in self.results:
307 if not res.codec:
308 filtered.append(res)
309 continue
310 if res.codec == "HEVC" and not hevc_enabled:
311 continue
312 if res.codec == "AV1" and not av1_enabled:
313 continue
314 if res.codec == "H.264" and not h264_enabled:
315 continue
316 filtered.append(res)
317
318 self.results = filtered
319 return self
320
321 def filter_by_hdr(self) -> "PreProcessBuilder":
322 dv_enabled = get_setting("dolby_vision_enabled", True)
323 hdr10_enabled = get_setting("hdr10_enabled", True)
324 hdr_enabled = get_setting("hdr_enabled", True)
325
326 if dv_enabled and hdr10_enabled and hdr_enabled:
327 return self
328
329 filtered = []
330 for res in self.results:
331 if not res.hdr:
332 filtered.append(res)
333 continue
334 if res.hdr == "DV" and not dv_enabled:
335 continue
336 if res.hdr in ("HDR10", "HDR10+") and not hdr10_enabled:
337 continue
338 if res.hdr == "HDR" and not hdr_enabled:
339 continue
340 filtered.append(res)
341
342 self.results = filtered
343 return self
plugin.video.jacktook/lib/utils/general/utils.py +137 -88
……@@ -1,35 +1,44 @@
1import contextlib
2import hashlib
3import json
14import os
25import re
3import hashlib
46import unicodedata
5import json
7from collections import deque
8from concurrent.futures import ThreadPoolExecutor, as_completed
9from datetime import datetime, timedelta
610from email.utils import parsedate_to_datetime
711from enum import Enum
8from urllib.parse import unquote
9import requests
1012from typing import Dict, List
13from urllib.parse import unquote
1114from zipfile import ZipFile
12from concurrent.futures import ThreadPoolExecutor, as_completed
13from datetime import datetime, timedelta
15
16import requests
17import xbmc
18from xbmc import getSupportedMedia
19from xbmcgui import Dialog, DialogProgressBG
20from xbmcplugin import addDirectoryItem, setContent, setPluginCategory
1421
1522from lib.api.fanart.fanart import get_fanart
16from lib.clients.subtitle.utils import get_language_code
17from lib.gui.qr_progress_dialog import QRProgressDialog
18from lib.api.trakt.trakt_cache import trakt_watched_cache, trakt_cache
23from lib.api.tmdbv3api.tmdb import TMDb
1924from lib.api.trakt.lists_cache import lists_cache
2025from lib.api.trakt.main_cache import main_cache
21from lib.utils.debrid.qrcode_utils import make_qrcode
22from lib.utils.general.processors import PostProcessBuilder, PreProcessBuilder
23from lib.clients.base import TorrentStream
26from lib.api.trakt.trakt_cache import trakt_cache, trakt_watched_cache
2427from lib.api.tvdbapi.tvdbapi import TVDBAPI
25from lib.db.cached import cache
28from lib.clients.base import TorrentStream
2629from lib.clients.stremio.constants import (
27 STREMIO_ADDONS_KEY,
2830 STREMIO_ADDONS_CATALOGS_KEY,
31 STREMIO_ADDONS_KEY,
2932 STREMIO_TV_ADDONS_KEY,
3033 STREMIO_USER_ADDONS,
3134)
35from lib.clients.subtitle.utils import get_language_code
36from lib.db.cached import cache
3237from lib.db.pickle_db import PickleDatabase
38from lib.gui.qr_progress_dialog import QRProgressDialog
39from lib.utils.debrid.qrcode_utils import make_qrcode
40from lib.utils.general.processors import PostProcessBuilder, PreProcessBuilder
41from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled
3342from lib.utils.kodi.utils import (
3443 ADDON_HANDLE,
3544 ADDON_PATH,
……@@ -52,19 +61,8 @@ from lib.utils.kodi.utils import (
5261 translatePath,
5362 translation,
5463)
55
56from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled
5764from lib.vendor.torf._magnet import Magnet
5865
59from collections import deque
60
61from xbmcgui import Dialog
62from xbmcgui import DialogProgressBG
63from xbmcplugin import addDirectoryItem, setContent, setPluginCategory
64from xbmc import getSupportedMedia
65import xbmc
66
67
6866pickle_db = PickleDatabase()
6967
7068PROVIDER_COLOR_MIN_BRIGHTNESS = 128
……@@ -77,7 +75,24 @@ USER_AGENT_HEADER = {
7775
7876USER_AGENT_STRING = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
7977
80TMDB_POSTER_URL = "http://image.tmdb.org/t/p/w780"
78TMDB_IMAGE_SIZES = {
79 "low": {"poster": "w185", "thumb": "w185", "profile": "w185", "fanart": "w300"},
80 "medium": {"poster": "w342", "thumb": "w342", "profile": "w342", "fanart": "w780"},
81 "high": {"poster": "w780", "thumb": "w780", "profile": "w780", "fanart": "w1280"},
82 "original": {
83 "poster": "original",
84 "thumb": "original",
85 "profile": "original",
86 "fanart": "original",
87 },
88}
89
90
91def get_image_size(image_type):
92 tier_map = {"0": "low", "1": "medium", "2": "high", "3": "original"}
93 tier = tier_map.get(get_setting_fresh("image_resolution_tier", "2"), "high")
94 return TMDB_IMAGE_SIZES.get(tier, {}).get(image_type, "")
95
8196
8297MEDIA_FUSION_DEFAULT_KEY = "eJwBYACf_4hAkZJe85krAoD5hN50-2M0YuyGmgswr-cis3uap4FNnLMvSfOc4e1IcejWJmykujTnWAlQKRi9cct5k3IRqhu-wFBnDoe_QmwMjJI3FnQtFNp2u3jDo23THEEgKXHYqTMrLos="
8398
……@@ -189,6 +204,7 @@ class DebridType(Enum):
189204 RD = "RealDebrid"
190205 PM = "Premiumize"
191206 TB = "Torbox"
207 OC = "Offcloud"
192208 ED = "EasyDebrid"
193209 DB = "Debrider"
194210 AD = "AllDebrid"
……@@ -214,6 +230,7 @@ class Indexer(Enum):
214230 BURST = "Burst"
215231 ZILEAN = "Zilean"
216232 EASYNEWS = "Easynews"
233 EXTERNAL_SCRAPER = "External Scraper"
217234
218235
219236class SearchVariant(Enum):
……@@ -268,6 +285,7 @@ torrent_indexers = [
268285 Indexer.PEERFLIX,
269286 Indexer.ELHOSTED,
270287 Indexer.BURST,
288 Indexer.EXTERNAL_SCRAPER,
271289]
272290
273291
……@@ -291,10 +309,8 @@ class DialogListener:
291309 return self
292310
293311 def __exit__(self, exc_type, exc_val, exc_tb):
294 try:
312 with contextlib.suppress(BaseException):
295313 self._dialog.close()
296 except:
297 pass
298314
299315
300316def is_debrid_activated():
……@@ -302,6 +318,7 @@ def is_debrid_activated():
302318 get_setting_fresh("real_debrid_enabled")
303319 or get_setting_fresh("premiumize_enabled")
304320 or get_setting_fresh("torbox_enabled")
321 or get_setting_fresh("offcloud_enabled")
305322 or get_setting_fresh("debrider_enabled")
306323 or get_setting_fresh("alldebrid_enabled")
307324 )
……@@ -314,6 +331,8 @@ def check_debrid_enabled(debrid_type):
314331 return is_pm_enabled()
315332 elif debrid_type == DebridType.TB:
316333 return is_tb_enabled()
334 elif debrid_type == DebridType.OC:
335 return is_oc_enabled()
317336 elif debrid_type == DebridType.DB:
318337 return is_debrider_enabled()
319338 elif debrid_type == DebridType.AD:
……@@ -342,6 +361,10 @@ def is_tb_enabled():
342361 return get_setting_fresh("torbox_enabled")
343362
344363
364def is_oc_enabled():
365 return get_setting_fresh("offcloud_enabled")
366
367
345368def is_ed_enabled():
346369 return get_setting("easydebrid_enabled")
347370
……@@ -381,7 +404,9 @@ def make_listing(data):
381404 return list_item
382405
383406
384def set_media_infoTag(list_item, data, fanart_data={}, mode="video", detailed=False):
407def set_media_infoTag(list_item, data, fanart_data=None, mode="video", detailed=False):
408 if fanart_data is None:
409 fanart_data = {}
385410 mode = "movies" if mode in ("movie", "movies") else mode
386411 info_tag = list_item.getVideoInfoTag()
387412
……@@ -563,9 +588,7 @@ def _set_trakt_ui_state(list_item, info_tag, data, mode):
563588 if not tmdb_id or season is None or episode is None:
564589 return
565590
566 is_watched = trakt_watched_cache.get_watched_status(
567 "episode", tmdb_id, season, episode
568 )
591 is_watched = trakt_watched_cache.get_watched_status("episode", tmdb_id, season, episode)
569592 if is_watched:
570593 info_tag.setPlaycount(1)
571594 return
……@@ -579,8 +602,7 @@ def _set_show_info(info_tag, data, mode):
579602 seasons = list(data["seasons"])
580603 if isinstance(seasons, list):
581604 named_seasons = [
582 (season.get("season_number", 0), season.get("name", ""))
583 for season in seasons
605 (season.get("season_number", 0), season.get("name", "")) for season in seasons
584606 ]
585607 info_tag.addSeasons(named_seasons)
586608 else:
……@@ -706,17 +728,17 @@ def build_actor(member, is_cast=True):
706728
707729def set_listitem_artwork(list_item, data, fanart_data):
708730 thumb_sources = [
709 (data.get("poster_path"), "w780"),
710 (data.get("still_path"), "w780"),
731 (data.get("poster_path"), get_image_size("thumb")),
732 (data.get("still_path"), get_image_size("thumb")),
711733 ]
712734 poster_sources = [
713 (data.get("poster_path"), "w780"),
714 (data.get("still_path"), "w780"),
715 (data.get("profile_path"), "w780"),
735 (data.get("poster_path"), get_image_size("poster")),
736 (data.get("still_path"), get_image_size("poster")),
737 (data.get("profile_path"), get_image_size("profile")),
716738 ]
717739 fanart_sources = [
718 (data.get("backdrop_path"), "w1280"),
719 (data.get("still_path"), "w1280"),
740 (data.get("backdrop_path"), get_image_size("fanart")),
741 (data.get("still_path"), get_image_size("fanart")),
720742 ]
721743
722744 clear_logo = [(extract_tmdb_logo_url(data), "original")]
……@@ -760,6 +782,29 @@ def tmdb_url(path, size):
760782 return f"http://image.tmdb.org/t/p/{size}{path}" if path else ""
761783
762784
785def get_rpdb_poster(imdb_id, api_key):
786 cache_key = f"rpdb_poster|{imdb_id}"
787 cached = cache.get(cache_key)
788 if cached:
789 return cached
790
791 try:
792 response = requests.get(
793 f"https://api.ratingposterdb.com/{api_key}/imdb/{imdb_id}?lang=en",
794 timeout=10,
795 )
796 if response.status_code == 200:
797 data = response.json()
798 poster_url = data.get("poster") or data.get("poster_large")
799 if poster_url:
800 cache.set(cache_key, poster_url, timedelta(hours=24))
801 return poster_url
802 except requests.RequestException as e:
803 kodilog(f"RPDB request error: {e}")
804
805 return None
806
807
763808def build_media_metadata(ids, mode: str) -> dict:
764809 metadata = {
765810 "poster": "",
……@@ -795,9 +840,7 @@ def build_media_metadata(ids, mode: str) -> dict:
795840 if mode == "movies" and getattr(res, "movie_results", []):
796841 tmdb_id = str(res.movie_results[0]["id"])
797842 ids["tmdb_id"] = tmdb_id
798 elif mode in ("tv", "episode", "season", "anime") and getattr(
799 res, "tv_results", []
800 ):
843 elif mode in ("tv", "episode", "season", "anime") and getattr(res, "tv_results", []):
801844 tmdb_id = str(res.tv_results[0]["id"])
802845 ids["tmdb_id"] = tmdb_id
803846
……@@ -812,7 +855,15 @@ def build_media_metadata(ids, mode: str) -> dict:
812855
813856 details = get_tmdb_media_details(tmdb_id, mode)
814857 poster_path = getattr(details, "poster_path", "")
815 metadata["poster"] = f"{TMDB_POSTER_URL}{poster_path}" if poster_path else ""
858 metadata["poster"] = tmdb_url(poster_path, get_image_size("poster")) if poster_path else ""
859
860 if imdb_id and get_setting_fresh("rpdb_enabled"):
861 rpdb_api_key = get_setting_fresh("rpdb_api_key", "")
862 if rpdb_api_key:
863 rpdb_poster = get_rpdb_poster(imdb_id, rpdb_api_key)
864 if rpdb_poster:
865 metadata["poster"] = rpdb_poster
866
816867 metadata["overview"] = getattr(details, "overview", "")
817868 metadata["title"] = getattr(details, "title", getattr(details, "name", ""))
818869 metadata["original_title"] = getattr(
……@@ -919,9 +970,7 @@ def is_in_library(title):
919970
920971
921972def get_fanart_details(tvdb_id="", tmdb_id="", mode="tv"):
922 identifier = "{}|{}".format(
923 "fanart.tv", tvdb_id if tvdb_id and tvdb_id != "None" else tmdb_id
924 )
973 identifier = "{}|{}".format("fanart.tv", tvdb_id if tvdb_id and tvdb_id != "None" else tmdb_id)
925974 cached = cache.get(identifier)
926975 if cached:
927976 return cached
……@@ -959,13 +1008,17 @@ def cache_results(results, query, mode, media_type, episode, cache_scope=""):
9591008 set_cached(results, query, params=("index", cache_scope))
9601009
9611010
962def get_cached(path, params={}):
963 identifier = "{}|{}".format(path, params)
1011def get_cached(path, params=None):
1012 if params is None:
1013 params = {}
1014 identifier = f"{path}|{params}"
9641015 return cache.get(identifier)
9651016
9661017
967def set_cached(data, path, params={}):
968 identifier = "{}|{}".format(path, params)
1018def set_cached(data, path, params=None):
1019 if params is None:
1020 params = {}
1021 identifier = f"{path}|{params}"
9691022 cache.set(
9701023 identifier,
9711024 data,
……@@ -974,7 +1027,7 @@ def set_cached(data, path, params={}):
9741027
9751028
9761029def db_get(name, func, path, params):
977 identifier = "{}|{}".format(path, params)
1030 identifier = f"{path}|{params}"
9781031 data = cache.get(identifier)
9791032 if not data:
9801033 if name == "search_client":
……@@ -987,8 +1040,10 @@ def db_get(name, func, path, params):
9871040 return data
9881041
9891042
990def tvdb_get(path, params={}):
991 identifier = "{}|{}".format(path, params)
1043def tvdb_get(path, params=None):
1044 if params is None:
1045 params = {}
1046 identifier = f"{path}|{params}"
9921047 data = cache.get(identifier)
9931048 if data:
9941049 return data
……@@ -1043,9 +1098,7 @@ def get_random_color(provider_name, formatted=True):
10431098 spec = 10
10441099 for i in range(0, 3):
10451100 offset = spec * i
1046 rounded = round(
1047 int(hash[offset : offset + spec], 16) / int("F" * spec, 16) * 255
1048 )
1101 rounded = round(int(hash[offset : offset + spec], 16) / int("F" * spec, 16) * 255)
10491102 colors.append(int(max(rounded, PROVIDER_COLOR_MIN_BRIGHTNESS)))
10501103
10511104 while (sum(colors) / 3) < PROVIDER_COLOR_MIN_BRIGHTNESS:
……@@ -1135,12 +1188,14 @@ def clear_tmdb_cache():
11351188 "collection_%",
11361189 "get_imdb_id%",
11371190 "latest_%",
1191 "tmdb_ui_meta",
11381192 ]
11391193 cache.delete("movie_genres|None")
11401194 cache.delete("show_genres|None")
11411195
11421196 for prefix in prefixes:
11431197 cache.delete_like(f"{prefix}|%")
1198 TMDb().cache_clear()
11441199 notification(translation(30240))
11451200
11461201
……@@ -1241,7 +1296,7 @@ def unzip(zip_location, destination_location, destination_check):
12411296 status = True
12421297 else:
12431298 status = False
1244 except:
1299 except Exception:
12451300 status = False
12461301 return status
12471302
……@@ -1255,6 +1310,9 @@ def pre_process(
12551310 skip_episode_filter: bool = False,
12561311) -> List[TorrentStream]:
12571312 builder = PreProcessBuilder(results).remove_duplicates()
1313 builder.extract_codec_hdr()
1314 builder.filter_by_codec()
1315 builder.filter_by_hdr()
12581316 if mode == "tv" and not skip_episode_filter:
12591317 builder.filter_sources(episode_name, episode, season)
12601318 builder.filter_by_source()
……@@ -1284,13 +1342,7 @@ def filter_debrid_episode(
12841342 ep_minus_1 = str(episode_num - 1).zfill(2)
12851343
12861344 def get_filename(file):
1287 return (
1288 file.get("path")
1289 or file.get("filename")
1290 or file.get("name")
1291 or file.get("n")
1292 or ""
1293 )
1345 return file.get("path") or file.get("filename") or file.get("name") or file.get("n") or ""
12941346
12951347 # Normalize filenames for matching
12961348 def normalize_title(title):
……@@ -1356,13 +1408,7 @@ def clean_auto_play_undesired(results: List[TorrentStream]) -> List[TorrentStrea
13561408
13571409def is_torrent_url(uri):
13581410 res = requests.head(uri, timeout=20, headers=USER_AGENT_HEADER)
1359 if (
1360 res.status_code == 200
1361 and res.headers.get("Content-Type") == "application/octet-stream"
1362 ):
1363 return True
1364 else:
1365 return False
1411 return bool(res.status_code == 200 and res.headers.get("Content-Type") == "application/octet-stream")
13661412
13671413
13681414def supported_video_extensions():
……@@ -1372,9 +1418,7 @@ def supported_video_extensions():
13721418
13731419def add_next_button(func_name, page=1, **kwargs):
13741420 list_item = make_list_item(label="Next")
1375 list_item.setArt(
1376 {"icon": os.path.join(ADDON_PATH, "resources", "img", "nextpage.png")}
1377 )
1421 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "nextpage.png")})
13781422
13791423 page += 1
13801424 addDirectoryItem(
……@@ -1506,7 +1550,7 @@ def show_log_export_dialog(params):
15061550 kodi_log_path = os.path.join(translatePath("special://logpath"), log_file)
15071551 if os.path.exists(kodi_log_path):
15081552 try:
1509 with open(kodi_log_path, "r", encoding="utf-8", errors="ignore") as f:
1553 with open(kodi_log_path, encoding="utf-8", errors="ignore") as f:
15101554 lines = deque(f, maxlen=500)
15111555
15121556 content = "".join(reversed(lines))
……@@ -1525,7 +1569,7 @@ def show_log_export_dialog(params):
15251569 copy2clip(paste_url)
15261570 progressDialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH)
15271571 progressDialog.setup(
1528 translation(90569),
1572 translation(90569),
15291573 qr_code,
15301574 paste_url,
15311575 is_debrid=False,
……@@ -1537,7 +1581,7 @@ def show_log_export_dialog(params):
15371581 try:
15381582 count -= 1
15391583 progressDialog.update_progress(count)
1540 except:
1584 except Exception:
15411585 pass
15421586 sleep(1000 * count)
15431587 else:
……@@ -1656,8 +1700,8 @@ def parse_time(item):
16561700 if "," in normalized:
16571701 weekday_part, remainder = normalized.split(",", 1)
16581702 weekday_key = weekday_part.strip().lower()
1659 normalized = "{},{}".format(
1660 day_replacements.get(weekday_key, weekday_part.strip()), remainder
1703 normalized = (
1704 f"{day_replacements.get(weekday_key, weekday_part.strip())},{remainder}"
16611705 )
16621706
16631707 normalized = re.sub(
……@@ -1698,17 +1742,17 @@ def format_season_episode(season, episode):
16981742 episode_value = normalize(episode)
16991743
17001744 if isinstance(season_value, int) and isinstance(episode_value, int):
1701 return "S{:02d}E{:02d}".format(season_value, episode_value)
1745 return f"S{season_value:02d}E{episode_value:02d}"
17021746 if season_value is not None and episode_value is not None:
1703 return "S{}E{}".format(season_value, episode_value)
1747 return f"S{season_value}E{episode_value}"
17041748 if season_value is not None:
17051749 if isinstance(season_value, int):
1706 return "S{:02d}".format(season_value)
1707 return "S{}".format(season_value)
1750 return f"S{season_value:02d}"
1751 return f"S{season_value}"
17081752 if episode_value is not None:
17091753 if isinstance(episode_value, int):
1710 return "E{:02d}".format(episode_value)
1711 return "E{}".format(episode_value)
1754 return f"E{episode_value:02d}"
1755 return f"E{episode_value}"
17121756 return ""
17131757
17141758
……@@ -1734,6 +1778,11 @@ def normalize_tv_data(tv_data):
17341778
17351779 normalized = dict(tv_data)
17361780
1781 # URL-decode the name to handle & in episode titles (issue #198)
1782 name = normalized.get("name", "")
1783 if name and isinstance(name, str):
1784 normalized["name"] = unquote(name)
1785
17371786 for key in ("season", "episode"):
17381787 value = _coerce_int(normalized.get(key))
17391788 if value is None:
plugin.video.jacktook/lib/utils/kodi/kodi_formats.py +474 -38
……@@ -1,50 +1,486 @@
11video_extensions = (
2 '.001', '.3g2', '.3gp', '.asf', '.asx', '.avc', '.avi', '.avs', '.bdm', '.bdmv', '.bin', '.bivx', '.dat', '.divx',
3 '.dv', '.dvr-ms', '.evo', '.f4v', '.fli', '.flv', '.h264', '.img', '.iso', '.m2t', '.m2ts', '.m2v', '.m3u8', '.m4v',
4 '.mk3d', '.mkv', '.mov', '.mp4', '.mpeg', '.mpg', '.mpl', '.mpls', '.mts', '.nrg', '.nuv', '.ogm', '.ogv', '.pva',
5 '.qt', '.rcv', '.rec', '.rmvb', '.sdp', '.svq3', '.tp', '.trp', '.ts', '.ty', '.udf', '.vc1', '.vdr', '.viv',
6 '.vob', '.vp3', '.webm', '.wmv', '.xvid')
2 ".001",
3 ".3g2",
4 ".3gp",
5 ".asf",
6 ".asx",
7 ".avc",
8 ".avi",
9 ".avs",
10 ".bdm",
11 ".bdmv",
12 ".bin",
13 ".bivx",
14 ".dat",
15 ".divx",
16 ".dv",
17 ".dvr-ms",
18 ".evo",
19 ".f4v",
20 ".fli",
21 ".flv",
22 ".h264",
23 ".img",
24 ".iso",
25 ".m2t",
26 ".m2ts",
27 ".m2v",
28 ".m3u8",
29 ".m4v",
30 ".mk3d",
31 ".mkv",
32 ".mov",
33 ".mp4",
34 ".mpeg",
35 ".mpg",
36 ".mpl",
37 ".mpls",
38 ".mts",
39 ".nrg",
40 ".nuv",
41 ".ogm",
42 ".ogv",
43 ".pva",
44 ".qt",
45 ".rcv",
46 ".rec",
47 ".rmvb",
48 ".sdp",
49 ".svq3",
50 ".tp",
51 ".trp",
52 ".ts",
53 ".ty",
54 ".udf",
55 ".vc1",
56 ".vdr",
57 ".viv",
58 ".vob",
59 ".vp3",
60 ".webm",
61 ".wmv",
62 ".xvid",
63)
764
865music_extensions = (
9 '.aac', '.ac3', '.aif', '.aiff', '.ape', '.cmc', '.cmr', '.cue', '.dff', '.dmc', '.dsf', '.dsp', '.dts', '.dtshd',
10 '.flac', '.gcm', '.gdm', '.idsp', '.imf', '.m15', '.m4a', '.m4b', '.mac', '.mka', '.mp+', '.mp2', '.mp3', '.mpa',
11 '.mpc', '.mpdsp', '.mpp', '.mpt', '.mss', '.oga', '.ogg', '.opus', '.rma', '.rmt', '.rsd', '.sap', '.sfx', '.shn',
12 '.spt', '.tak', '.tm2', '.tm8', '.tmc', '.tta', '.uni', '.waa', '.wam', '.wav', '.wma', '.wv', '.wvs', '.xwav')
66 ".aac",
67 ".ac3",
68 ".aif",
69 ".aiff",
70 ".ape",
71 ".cmc",
72 ".cmr",
73 ".cue",
74 ".dff",
75 ".dmc",
76 ".dsf",
77 ".dsp",
78 ".dts",
79 ".dtshd",
80 ".flac",
81 ".gcm",
82 ".gdm",
83 ".idsp",
84 ".imf",
85 ".m15",
86 ".m4a",
87 ".m4b",
88 ".mac",
89 ".mka",
90 ".mp+",
91 ".mp2",
92 ".mp3",
93 ".mpa",
94 ".mpc",
95 ".mpdsp",
96 ".mpp",
97 ".mpt",
98 ".mss",
99 ".oga",
100 ".ogg",
101 ".opus",
102 ".rma",
103 ".rmt",
104 ".rsd",
105 ".sap",
106 ".sfx",
107 ".shn",
108 ".spt",
109 ".tak",
110 ".tm2",
111 ".tm8",
112 ".tmc",
113 ".tta",
114 ".uni",
115 ".waa",
116 ".wam",
117 ".wav",
118 ".wma",
119 ".wv",
120 ".wvs",
121 ".xwav",
122)
13123
14124picture_extensions = (
15 '.apng', '.bmp', '.cbz', '.gif', '.ico', '.jp2', '.jpeg', '.jpg', '.pcx', '.png', '.tga', '.tif', '.tiff', '.webp')
125 ".apng",
126 ".bmp",
127 ".cbz",
128 ".gif",
129 ".ico",
130 ".jp2",
131 ".jpeg",
132 ".jpg",
133 ".pcx",
134 ".png",
135 ".tga",
136 ".tif",
137 ".tiff",
138 ".webp",
139)
16140
17141subtitle_extensions = (
18 '.aqt', '.ass', '.idx', '.jss', '.rt', '.smi', '.srt', '.ssa', '.sub', '.text', '.txt', '.utf', '.utf-8', '.utf8')
142 ".aqt",
143 ".ass",
144 ".idx",
145 ".jss",
146 ".rt",
147 ".smi",
148 ".srt",
149 ".ssa",
150 ".sub",
151 ".text",
152 ".txt",
153 ".utf",
154 ".utf-8",
155 ".utf8",
156)
19157
20158text_extensions = (
21 '.ada', '.adb', '.ads', '.applescript', '.as', '.asc', '.ascii', '.ascx', '.asm', '.asmx', '.asp', '.aspx', '.atom',
22 '.au3', '.awk', '.bas', '.bash', '.bashrc', '.bat', '.bbcolors', '.bcp', '.bdsgroup', '.bdsproj', '.bib',
23 '.bowerrc', '.c', '.cbl', '.cc', '.cfc', '.cfg', '.cfm', '.cfml', '.cgi', '.clj', '.cljs', '.cls', '.cmake', '.cmd',
24 '.cnf', '.cob', '.code-snippets', '.coffee', '.coffeekup', '.conf', '.cp', '.cpp', '.cpt', '.cpy', '.crt', '.cs',
25 '.csh', '.cson', '.csproj', '.csr', '.css', '.csslintrc', '.csv', '.ctl', '.curlrc', '.cxx', '.d', '.dart', '.dfm',
26 '.diff', '.dof', '.dpk', '.dpr', '.dproj', '.dtd', '.eco', '.editorconfig', '.ejs', '.el', '.elm', '.emacs', '.eml',
27 '.ent', '.erb', '.erl', '.eslintignore', '.eslintrc', '.ex', '.exs', '.f', '.f03', '.f77', '.f90', '.f95', '.fish',
28 '.for', '.fpp', '.frm', '.fs', '.fsproj', '.fsx', '.ftn', '.gemrc', '.gemspec', '.gitattributes', '.gitconfig',
29 '.gitignore', '.gitkeep', '.gitmodules', '.go', '.gpp', '.gradle', '.groovy', '.groupproj', '.grunit', '.gtmpl',
30 '.gvimrc', '.h', '.haml', '.hbs', '.hgignore', '.hh', '.hpp', '.hrl', '.hs', '.hta', '.htaccess', '.htc', '.htm',
31 '.html', '.htpasswd', '.hxx', '.iced', '.iml', '.inc', '.inf', '.info', '.ini', '.ino', '.int', '.irbrc', '.itcl',
32 '.itermcolors', '.itk', '.jade', '.java', '.jhtm', '.jhtml', '.js', '.jscsrc', '.jshintignore', '.jshintrc',
33 '.json', '.json5', '.jsonld', '.jsp', '.jspx', '.jsx', '.ksh', '.less', '.lhs', '.lisp', '.log', '.ls', '.lsp',
34 '.lua', '.m', '.m4', '.mak', '.map', '.markdown', '.master', '.md', '.mdown', '.mdwn', '.mdx', '.metadata', '.mht',
35 '.mhtml', '.mjs', '.mk', '.mkd', '.mkdn', '.mkdown', '.ml', '.mli', '.mm', '.mxml', '.nfm', '.nfo', '.noon',
36 '.npmignore', '.npmrc', '.nuspec', '.nvmrc', '.ops', '.pas', '.pasm', '.patch', '.pbxproj', '.pch', '.pem', '.pg',
37 '.php', '.php3', '.php4', '.php5', '.phpt', '.phtml', '.pir', '.pl', '.pm', '.pmc', '.pod', '.pot', '.prettierrc',
38 '.properties', '.props', '.pt', '.pug', '.purs', '.py', '.pyx', '.r', '.rake', '.rb', '.rbw', '.rc', '.rdoc',
39 '.rdoc_options', '.resx', '.rexx', '.rhtml', '.rjs', '.rlib', '.ron', '.rs', '.rss', '.rst', '.rtf', '.rvmrc',
40 '.rxml', '.s', '.sass', '.scala', '.scm', '.scss', '.seestyle', '.sh', '.shtml', '.sln', '.sls', '.spec', '.sql',
41 '.sqlite', '.sqlproj', '.srt', '.ss', '.sss', '.st', '.strings', '.sty', '.styl', '.stylus', '.sub',
42 '.sublime-build', '.sublime-commands', '.sublime-completions', '.sublime-keymap', '.sublime-macro', '.sublime-menu',
43 '.sublime-project', '.sublime-settings', '.sublime-workspace', '.sv', '.svc', '.svg', '.swift', '.t', '.tcl',
44 '.tcsh', '.terminal', '.tex', '.text', '.textile', '.tg', '.tk', '.tmlanguage', '.tmpl', '.tmtheme', '.tpl', '.ts',
45 '.tsv', '.tsx', '.tt', '.tt2', '.ttml', '.twig', '.txt', '.v', '.vb', '.vbproj', '.vbs', '.vcproj', '.vcxproj',
46 '.vh', '.vhd', '.vhdl', '.vim', '.viminfo', '.vimrc', '.vm', '.vue', '.webapp', '.webmanifest', '.wsc', '.x-php',
47 '.xaml', '.xht', '.xhtml', '.xml', '.xs', '.xsd', '.xsl', '.xslt', '.y', '.yaml', '.yml', '.zsh', '.zshrc')
159 ".ada",
160 ".adb",
161 ".ads",
162 ".applescript",
163 ".as",
164 ".asc",
165 ".ascii",
166 ".ascx",
167 ".asm",
168 ".asmx",
169 ".asp",
170 ".aspx",
171 ".atom",
172 ".au3",
173 ".awk",
174 ".bas",
175 ".bash",
176 ".bashrc",
177 ".bat",
178 ".bbcolors",
179 ".bcp",
180 ".bdsgroup",
181 ".bdsproj",
182 ".bib",
183 ".bowerrc",
184 ".c",
185 ".cbl",
186 ".cc",
187 ".cfc",
188 ".cfg",
189 ".cfm",
190 ".cfml",
191 ".cgi",
192 ".clj",
193 ".cljs",
194 ".cls",
195 ".cmake",
196 ".cmd",
197 ".cnf",
198 ".cob",
199 ".code-snippets",
200 ".coffee",
201 ".coffeekup",
202 ".conf",
203 ".cp",
204 ".cpp",
205 ".cpt",
206 ".cpy",
207 ".crt",
208 ".cs",
209 ".csh",
210 ".cson",
211 ".csproj",
212 ".csr",
213 ".css",
214 ".csslintrc",
215 ".csv",
216 ".ctl",
217 ".curlrc",
218 ".cxx",
219 ".d",
220 ".dart",
221 ".dfm",
222 ".diff",
223 ".dof",
224 ".dpk",
225 ".dpr",
226 ".dproj",
227 ".dtd",
228 ".eco",
229 ".editorconfig",
230 ".ejs",
231 ".el",
232 ".elm",
233 ".emacs",
234 ".eml",
235 ".ent",
236 ".erb",
237 ".erl",
238 ".eslintignore",
239 ".eslintrc",
240 ".ex",
241 ".exs",
242 ".f",
243 ".f03",
244 ".f77",
245 ".f90",
246 ".f95",
247 ".fish",
248 ".for",
249 ".fpp",
250 ".frm",
251 ".fs",
252 ".fsproj",
253 ".fsx",
254 ".ftn",
255 ".gemrc",
256 ".gemspec",
257 ".gitattributes",
258 ".gitconfig",
259 ".gitignore",
260 ".gitkeep",
261 ".gitmodules",
262 ".go",
263 ".gpp",
264 ".gradle",
265 ".groovy",
266 ".groupproj",
267 ".grunit",
268 ".gtmpl",
269 ".gvimrc",
270 ".h",
271 ".haml",
272 ".hbs",
273 ".hgignore",
274 ".hh",
275 ".hpp",
276 ".hrl",
277 ".hs",
278 ".hta",
279 ".htaccess",
280 ".htc",
281 ".htm",
282 ".html",
283 ".htpasswd",
284 ".hxx",
285 ".iced",
286 ".iml",
287 ".inc",
288 ".inf",
289 ".info",
290 ".ini",
291 ".ino",
292 ".int",
293 ".irbrc",
294 ".itcl",
295 ".itermcolors",
296 ".itk",
297 ".jade",
298 ".java",
299 ".jhtm",
300 ".jhtml",
301 ".js",
302 ".jscsrc",
303 ".jshintignore",
304 ".jshintrc",
305 ".json",
306 ".json5",
307 ".jsonld",
308 ".jsp",
309 ".jspx",
310 ".jsx",
311 ".ksh",
312 ".less",
313 ".lhs",
314 ".lisp",
315 ".log",
316 ".ls",
317 ".lsp",
318 ".lua",
319 ".m",
320 ".m4",
321 ".mak",
322 ".map",
323 ".markdown",
324 ".master",
325 ".md",
326 ".mdown",
327 ".mdwn",
328 ".mdx",
329 ".metadata",
330 ".mht",
331 ".mhtml",
332 ".mjs",
333 ".mk",
334 ".mkd",
335 ".mkdn",
336 ".mkdown",
337 ".ml",
338 ".mli",
339 ".mm",
340 ".mxml",
341 ".nfm",
342 ".nfo",
343 ".noon",
344 ".npmignore",
345 ".npmrc",
346 ".nuspec",
347 ".nvmrc",
348 ".ops",
349 ".pas",
350 ".pasm",
351 ".patch",
352 ".pbxproj",
353 ".pch",
354 ".pem",
355 ".pg",
356 ".php",
357 ".php3",
358 ".php4",
359 ".php5",
360 ".phpt",
361 ".phtml",
362 ".pir",
363 ".pl",
364 ".pm",
365 ".pmc",
366 ".pod",
367 ".pot",
368 ".prettierrc",
369 ".properties",
370 ".props",
371 ".pt",
372 ".pug",
373 ".purs",
374 ".py",
375 ".pyx",
376 ".r",
377 ".rake",
378 ".rb",
379 ".rbw",
380 ".rc",
381 ".rdoc",
382 ".rdoc_options",
383 ".resx",
384 ".rexx",
385 ".rhtml",
386 ".rjs",
387 ".rlib",
388 ".ron",
389 ".rs",
390 ".rss",
391 ".rst",
392 ".rtf",
393 ".rvmrc",
394 ".rxml",
395 ".s",
396 ".sass",
397 ".scala",
398 ".scm",
399 ".scss",
400 ".seestyle",
401 ".sh",
402 ".shtml",
403 ".sln",
404 ".sls",
405 ".spec",
406 ".sql",
407 ".sqlite",
408 ".sqlproj",
409 ".srt",
410 ".ss",
411 ".sss",
412 ".st",
413 ".strings",
414 ".sty",
415 ".styl",
416 ".stylus",
417 ".sub",
418 ".sublime-build",
419 ".sublime-commands",
420 ".sublime-completions",
421 ".sublime-keymap",
422 ".sublime-macro",
423 ".sublime-menu",
424 ".sublime-project",
425 ".sublime-settings",
426 ".sublime-workspace",
427 ".sv",
428 ".svc",
429 ".svg",
430 ".swift",
431 ".t",
432 ".tcl",
433 ".tcsh",
434 ".terminal",
435 ".tex",
436 ".text",
437 ".textile",
438 ".tg",
439 ".tk",
440 ".tmlanguage",
441 ".tmpl",
442 ".tmtheme",
443 ".tpl",
444 ".ts",
445 ".tsv",
446 ".tsx",
447 ".tt",
448 ".tt2",
449 ".ttml",
450 ".twig",
451 ".txt",
452 ".v",
453 ".vb",
454 ".vbproj",
455 ".vbs",
456 ".vcproj",
457 ".vcxproj",
458 ".vh",
459 ".vhd",
460 ".vhdl",
461 ".vim",
462 ".viminfo",
463 ".vimrc",
464 ".vm",
465 ".vue",
466 ".webapp",
467 ".webmanifest",
468 ".wsc",
469 ".x-php",
470 ".xaml",
471 ".xht",
472 ".xhtml",
473 ".xml",
474 ".xs",
475 ".xsd",
476 ".xsl",
477 ".xslt",
478 ".y",
479 ".yaml",
480 ".yml",
481 ".zsh",
482 ".zshrc",
483)
48484
49485
50486def _contains_extension(s, extensions):
plugin.video.jacktook/lib/utils/kodi/last_files_actions.py +2 -1
……@@ -1,9 +1,10 @@
1import json
2
13from lib.utils.kodi.utils import (
24 action_url_run,
35 kodi_play_media,
46 translation,
57)
6import json
78
89
910def add_last_files_context_menu(data):
plugin.video.jacktook/lib/utils/kodi/logging.py +3 -4
……@@ -3,7 +3,6 @@ from urllib.parse import urlsplit
33
44import xbmc
55
6
76_ERROR_PREFIX_RE = re.compile(
87 r"^(?:\[[^\]]+\]\s*)?(?:error|failed|traceback|exception|crash)\b",
98 re.IGNORECASE,
……@@ -32,15 +31,15 @@ def summarize_locator_for_log(value):
3231 magnet_match = re.search(r"btih:([A-Fa-f0-9]{8,40})", text)
3332 if text.startswith("magnet:?"):
3433 if magnet_match:
35 return "magnet:{}".format(magnet_match.group(1).lower()[:12])
34 return f"magnet:{magnet_match.group(1).lower()[:12]}"
3635 return "magnet"
3736
3837 if re.fullmatch(r"[A-Fa-f0-9]{40}", text):
39 return "infohash:{}".format(text.lower()[:12])
38 return f"infohash:{text.lower()[:12]}"
4039
4140 if text.startswith(("http://", "https://")):
4241 parts = urlsplit(text)
4342 path = parts.path or "/"
44 return "{}://{}{}".format(parts.scheme, parts.netloc, path)
43 return f"{parts.scheme}://{parts.netloc}{path}"
4544
4645 return text[:80]
plugin.video.jacktook/lib/utils/kodi/settings.py +2 -2
……@@ -1,6 +1,6 @@
1from .utils import get_setting, get_property, ADDON_ID
21import xbmc
32
3from .utils import ADDON_ID, get_setting
44
55EMPTY_USER = "unknown_user"
66
……@@ -50,7 +50,7 @@ def trakt_secret():
5050
5151
5252def trakt_lists_sort_order(setting):
53 return int(get_setting("trakt_sort_%s" % setting, "0"))
53 return int(get_setting(f"trakt_sort_{setting}", "0"))
5454
5555
5656def get_update_action():
plugin.video.jacktook/lib/utils/kodi/settings_backup.py +12 -9
……@@ -1,11 +1,12 @@
11import json
22import os
3from datetime import datetime, timedelta, timezone
43import xml.etree.ElementTree as ET
4from datetime import datetime, timedelta, timezone
55
66import requests
77import xbmcgui
88
9from lib.api.stremio.addon_manager import build_addon_instance_key
910from lib.clients.stremio.constants import (
1011 STREMIO_ADDONS_CATALOGS_KEY,
1112 STREMIO_ADDONS_KEY,
……@@ -14,7 +15,6 @@ from lib.clients.stremio.constants import (
1415 decode_selected_ids,
1516 encode_selected_ids,
1617)
17from lib.api.stremio.addon_manager import build_addon_instance_key
1818from lib.db.cached import cache
1919from lib.db.pickle_db import PickleDatabase
2020from lib.utils.general.utils import (
……@@ -37,11 +37,10 @@ from lib.utils.kodi.utils import (
3737 notification,
3838 set_cached_setting_property,
3939 set_setting,
40 translation,
4140 translatePath,
41 translation,
4242)
4343
44
4544BACKUP_VERSION = 1
4645CUSTOM_ADDON_EXPIRY = timedelta(days=365 * 20)
4746SETTINGS_XML_PATH = os.path.join(ADDON_PATH, "resources", "settings.xml")
……@@ -60,6 +59,7 @@ SENSITIVE_SETTING_IDS = {
6059 "alldebrid_token",
6160 "debrider_token",
6261 "torbox_token",
62 "offcloud_token",
6363 "premiumize_token",
6464 "easynews_password",
6565 "stremio_pass",
……@@ -81,6 +81,7 @@ IDENTITY_SETTING_IDS = {
8181 "real_debrid_user",
8282 "alldebrid_user",
8383 "torbox_user",
84 "offcloud_user",
8485 "trakt_user",
8586}
8687
……@@ -92,9 +93,7 @@ AUTH_STATE_SETTING_IDS = {
9293 "is_trakt_auth",
9394}
9495
95SCRUBBED_SETTING_IDS = (
96 SENSITIVE_SETTING_IDS | IDENTITY_SETTING_IDS | AUTH_STATE_SETTING_IDS
97)
96SCRUBBED_SETTING_IDS = SENSITIVE_SETTING_IDS | IDENTITY_SETTING_IDS | AUTH_STATE_SETTING_IDS
9897
9998EXTRA_DYNAMIC_SETTINGS = {
10099 "trakt_token": "",
……@@ -158,7 +157,9 @@ def _get_custom_stremio_addons(user_addons=None):
158157
159158def _get_custom_selection_map(custom_addons):
160159 custom_keys = {
161 addon_key for addon_key in (_custom_addon_key(addon) for addon in custom_addons) if addon_key
160 addon_key
161 for addon_key in (_custom_addon_key(addon) for addon in custom_addons)
162 if addon_key
162163 }
163164 selections = {}
164165 for label, cache_key in CUSTOM_SELECTION_KEYS.items():
……@@ -245,7 +246,9 @@ def _normalize_custom_addons(custom_addons):
245246
246247def _filtered_custom_selection_map(cache_payload, custom_addons):
247248 custom_keys = {
248 addon_key for addon_key in (_custom_addon_key(addon) for addon in custom_addons) if addon_key
249 addon_key
250 for addon_key in (_custom_addon_key(addon) for addon in custom_addons)
251 if addon_key
249252 }
250253 raw_selections = cache_payload.get("custom_stremio_selections", {})
251254 filtered = {}
plugin.video.jacktook/lib/utils/kodi/utils.py +97 -56
……@@ -1,30 +1,35 @@
11#!/usr/bin/env python3
2from datetime import date, datetime, timedelta
2import contextlib
33import json
44import os
55import re
6import xml.etree.ElementTree as ET
76import sys
87import time
8import xml.etree.ElementTree as ET
9from datetime import date, datetime, timedelta
910from typing import Any, Union
10
1111from urllib.parse import quote, urlencode
12from lib.db.cached import cache
13from lib.utils.kodi.logging import kodilog
1412
1513import xbmc
1614import xbmcaddon
1715import xbmcgui
18
19from xbmcgui import Window, ListItem
20from xbmcplugin import addDirectoryItems, setResolvedUrl, endOfDirectory
16from xbmcgui import ListItem, Window
17from xbmcplugin import addDirectoryItems, endOfDirectory, setResolvedUrl
18from xbmcvfs import (
19 File as xbmcvfs_File,
20)
2121from xbmcvfs import (
22 translatePath as translate_path,
2322 delete as xbmc_delete,
23)
24from xbmcvfs import (
2425 listdir,
25 File as xbmcvfs_File,
26)
27from xbmcvfs import (
28 translatePath as translate_path,
2629)
2730
31from lib.db.cached import cache
32from lib.utils.kodi.logging import kodilog
2833
2934_URL = sys.argv[0]
3035
……@@ -54,10 +59,43 @@ JACKTORR_ADDON = _get_jacktorr_addon()
5459
5560
5661ADDON = xbmcaddon.Addon()
57try:
58 ADDON_HANDLE = int(sys.argv[1])
59except IndexError:
60 ADDON_HANDLE = 0
62
63
64class _AddonHandle:
65 """Dynamically resolves the Kodi plugin handle at call time.
66
67 With ``reuselanguageinvoker=true`` the Python interpreter is reused
68 across plugin invocations, so ``sys.argv[1]`` (set once at module
69 import) becomes stale. This class resolves ``int(sys.argv[1])``
70 *every time* ``__index__`` is called — Kodi's C extension calls
71 ``__index__`` when it receives this object where an ``int`` is
72 expected (e.g. ``addDirectoryItems``, ``endOfDirectory``).
73
74 Example
75 -------
76 >>> from lib.utils.kodi.utils import ADDON_HANDLE
77 >>> addDirectoryItems(ADDON_HANDLE, items) # resolves correctly
78 """
79
80 @staticmethod
81 def _resolve():
82 try:
83 return int(sys.argv[1])
84 except (IndexError, ValueError):
85 return 0
86
87 def __index__(self):
88 return self._resolve()
89
90 def __int__(self):
91 return self._resolve()
92
93 def __eq__(self, other):
94 return self._resolve() == int(other)
95
96
97ADDON_HANDLE = _AddonHandle()
98
6199
62100ADDON_PATH = ADDON.getAddonInfo("path")
63101ADDON_PROFILE_PATH = translate_path(ADDON.getAddonInfo("profile"))
……@@ -69,9 +107,7 @@ ADDON_VERSION = ADDON.getAddonInfo("version")
69107ADDON_NAME = ADDON.getAddonInfo("name")
70108PLAYLIST = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
71109
72CHANGELOG_PATH = translate_path(
73 "special://home/addons/plugin.video.jacktook/CHANGELOG.md"
74)
110CHANGELOG_PATH = translate_path("special://home/addons/plugin.video.jacktook/CHANGELOG.md")
75111
76112progressDialog = xbmcgui.DialogProgress()
77113
……@@ -120,9 +156,7 @@ def rebuild_settings_snapshot():
120156 if os.path.exists(profile_xml):
121157 root = ET.parse(profile_xml).getroot()
122158 settings_dict = {
123 item.get("id"): (item.text or "")
124 for item in root.iter("setting")
125 if item.get("id")
159 item.get("id"): (item.text or "") for item in root.iter("setting") if item.get("id")
126160 }
127161 else:
128162 settings_dict = {}
……@@ -227,12 +261,13 @@ def set_property(prop: str, value: Any):
227261
228262
229263def clear_property(prop):
264 cache.delete(prop)
230265 return Window(10000).clearProperty(prop)
231266
232267
233268def burst_addon_settings():
234269 close_all_dialog()
235 xbmc.executebuiltin("Addon.OpenSettings(%s)" % JACKTOOK_BURST_ADOON_ID)
270 xbmc.executebuiltin(f"Addon.OpenSettings({JACKTOOK_BURST_ADOON_ID})")
236271
237272
238273def get_kodi_version():
……@@ -253,7 +288,7 @@ def is_jacktorr_addon_enabled():
253288 try:
254289 if not xbmc.getCondVisibility(f"System.HasAddon({JACKTORR_ADDON_ID})"):
255290 return False
256 addon = xbmcaddon.Addon(JACKTORR_ADDON_ID)
291 xbmcaddon.Addon(JACKTORR_ADDON_ID)
257292 # If the addon is disabled, this will raise RuntimeError
258293 return True
259294 except RuntimeError:
……@@ -265,7 +300,7 @@ def is_youtube_addon_enabled():
265300 try:
266301 if not xbmc.getCondVisibility(f"System.HasAddon({YOUTUBE_ADDON_ID})"):
267302 return False
268 addon = xbmcaddon.Addon(YOUTUBE_ADDON_ID)
303 xbmcaddon.Addon(YOUTUBE_ADDON_ID)
269304 # If the addon is disabled, this will raise RuntimeError
270305 return True
271306 except RuntimeError:
……@@ -284,6 +319,7 @@ def is_burst_addon():
284319def enable_addon(addon_id: str):
285320 """
286321 Enable a Kodi addon using JSON-RPC.
322
287323 Returns True if successful, False otherwise.
288324 """
289325 request = {
……@@ -311,7 +347,7 @@ def logger(message, level=xbmc.LOGINFO):
311347
312348
313349def get_url(**kwargs):
314 return "{}?{}".format(_URL, urlencode(kwargs))
350 return f"{_URL}?{urlencode(kwargs)}"
315351
316352
317353def set_art(list_item, artwork_url):
……@@ -340,7 +376,7 @@ def compat(line1, line2, line3):
340376
341377
342378def kodi_refresh():
343 execute_builtin('UpdateLibrary(video,special://skin/foo)')
379 execute_builtin("UpdateLibrary(video,special://skin/foo)")
344380
345381
346382def refresh():
……@@ -375,10 +411,7 @@ def dialogyesno(header, text):
375411 header,
376412 text,
377413 )
378 if confirmed:
379 return True
380 else:
381 return False
414 return bool(confirmed)
382415
383416
384417def dialog_select(heading, _list):
……@@ -397,7 +430,7 @@ def container_update(name, **kwargs):
397430 :param path: The path where to update.
398431 :type path: str
399432 """
400 return "Container.Update({})".format(build_url(name, **kwargs))
433 return f"Container.Update({build_url(name, **kwargs)})"
401434
402435
403436def container_refresh():
……@@ -405,7 +438,7 @@ def container_refresh():
405438
406439
407440def action_url_run(name, **kwargs):
408 return "RunPlugin({})".format(build_url(name, **kwargs))
441 return f"RunPlugin({build_url(name, **kwargs)})"
409442
410443
411444def build_url(action, **params):
……@@ -433,7 +466,7 @@ def buffer_and_play(info_hash, file_id, path):
433466
434467
435468def kodi_play_media(name, *args, **kwargs):
436 return "PlayMedia({})".format(build_url(name, *args, **kwargs))
469 return f"PlayMedia({build_url(name, *args, **kwargs)})"
437470
438471
439472def show_busy_dialog():
……@@ -441,7 +474,7 @@ def show_busy_dialog():
441474
442475
443476def show_picture(url):
444 xbmc.executebuiltin('ShowPicture("{}")'.format(url))
477 xbmc.executebuiltin(f'ShowPicture("{url}")')
445478
446479
447480def close_busy_dialog():
……@@ -488,24 +521,22 @@ def disable_enable_addon(addon_id=ADDON_ID):
488521 }
489522 )
490523 )
491 except:
524 except Exception:
492525 pass
493526
494527
495528def update_kodi_addons_db(addon_id=ADDON_ID):
496529 try:
497530 import sqlite3 as database
498
531
499532 date = time.strftime("%Y-%m-%d %H:%M:%S")
500 dbcon = database.connect(
501 translate_path("special://database/Addons33.db"), timeout=40.0
502 )
533 dbcon = database.connect(translate_path("special://database/Addons33.db"), timeout=40.0)
503534 dbcon.execute(
504535 "INSERT OR REPLACE INTO installed (addonID, enabled, lastUpdated) VALUES (?, ?, ?)",
505536 (addon_id, 1, date),
506537 )
507538 dbcon.close()
508 except:
539 except Exception:
509540 pass
510541
511542
……@@ -520,17 +551,28 @@ def bytes_to_human_readable(size: int, unit: str = "B") -> str:
520551
521552
522553def convert_size_to_bytes(size_str: str) -> int:
523 """Convert size string to bytes."""
524 match = re.match(r"(\d+(?:\.\d+)?)\s*(GB|MB|KB|B)", size_str, re.IGNORECASE)
554 """Convert size string to bytes.
555
556 Supports:
557 - Standard units: GB, MB, KB, B
558 - European units: Go, Mo, Ko (Gigaoctet, Megaoctet, Kilooctet)
559 - European decimal comma: 1,5 GB → treated as 1.5 GB
560 """
561 # Normalize European decimal comma (only between digits, not a thousands separator)
562 size_str = re.sub(r"(\d),(\d)", r"\1.\2", size_str)
563 match = re.match(
564 r"(\d+(?:\.\d+)?)\s*(GB|MB|KB|B|Go|Mo|Ko)", size_str, re.IGNORECASE
565 )
566
525567 if match:
526568 size, unit = match.groups()
527569 size = float(size)
528570 unit = unit.upper()
529 if unit == "GB":
571 if unit in ("GB", "GO"):
530572 return int(size * 1024**3)
531 if unit == "MB":
573 if unit in ("MB", "MO"):
532574 return int(size * 1024**2)
533 if unit == "KB":
575 if unit in ("KB", "KO"):
534576 return int(size * 1024)
535577 return int(size)
536578 return 0
……@@ -564,11 +606,11 @@ SECTION_VIEW_KEYS = (
564606
565607
566608def get_view_setting_key(view_key):
567 return "saved_%s" % view_key.replace(".", "_")
609 return "saved_{}".format(view_key.replace(".", "_"))
568610
569611
570612def get_view_property_key(view_key):
571 return "jacktook.%s" % view_key
613 return f"jacktook.{view_key}"
572614
573615
574616def _get_named_view_id(name, default="current"):
……@@ -696,9 +738,9 @@ def copy2clip(txt):
696738 platform = sys.platform
697739 if platform == "win32":
698740 try:
699 cmd = "echo %s|clip" % txt.strip()
741 cmd = f"echo {txt.strip()}|clip"
700742 return subprocess.check_call(cmd, shell=True)
701 except:
743 except Exception:
702744 pass
703745 elif platform == "linux2":
704746 try:
……@@ -706,7 +748,7 @@ def copy2clip(txt):
706748
707749 p = Popen(["xsel", "-pi"], stdin=PIPE)
708750 p.communicate(input=txt)
709 except:
751 except Exception:
710752 pass
711753
712754
……@@ -718,6 +760,7 @@ def get_datetime(string: bool = False, dt: bool = False) -> Union[date, datetime
718760 :return: By default, returns a date object.
719761 """
720762 now = datetime.now()
763
721764 if dt:
722765 return now
723766 else:
……@@ -740,11 +783,11 @@ def cancel_playback():
740783 PLAYLIST.clear()
741784 close_busy_dialog()
742785 close_all_dialog()
743 try:
786 with contextlib.suppress(Exception):
744787 xbmc.Player().stop()
745 except Exception:
746 pass
747 setResolvedUrl(ADDON_HANDLE, False, ListItem(offscreen=True))
788 # Gracefully handle the "no handle" case (background/monitor contexts)
789 with contextlib.suppress(RuntimeError, SystemExit):
790 setResolvedUrl(ADDON_HANDLE, False, ListItem(offscreen=True))
748791
749792
750793def finish_action():
……@@ -770,6 +813,4 @@ def is_widget():
770813
771814
772815def end_of_directory(cache=True):
773 endOfDirectory(
774 ADDON_HANDLE, cacheToDisc=False if is_widget() or not cache else True
775 )
816 endOfDirectory(ADDON_HANDLE, cacheToDisc=not (is_widget() or not cache))
plugin.video.jacktook/lib/utils/localization/countries.py +1767 -1250
……@@ -1,1238 +1,1739 @@
1# -*- coding: utf-8 -*-
21# ref: https://gist.github.com/tientran0019/c68b7095976386cf8b1812327228450e
32
4_countries = [{'alpha2': 'AD',
5 'flag': '🇦🇩',
6 'language': {'code': 'ca', 'name': 'Catalan'},
7 'name': 'Andorra',
8 'unicode': 'U+1F1E6 U+1F1E9'},
9 {'alpha2': 'AE',
10 'flag': '🇦🇪',
11 'language': {'code': 'ar', 'name': 'Arabic'},
12 'name': 'United Arab Emirates',
13 'unicode': 'U+1F1E6 U+1F1EA'},
14 {'alpha2': 'AF',
15 'flag': '🇦🇫',
16 'language': {'code': 'ps', 'name': 'Pashto'},
17 'name': 'Afghanistan',
18 'unicode': 'U+1F1E6 U+1F1EB'},
19 {'alpha2': 'AG',
20 'flag': '🇦🇬',
21 'language': {'code': 'en', 'name': 'English'},
22 'name': 'Antigua and Barbuda',
23 'unicode': 'U+1F1E6 U+1F1EC'},
24 {'alpha2': 'AI',
25 'flag': '🇦🇮',
26 'language': {'code': 'en', 'name': 'English'},
27 'name': 'Anguilla',
28 'unicode': 'U+1F1E6 U+1F1EE'},
29 {'alpha2': 'AL',
30 'flag': '🇦🇱',
31 'language': {'code': 'sq', 'name': 'Albanian'},
32 'name': 'Albania',
33 'unicode': 'U+1F1E6 U+1F1F1'},
34 {'alpha2': 'AM',
35 'flag': '🇦🇲',
36 'language': {'code': 'hy', 'name': 'Armenian'},
37 'name': 'Armenia',
38 'unicode': 'U+1F1E6 U+1F1F2'},
39 {'alpha2': 'AO',
40 'flag': '🇦🇴',
41 'language': {'code': 'pt', 'name': 'Portuguese'},
42 'name': 'Angola',
43 'unicode': 'U+1F1E6 U+1F1F4'},
44 {'alpha2': 'AR',
45 'flag': '🇦🇷',
46 'language': {'code': 'es', 'name': 'Spanish'},
47 'name': 'Argentina',
48 'unicode': 'U+1F1E6 U+1F1F7'},
49 {'alpha2': 'AS',
50 'flag': '🇦🇸',
51 'language': {'code': 'en', 'name': 'English'},
52 'name': 'American Samoa',
53 'unicode': 'U+1F1E6 U+1F1F8'},
54 {'alpha2': 'AUT',
55 'flag': '🇦🇹',
56 'language': {'code': 'de', 'name': 'German'},
57 'name': 'Austria',
58 'unicode': 'U+1F1E6 U+1F1F9'},
59 {'alpha2': 'AU',
60 'flag': '🇦🇺',
61 'language': {'code': 'en', 'name': 'English'},
62 'name': 'Australia',
63 'unicode': 'U+1F1E6 U+1F1FA'},
64 {'alpha2': 'AW',
65 'flag': '🇦🇼',
66 'language': {'code': 'nl', 'name': 'Dutch'},
67 'name': 'Aruba',
68 'unicode': 'U+1F1E6 U+1F1FC'},
69 {'alpha2': 'AX',
70 'flag': '🇦🇽',
71 'language': {'code': 'fi',
72 'iso639_2': 'fin',
73 'name': 'Finnish',
74 'nativeName': 'suomi'},
75 'name': 'Åland Islands',
76 'unicode': 'U+1F1E6 U+1F1FD'},
77 {'alpha2': 'AZ',
78 'flag': '🇦🇿',
79 'language': {'code': 'az', 'name': 'Azerbaijani'},
80 'name': 'Azerbaijan',
81 'unicode': 'U+1F1E6 U+1F1FF'},
82 {'alpha2': 'BA',
83 'flag': '🇧🇦',
84 'language': {'code': 'bs', 'name': 'Bosnian'},
85 'name': 'Bosnia and Herzegovina',
86 'unicode': 'U+1F1E7 U+1F1E6'},
87 {'alpha2': 'BB',
88 'flag': '🇧🇧',
89 'language': {'code': 'en', 'name': 'English'},
90 'name': 'Barbados',
91 'unicode': 'U+1F1E7 U+1F1E7'},
92 {'alpha2': 'BD',
93 'flag': '🇧🇩',
94 'language': {'code': 'bn', 'name': 'Bengali'},
95 'name': 'Bangladesh',
96 'unicode': 'U+1F1E7 U+1F1E9'},
97 {'alpha2': 'BE',
98 'flag': '🇧🇪',
99 'language': {'code': 'nl', 'name': 'Dutch'},
100 'name': 'Belgium',
101 'unicode': 'U+1F1E7 U+1F1EA'},
102 {'alpha2': 'BF',
103 'flag': '🇧🇫',
104 'language': {'code': 'fr', 'name': 'French'},
105 'name': 'Burkina Faso',
106 'unicode': 'U+1F1E7 U+1F1EB'},
107 {'alpha2': 'BG',
108 'flag': '🇧🇬',
109 'language': {'code': 'bg', 'name': 'Bulgarian'},
110 'name': 'Bulgaria',
111 'unicode': 'U+1F1E7 U+1F1EC'},
112 {'alpha2': 'BH',
113 'flag': '🇧🇭',
114 'language': {'code': 'ar', 'name': 'Arabic'},
115 'name': 'Bahrain',
116 'unicode': 'U+1F1E7 U+1F1ED'},
117 {'alpha2': 'BI',
118 'flag': '🇧🇮',
119 'language': {'code': 'fr', 'name': 'French'},
120 'name': 'Burundi',
121 'unicode': 'U+1F1E7 U+1F1EE'},
122 {'alpha2': 'BJ',
123 'flag': '🇧🇯',
124 'language': {'code': 'fr', 'name': 'French'},
125 'name': 'Benin',
126 'unicode': 'U+1F1E7 U+1F1EF'},
127 {'alpha2': 'BL',
128 'flag': '🇧🇱',
129 'language': {'code': 'fr', 'name': 'French'},
130 'name': 'Saint Barthélemy',
131 'unicode': 'U+1F1E7 U+1F1F1'},
132 {'alpha2': 'BM',
133 'flag': '🇧🇲',
134 'language': {'code': 'en', 'name': 'English'},
135 'name': 'Bermuda',
136 'unicode': 'U+1F1E7 U+1F1F2'},
137 {'alpha2': 'BN',
138 'flag': '🇧🇳',
139 'language': {'code': 'ms', 'name': 'Malay'},
140 'name': 'Brunei Darussalam',
141 'unicode': 'U+1F1E7 U+1F1F3'},
142 {'alpha2': 'BO',
143 'flag': '🇧🇴',
144 'language': {'code': 'es', 'name': 'Spanish'},
145 'name': 'Bolivia (Plurinational State of)',
146 'unicode': 'U+1F1E7 U+1F1F4'},
147 {'alpha2': 'BR',
148 'flag': '🇧🇷',
149 'language': {'code': 'pt', 'name': 'Portuguese'},
150 'name': 'Brazil',
151 'unicode': 'U+1F1E7 U+1F1F7'},
152 {'alpha2': 'BS',
153 'flag': '🇧🇸',
154 'language': {'code': 'en', 'name': 'English'},
155 'name': 'Bahamas',
156 'unicode': 'U+1F1E7 U+1F1F8'},
157 {'alpha2': 'BT',
158 'flag': '🇧🇹',
159 'language': {'code': 'dz', 'name': 'Dzongkha'},
160 'name': 'Bhutan',
161 'unicode': 'U+1F1E7 U+1F1F9'},
162 {'alpha2': 'BV',
163 'flag': '🇧🇻',
164 'language': {'code': 'no', 'name': 'Norwegian'},
165 'name': 'Bouvet Island',
166 'unicode': 'U+1F1E7 U+1F1FC'},
167 {'alpha2': 'BW',
168 'flag': '🇧🇼',
169 'language': {'code': 'en', 'name': 'English'},
170 'name': 'Botswana',
171 'unicode': 'U+1F1E7 U+1F1FC'},
172 {'alpha2': 'BY',
173 'flag': '🇧🇾',
174 'language': {'code': 'be', 'name': 'Belarusian'},
175 'name': 'Belarus',
176 'unicode': 'U+1F1E7 U+1F1FE'},
177 {'alpha2': 'BZ',
178 'flag': '🇧🇿',
179 'language': {'code': 'en', 'name': 'English'},
180 'name': 'Belize',
181 'unicode': 'U+1F1E7 U+1F1FF'},
182 {'alpha2': 'CA',
183 'flag': '🇨🇦',
184 'language': {'code': 'en', 'name': 'English'},
185 'name': 'Canada',
186 'unicode': 'U+1F1E8 U+1F1E6'},
187 {'alpha2': 'CC',
188 'flag': '🇨🇨',
189 'language': {'code': 'en', 'name': 'English'},
190 'name': 'Cocos (Keeling) Islands',
191 'unicode': 'U+1F1E8 U+1F1E8'},
192 {'alpha2': 'CD',
193 'flag': '🇨🇩',
194 'language': {'code': 'fr', 'name': 'French'},
195 'name': 'Congo (Democratic Republic of the)',
196 'unicode': 'U+1F1E8 U+1F1E9'},
197 {'alpha2': 'CF',
198 'flag': '🇨🇫',
199 'language': {'code': 'fr', 'name': 'French'},
200 'name': 'Central African Republic',
201 'unicode': 'U+1F1E8 U+1F1EB'},
202 {'alpha2': 'CG',
203 'flag': '🇨🇬',
204 'language': {'code': 'fr', 'name': 'French'},
205 'name': 'Congo',
206 'unicode': 'U+1F1E8 U+1F1EC'},
207 {'alpha2': 'CH',
208 'flag': '🇨🇭',
209 'language': {'code': 'de', 'name': 'German'},
210 'name': 'Switzerland',
211 'unicode': 'U+1F1E8 U+1F1ED'},
212 {'alpha2': 'CI',
213 'flag': '🇨🇮',
214 'language': {'code': 'fr', 'name': 'French'},
215 'name': "Côte d'Ivoire",
216 'unicode': 'U+1F1E8 U+1F1EE'},
217 {'alpha2': 'CK',
218 'flag': '🇨🇰',
219 'language': {'code': 'en', 'name': 'English'},
220 'name': 'Cook Islands',
221 'unicode': 'U+1F1E8 U+1F1F0'},
222 {'alpha2': 'CL',
223 'flag': '🇨🇱',
224 'language': {'code': 'es',
225 'iso639_2': 'spa',
226 'name': 'Spanish',
227 'nativeName': 'Español'},
228 'name': 'Chile',
229 'unicode': 'U+1F1E8 U+1F1F1'},
230 {'alpha2': 'CM',
231 'flag': '🇨🇲',
232 'language': {'code': 'en', 'name': 'English'},
233 'name': 'Cameroon',
234 'unicode': 'U+1F1E8 U+1F1F2'},
235 {'alpha2': 'CN',
236 'flag': '🇨🇳',
237 'language': {'code': 'zh', 'name': 'Chinese'},
238 'name': 'China',
239 'unicode': 'U+1F1E8 U+1F1F3'},
240 {'alpha2': 'CO',
241 'flag': '🇨🇴',
242 'language': {'code': 'es', 'name': 'Spanish'},
243 'name': 'Colombia',
244 'unicode': 'U+1F1E8 U+1F1F4'},
245 {'alpha2': 'CR',
246 'flag': '🇨🇷',
247 'language': {'code': 'es', 'name': 'Spanish'},
248 'name': 'Costa Rica',
249 'unicode': 'U+1F1E8 U+1F1F7'},
250 {'alpha2': 'CU',
251 'flag': '🇨🇺',
252 'language': {'code': 'es', 'name': 'Spanish'},
253 'name': 'Cuba',
254 'unicode': 'U+1F1E8 U+1F1FA'},
255 {'alpha2': 'CV',
256 'flag': '🇨🇻',
257 'language': {'code': 'pt',
258 'iso639_2': 'por',
259 'name': 'Portuguese',
260 'nativeName': 'Português'},
261 'name': 'Cabo Verde',
262 'unicode': 'U+1F1E8 U+1F1FB'},
263 {'alpha2': 'CW',
264 'flag': '🇨🇼',
265 'language': {'code': 'nl', 'name': 'Dutch'},
266 'name': 'Curaçao',
267 'unicode': 'U+1F1E8 U+1F1FC'},
268 {'alpha2': 'CX',
269 'flag': '🇨🇽',
270 'language': {'code': 'en', 'name': 'English'},
271 'name': 'Christmas Island',
272 'unicode': 'U+1F1E8 U+1F1FD'},
273 {'alpha2': 'CY',
274 'flag': '🇨🇾',
275 'language': {'code': 'tr', 'name': 'Turkish'},
276 'name': 'Cyprus',
277 'unicode': 'U+1F1E8 U+1F1FE'},
278 {'alpha2': 'CZ',
279 'flag': '🇨🇿',
280 'language': {'code': 'cs', 'name': 'Czech'},
281 'name': 'Czech Republic',
282 'unicode': 'U+1F1E8 U+1F1FF'},
283 {'alpha2': 'DE',
284 'flag': '🇩🇪',
285 'language': {'code': 'de', 'name': 'German'},
286 'name': 'Germany',
287 'unicode': 'U+1F1E9 U+1F1EA'},
288 {'alpha2': 'DJ',
289 'flag': '🇩🇯',
290 'language': {'code': 'fr', 'name': 'French'},
291 'name': 'Djibouti',
292 'unicode': 'U+1F1E9 U+1F1EF'},
293 {'alpha2': 'DK',
294 'flag': '🇩🇰',
295 'language': {'code': 'da', 'name': 'Danish'},
296 'name': 'Denmark',
297 'unicode': 'U+1F1E9 U+1F1F0'},
298 {'alpha2': 'DM',
299 'flag': '🇩🇲',
300 'language': {'code': 'en', 'name': 'English'},
301 'name': 'Dominica',
302 'unicode': 'U+1F1E9 U+1F1F2'},
303 {'alpha2': 'DO',
304 'flag': '🇩🇴',
305 'language': {'code': 'es', 'name': 'Spanish'},
306 'name': 'Dominican Republic',
307 'unicode': 'U+1F1E9 U+1F1F4'},
308 {'alpha2': 'DZ',
309 'flag': '🇩🇿',
310 'language': {'code': 'ar', 'name': 'Arabic'},
311 'name': 'Algeria',
312 'unicode': 'U+1F1E9 U+1F1FF'},
313 {'alpha2': 'EC',
314 'flag': '🇪🇨',
315 'language': {'code': 'es', 'name': 'Spanish'},
316 'name': 'Ecuador',
317 'unicode': 'U+1F1EA U+1F1E8'},
318 {'alpha2': 'EE',
319 'flag': '🇪🇪',
320 'language': {'code': 'et', 'name': 'Estonian'},
321 'name': 'Estonia',
322 'unicode': 'U+1F1EA U+1F1EA'},
323 {'alpha2': 'EG',
324 'flag': '🇪🇬',
325 'language': {'code': 'ar', 'name': 'Arabic'},
326 'name': 'Egypt',
327 'unicode': 'U+1F1EA U+1F1EC'},
328 {'alpha2': 'ER',
329 'flag': '🇪🇷',
330 'language': {'code': 'en', 'name': 'English'},
331 'name': 'Eritrea',
332 'unicode': 'U+1F1EA U+1F1F7'},
333 {'alpha2': 'ES',
334 'flag': '🇪🇸',
335 'language': {'code': 'es', 'name': 'Spanish'},
336 'name': 'Spain',
337 'unicode': 'U+1F1EA U+1F1F8'},
338 {'alpha2': 'ET',
339 'flag': '🇪🇹',
340 'language': {'code': 'am', 'name': 'Amharic'},
341 'name': 'Ethiopia',
342 'unicode': 'U+1F1EA U+1F1F9'},
343 {'alpha2': 'FI',
344 'flag': '🇫🇮',
345 'language': {'code': 'fi',
346 'iso639_2': 'fin',
347 'name': 'Finnish',
348 'nativeName': 'suomi'},
349 'name': 'Finland',
350 'unicode': 'U+1F1EB U+1F1EE'},
351 {'alpha2': 'FJ',
352 'flag': '🇫🇯',
353 'language': {'code': 'en', 'name': 'English'},
354 'name': 'Fiji',
355 'unicode': 'U+1F1EB U+1F1EF'},
356 {'alpha2': 'FK',
357 'flag': '🇫🇰',
358 'language': {'code': 'en', 'name': 'English'},
359 'name': 'Falkland Islands (Malvinas)',
360 'unicode': 'U+1F1EB U+1F1F0'},
361 {'alpha2': 'FM',
362 'flag': '🇫🇲',
363 'language': {'code': 'en', 'name': 'English'},
364 'name': 'Micronesia (Federated States of)',
365 'unicode': 'U+1F1EB U+1F1F2'},
366 {'alpha2': 'FO',
367 'flag': '🇫🇴',
368 'language': {'code': 'fo', 'name': 'Faroese'},
369 'name': 'Faroe Islands',
370 'unicode': 'U+1F1EB U+1F1F4'},
371 {'alpha2': 'FR',
372 'flag': '🇫🇷',
373 'language': {'code': 'fr', 'name': 'French'},
374 'name': 'France',
375 'unicode': 'U+1F1EB U+1F1F7'},
376 {'alpha2': 'GA',
377 'flag': '🇬🇦',
378 'language': {'code': 'fr', 'name': 'French'},
379 'name': 'Gabon',
380 'unicode': 'U+1F1EC U+1F1E6'},
381 {'alpha2': 'GB',
382 'flag': '🇬🇧',
383 'language': {'code': 'en', 'name': 'English'},
384 'name': 'United Kingdom of Great Britain and Northern Ireland',
385 'unicode': 'U+1F1EC U+1F1E7'},
386 {'alpha2': 'GD',
387 'flag': '🇬🇩',
388 'language': {'code': 'en', 'name': 'English'},
389 'name': 'Grenada',
390 'unicode': 'U+1F1EC U+1F1E9'},
391 {'alpha2': 'GE',
392 'flag': '🇬🇪',
393 'language': {'code': 'ka', 'name': 'Georgian'},
394 'name': 'Georgia',
395 'unicode': 'U+1F1EC U+1F1EA'},
396 {'alpha2': 'GF',
397 'flag': '🇬🇫',
398 'language': {'code': 'fr', 'name': 'French'},
399 'name': 'French Guiana',
400 'unicode': 'U+1F1EC U+1F1EB'},
401 {'alpha2': 'GG',
402 'flag': '🇬🇬',
403 'language': {'code': 'en', 'name': 'English'},
404 'name': 'Guernsey',
405 'unicode': 'U+1F1EC U+1F1EC'},
406 {'alpha2': 'GH',
407 'flag': '🇬🇭',
408 'language': {'code': 'en', 'name': 'English'},
409 'name': 'Ghana',
410 'unicode': 'U+1F1EC U+1F1ED'},
411 {'alpha2': 'GI',
412 'flag': '🇬🇮',
413 'language': {'code': 'en', 'name': 'English'},
414 'name': 'Gibraltar',
415 'unicode': 'U+1F1EC U+1F1EE'},
416 {'alpha2': 'GL',
417 'flag': '🇬🇱',
418 'language': {'code': 'kl', 'name': 'Kalaallisut'},
419 'name': 'Greenland',
420 'unicode': 'U+1F1EC U+1F1F1'},
421 {'alpha2': 'GM',
422 'flag': '🇬🇲',
423 'language': {'code': 'en', 'name': 'English'},
424 'name': 'Gambia',
425 'unicode': 'U+1F1EC U+1F1F2'},
426 {'alpha2': 'GN',
427 'flag': '🇬🇳',
428 'language': {'code': 'fr', 'name': 'French'},
429 'name': 'Guinea',
430 'unicode': 'U+1F1EC U+1F1F3'},
431 {'alpha2': 'GP',
432 'flag': '🇬🇵',
433 'language': {'code': 'fr', 'name': 'French'},
434 'name': 'Guadeloupe',
435 'unicode': 'U+1F1EC U+1F1F5'},
436 {'alpha2': 'GQ',
437 'flag': '🇬🇶',
438 'language': {'code': 'es',
439 'iso639_2': 'spa',
440 'name': 'Spanish',
441 'nativeName': 'Español'},
442 'name': 'Equatorial Guinea',
443 'unicode': 'U+1F1EC U+1F1F6'},
444 {'alpha2': 'GR',
445 'flag': '🇬🇷',
446 'language': {'code': 'el', 'name': 'Greek (modern)'},
447 'name': 'Greece',
448 'unicode': 'U+1F1EC U+1F1F7'},
449 {'alpha2': 'GT',
450 'flag': '🇬🇹',
451 'language': {'code': 'es', 'name': 'Spanish'},
452 'name': 'Guatemala',
453 'unicode': 'U+1F1EC U+1F1F9'},
454 {'alpha2': 'GU',
455 'flag': '🇬🇺',
456 'language': {'code': 'en', 'name': 'English'},
457 'name': 'Guam',
458 'unicode': 'U+1F1EC U+1F1FA'},
459 {'alpha2': 'GW',
460 'flag': '🇬🇼',
461 'language': {'code': 'pt', 'name': 'Portuguese'},
462 'name': 'Guinea-Bissau',
463 'unicode': 'U+1F1EC U+1F1FC'},
464 {'alpha2': 'GY',
465 'flag': '🇬🇾',
466 'language': {'code': 'en', 'name': 'English'},
467 'name': 'Guyana',
468 'unicode': 'U+1F1EC U+1F1FE'},
469 {'alpha2': 'HK',
470 'flag': '🇭🇰',
471 'language': {'code': 'en', 'name': 'English'},
472 'name': 'Hong Kong',
473 'unicode': 'U+1F1ED U+1F1F0'},
474 {'alpha2': 'HN',
475 'flag': '🇭🇳',
476 'language': {'code': 'es', 'name': 'Spanish'},
477 'name': 'Honduras',
478 'unicode': 'U+1F1ED U+1F1F3'},
479 {'alpha2': 'HR',
480 'flag': '🇭🇷',
481 'language': {'code': 'hr', 'name': 'Croatian'},
482 'name': 'Croatia',
483 'unicode': 'U+1F1ED U+1F1F7'},
484 {'alpha2': 'HT',
485 'flag': '🇭🇹',
486 'language': {'code': 'fr', 'name': 'French'},
487 'name': 'Haiti',
488 'unicode': 'U+1F1ED U+1F1F9'},
489 {'alpha2': 'HU',
490 'flag': '🇭🇺',
491 'language': {'code': 'hu', 'name': 'Hungarian'},
492 'name': 'Hungary',
493 'unicode': 'U+1F1ED U+1F1FA'},
494 {'alpha2': 'ID',
495 'flag': '🇮🇩',
496 'language': {'code': 'id', 'name': 'Indonesian'},
497 'name': 'Indonesia',
498 'unicode': 'U+1F1EE U+1F1E9'},
499 {'alpha2': 'IE',
500 'flag': '🇮🇪',
501 'language': {'code': 'ga', 'name': 'Irish'},
502 'name': 'Ireland',
503 'unicode': 'U+1F1EE U+1F1EA'},
504 {'alpha2': 'IL',
505 'flag': '🇮🇱',
506 'language': {'code': 'he', 'name': 'Hebrew (modern)'},
507 'name': 'Israel',
508 'unicode': 'U+1F1EE U+1F1F1'},
509 {'alpha2': 'IM',
510 'flag': '🇮🇲',
511 'language': {'code': 'en', 'name': 'English'},
512 'name': 'Isle of Man',
513 'unicode': 'U+1F1EE U+1F1F2'},
514 {'alpha2': 'IN',
515 'flag': '🇮🇳',
516 'language': {'code': 'hi', 'name': 'Hindi'},
517 'name': 'India',
518 'unicode': 'U+1F1EE U+1F1F3'},
519 {'alpha2': 'IO',
520 'flag': '🇮🇴',
521 'language': {'code': 'en', 'name': 'English'},
522 'name': 'British Indian Ocean Territory',
523 'unicode': 'U+1F1EE U+1F1F4'},
524 {'alpha2': 'IQ',
525 'flag': '🇮🇶',
526 'language': {'code': 'ar', 'name': 'Arabic'},
527 'name': 'Iraq',
528 'unicode': 'U+1F1EE U+1F1F6'},
529 {'alpha2': 'IR',
530 'flag': '🇮🇷',
531 'language': {'code': 'fa', 'name': 'Persian (Farsi)'},
532 'name': 'Iran (Islamic Republic of)',
533 'unicode': 'U+1F1EE U+1F1F7'},
534 {'alpha2': 'IS',
535 'flag': '🇮🇸',
536 'language': {'code': 'is', 'name': 'Icelandic'},
537 'name': 'Iceland',
538 'unicode': 'U+1F1EE U+1F1F8'},
539 {'alpha2': 'IT',
540 'flag': '🇮🇹',
541 'language': {'code': 'it', 'name': 'Italian'},
542 'name': 'Italy',
543 'unicode': 'U+1F1EE U+1F1F9'},
544 {'alpha2': 'JE',
545 'flag': '🇯🇪',
546 'language': {'code': 'en', 'name': 'English'},
547 'name': 'Jersey',
548 'unicode': 'U+1F1EF U+1F1EA'},
549 {'alpha2': 'JM',
550 'flag': '🇯🇲',
551 'language': {'code': 'en', 'name': 'English'},
552 'name': 'Jamaica',
553 'unicode': 'U+1F1EF U+1F1F2'},
554 {'alpha2': 'JO',
555 'flag': '🇯🇴',
556 'language': {'code': 'ar', 'name': 'Arabic'},
557 'name': 'Jordan',
558 'unicode': 'U+1F1EF U+1F1F4'},
559 {'alpha2': 'JP',
560 'flag': '🇯🇵',
561 'language': {'code': 'ja', 'name': 'Japanese'},
562 'name': 'Japan',
563 'unicode': 'U+1F1EF U+1F1F5'},
564 {'alpha2': 'KE',
565 'flag': '🇰🇪',
566 'language': {'code': 'en', 'name': 'English'},
567 'name': 'Kenya',
568 'unicode': 'U+1F1F0 U+1F1EA'},
569 {'alpha2': 'KG',
570 'flag': '🇰🇬',
571 'language': {'code': 'ky', 'name': 'Kyrgyz'},
572 'name': 'Kyrgyzstan',
573 'unicode': 'U+1F1F0 U+1F1EC'},
574 {'alpha2': 'KH',
575 'flag': '🇰🇭',
576 'language': {'code': 'km', 'name': 'Khmer'},
577 'name': 'Cambodia',
578 'unicode': 'U+1F1F0 U+1F1ED'},
579 {'alpha2': 'KI',
580 'flag': '🇰🇮',
581 'language': {'code': 'en', 'name': 'English'},
582 'name': 'Kiribati',
583 'unicode': 'U+1F1F0 U+1F1EE'},
584 {'alpha2': 'KM',
585 'flag': '🇰🇲',
586 'language': {'code': 'fr', 'name': 'French'},
587 'name': 'Comoros',
588 'unicode': 'U+1F1F0 U+1F1F2'},
589 {'alpha2': 'KN',
590 'flag': '🇰🇳',
591 'language': {'code': 'en', 'name': 'English'},
592 'name': 'Saint Kitts and Nevis',
593 'unicode': 'U+1F1F0 U+1F1F3'},
594 {'alpha2': 'KP',
595 'flag': '🇰🇵',
596 'language': {'code': 'ko', 'name': 'Korean'},
597 'name': "Korea (Democratic People's Republic of)",
598 'unicode': 'U+1F1F0 U+1F1F5'},
599 {'alpha2': 'KR',
600 'flag': '🇰🇷',
601 'language': {'code': 'ko', 'name': 'Korean'},
602 'name': 'Korea (Republic of)',
603 'unicode': 'U+1F1F0 U+1F1F7'},
604 {'alpha2': 'KW',
605 'flag': '🇰🇼',
606 'language': {'code': 'ar', 'name': 'Arabic'},
607 'name': 'Kuwait',
608 'unicode': 'U+1F1F0 U+1F1FC'},
609 {'alpha2': 'KY',
610 'flag': '🇰🇾',
611 'language': {'code': 'en', 'name': 'English'},
612 'name': 'Cayman Islands',
613 'unicode': 'U+1F1F0 U+1F1FE'},
614 {'alpha2': 'KZ',
615 'flag': '🇰🇿',
616 'language': {'code': 'kk', 'name': 'Kazakh'},
617 'name': 'Kazakhstan',
618 'unicode': 'U+1F1F0 U+1F1FF'},
619 {'alpha2': 'LA',
620 'flag': '🇱🇦',
621 'language': {'code': 'lo', 'name': 'Lao'},
622 'name': "Lao People's Democratic Republic",
623 'unicode': 'U+1F1F1 U+1F1E6'},
624 {'alpha2': 'LB',
625 'flag': '🇱🇧',
626 'language': {'code': 'ar', 'name': 'Arabic'},
627 'name': 'Lebanon',
628 'unicode': 'U+1F1F1 U+1F1E7'},
629 {'alpha2': 'LC',
630 'flag': '🇱🇨',
631 'language': {'code': 'en', 'name': 'English'},
632 'name': 'Saint Lucia',
633 'unicode': 'U+1F1F1 U+1F1E8'},
634 {'alpha2': 'LI',
635 'flag': '🇱🇮',
636 'language': {'code': 'de', 'name': 'German'},
637 'name': 'Liechtenstein',
638 'unicode': 'U+1F1F1 U+1F1EE'},
639 {'alpha2': 'LK',
640 'flag': '🇱🇰',
641 'language': {'code': 'si',
642 'iso639_2': 'sin',
643 'name': 'Sinhalese',
644 'nativeName': 'සිංහල'},
645 'name': 'Sri Lanka',
646 'unicode': 'U+1F1F1 U+1F1F0'},
647 {'alpha2': 'LR',
648 'flag': '🇱🇷',
649 'language': {'code': 'en', 'name': 'English'},
650 'name': 'Liberia',
651 'unicode': 'U+1F1F1 U+1F1F7'},
652 {'alpha2': 'LS',
653 'flag': '🇱🇸',
654 'language': {'code': 'en', 'name': 'English'},
655 'name': 'Lesotho',
656 'unicode': 'U+1F1F1 U+1F1F8'},
657 {'alpha2': 'LT',
658 'flag': '🇱🇹',
659 'language': {'code': 'lt', 'name': 'Lithuanian'},
660 'name': 'Lithuania',
661 'unicode': 'U+1F1F1 U+1F1F9'},
662 {'alpha2': 'LU',
663 'flag': '🇱🇺',
664 'language': {'code': 'fr', 'name': 'French'},
665 'name': 'Luxembourg',
666 'unicode': 'U+1F1F1 U+1F1FA'},
667 {'alpha2': 'LV',
668 'flag': '🇱🇻',
669 'language': {'code': 'lv', 'name': 'Latvian'},
670 'name': 'Latvia',
671 'unicode': 'U+1F1F1 U+1F1FB'},
672 {'alpha2': 'LY',
673 'flag': '🇱🇾',
674 'language': {'code': 'ar', 'name': 'Arabic'},
675 'name': 'Libya',
676 'unicode': 'U+1F1F1 U+1F1FE'},
677 {'alpha2': 'MA',
678 'flag': '🇲🇦',
679 'language': {'code': 'ar', 'name': 'Arabic'},
680 'name': 'Morocco',
681 'unicode': 'U+1F1F2 U+1F1E6'},
682 {'alpha2': 'MC',
683 'flag': '🇲🇨',
684 'language': {'code': 'fr', 'name': 'French'},
685 'name': 'Monaco',
686 'unicode': 'U+1F1F2 U+1F1E8'},
687 {'alpha2': 'MD',
688 'flag': '🇲🇩',
689 'language': {'code': 'ro', 'name': 'Romanian'},
690 'name': 'Moldova (Republic of)',
691 'unicode': 'U+1F1F2 U+1F1E9'},
692 {'alpha2': 'ME',
693 'flag': '🇲🇪',
694 'language': {'code': 'sr', 'name': 'Serbian'},
695 'name': 'Montenegro',
696 'unicode': 'U+1F1F2 U+1F1EA'},
697 {'alpha2': 'MF',
698 'flag': '🇲🇫',
699 'language': {'code': 'en', 'name': 'English'},
700 'name': 'Saint Martin (French part)',
701 'unicode': 'U+1F1F2 U+1F1EB'},
702 {'alpha2': 'MG',
703 'flag': '🇲🇬',
704 'language': {'code': 'fr', 'name': 'French'},
705 'name': 'Madagascar',
706 'unicode': 'U+1F1F2 U+1F1EC'},
707 {'alpha2': 'MH',
708 'flag': '🇲🇭',
709 'language': {'code': 'en', 'name': 'English'},
710 'name': 'Marshall Islands',
711 'unicode': 'U+1F1F2 U+1F1ED'},
712 {'alpha2': 'MK',
713 'flag': '🇲🇰',
714 'language': {'code': 'mk', 'name': 'Macedonian'},
715 'name': 'Macedonia (the former Yugoslav Republic of)',
716 'unicode': 'U+1F1F2 U+1F1F0'},
717 {'alpha2': 'ML',
718 'flag': '🇲🇱',
719 'language': {'code': 'fr', 'name': 'French'},
720 'name': 'Mali',
721 'unicode': 'U+1F1F2 U+1F1F1'},
722 {'alpha2': 'MM',
723 'flag': '🇲🇲',
724 'language': {'code': 'my', 'name': 'Burmese'},
725 'name': 'Myanmar',
726 'unicode': 'U+1F1F2 U+1F1F2'},
727 {'alpha2': 'MN',
728 'flag': '🇲🇳',
729 'language': {'code': 'mn', 'name': 'Mongolian'},
730 'name': 'Mongolia',
731 'unicode': 'U+1F1F2 U+1F1F3'},
732 {'alpha2': 'MO',
733 'flag': '🇲🇴',
734 'language': {'code': 'zh', 'name': 'Chinese'},
735 'name': 'Macao',
736 'unicode': 'U+1F1F2 U+1F1F4'},
737 {'alpha2': 'MP',
738 'flag': '🇲🇵',
739 'language': {'code': 'en', 'name': 'English'},
740 'name': 'Northern Mariana Islands',
741 'unicode': 'U+1F1F2 U+1F1F5'},
742 {'alpha2': 'MQ',
743 'flag': '🇲🇶',
744 'language': {'code': 'fr', 'name': 'French'},
745 'name': 'Martinique',
746 'unicode': 'U+1F1F2 U+1F1F6'},
747 {'alpha2': 'MR',
748 'flag': '🇲🇷',
749 'language': {'code': 'ar', 'name': 'Arabic'},
750 'name': 'Mauritania',
751 'unicode': 'U+1F1F2 U+1F1F7'},
752 {'alpha2': 'MS',
753 'flag': '🇲🇸',
754 'language': {'code': 'en', 'name': 'English'},
755 'name': 'Montserrat',
756 'unicode': 'U+1F1F2 U+1F1F8'},
757 {'alpha2': 'MT',
758 'flag': '🇲🇹',
759 'language': {'code': 'mt', 'name': 'Maltese'},
760 'name': 'Malta',
761 'unicode': 'U+1F1F2 U+1F1F9'},
762 {'alpha2': 'MU',
763 'flag': '🇲🇺',
764 'language': {'code': 'en', 'name': 'English'},
765 'name': 'Mauritius',
766 'unicode': 'U+1F1F2 U+1F1FA'},
767 {'alpha2': 'MV',
768 'flag': '🇲🇻',
769 'language': {'code': 'dv', 'name': 'Divehi'},
770 'name': 'Maldives',
771 'unicode': 'U+1F1F2 U+1F1FB'},
772 {'alpha2': 'MW',
773 'flag': '🇲🇼',
774 'language': {'code': 'en', 'name': 'English'},
775 'name': 'Malawi',
776 'unicode': 'U+1F1F2 U+1F1FC'},
777 {'alpha2': 'MX',
778 'flag': '🇲🇽',
779 'language': {'code': 'es', 'name': 'Spanish'},
780 'name': 'Mexico',
781 'unicode': 'U+1F1F2 U+1F1FD'},
782 {'alpha2': 'MY',
783 'flag': '🇲🇾',
784 'language': {'code': None, 'name': 'Malaysian'},
785 'name': 'Malaysia',
786 'unicode': 'U+1F1F2 U+1F1FE'},
787 {'alpha2': 'MZ',
788 'flag': '🇲🇿',
789 'language': {'code': 'pt', 'name': 'Portuguese'},
790 'name': 'Mozambique',
791 'unicode': 'U+1F1F2 U+1F1FF'},
792 {'alpha2': 'NA',
793 'flag': '🇳🇦',
794 'language': {'code': 'en', 'name': 'English'},
795 'name': 'Namibia',
796 'unicode': 'U+1F1F3 U+1F1E6'},
797 {'alpha2': 'NC',
798 'flag': '🇳🇨',
799 'language': {'code': 'fr', 'name': 'French'},
800 'name': 'New Caledonia',
801 'unicode': 'U+1F1F3 U+1F1E8'},
802 {'alpha2': 'NE',
803 'flag': '🇳🇪',
804 'language': {'code': 'fr', 'name': 'French'},
805 'name': 'Niger',
806 'unicode': 'U+1F1F3 U+1F1EA'},
807 {'alpha2': 'NF',
808 'flag': '🇳🇫',
809 'language': {'code': 'en', 'name': 'English'},
810 'name': 'Norfolk Island',
811 'unicode': 'U+1F1F3 U+1F1EB'},
812 {'alpha2': 'NG',
813 'flag': '🇳🇬',
814 'language': {'code': 'en', 'name': 'English'},
815 'name': 'Nigeria',
816 'unicode': 'U+1F1F3 U+1F1EC'},
817 {'alpha2': 'NI',
818 'flag': '🇳🇮',
819 'language': {'code': 'es', 'name': 'Spanish'},
820 'name': 'Nicaragua',
821 'unicode': 'U+1F1F3 U+1F1EE'},
822 {'alpha2': 'NL',
823 'flag': '🇳🇱',
824 'language': {'code': 'nl', 'name': 'Dutch'},
825 'name': 'Netherlands',
826 'unicode': 'U+1F1F3 U+1F1F1'},
827 {'alpha2': 'NO',
828 'flag': '🇳🇴',
829 'language': {'code': 'no', 'name': 'Norwegian'},
830 'name': 'Norway',
831 'unicode': 'U+1F1F3 U+1F1F4'},
832 {'alpha2': 'NP',
833 'flag': '🇳🇵',
834 'language': {'code': 'ne', 'name': 'Nepali'},
835 'name': 'Nepal',
836 'unicode': 'U+1F1F3 U+1F1F5'},
837 {'alpha2': 'NR',
838 'flag': '🇳🇷',
839 'language': {'code': 'en', 'name': 'English'},
840 'name': 'Nauru',
841 'unicode': 'U+1F1F3 U+1F1F7'},
842 {'alpha2': 'NU',
843 'flag': '🇳🇺',
844 'language': {'code': 'en', 'name': 'English'},
845 'name': 'Niue',
846 'unicode': 'U+1F1F3 U+1F1FA'},
847 {'alpha2': 'NZ',
848 'flag': '🇳🇿',
849 'language': {'code': 'en', 'name': 'English'},
850 'name': 'New Zealand',
851 'unicode': 'U+1F1F3 U+1F1FF'},
852 {'alpha2': 'OM',
853 'flag': '🇴🇲',
854 'language': {'code': 'ar', 'name': 'Arabic'},
855 'name': 'Oman',
856 'unicode': 'U+1F1F4 U+1F1F2'},
857 {'alpha2': 'PA',
858 'flag': '🇵🇦',
859 'language': {'code': 'es', 'name': 'Spanish'},
860 'name': 'Panama',
861 'unicode': 'U+1F1F5 U+1F1E6'},
862 {'alpha2': 'PE',
863 'flag': '🇵🇪',
864 'language': {'code': 'es', 'name': 'Spanish'},
865 'name': 'Peru',
866 'unicode': 'U+1F1F5 U+1F1EA'},
867 {'alpha2': 'PF',
868 'flag': '🇵🇫',
869 'language': {'code': 'fr', 'name': 'French'},
870 'name': 'French Polynesia',
871 'unicode': 'U+1F1F5 U+1F1EB'},
872 {'alpha2': 'PG',
873 'flag': '🇵🇬',
874 'language': {'code': 'en', 'name': 'English'},
875 'name': 'Papua New Guinea',
876 'unicode': 'U+1F1F5 U+1F1EC'},
877 {'alpha2': 'PH',
878 'flag': '🇵🇭',
879 'language': {'code': 'en', 'name': 'English'},
880 'name': 'Philippines',
881 'unicode': 'U+1F1F5 U+1F1ED'},
882 {'alpha2': 'PK',
883 'flag': '🇵🇰',
884 'language': {'code': 'en', 'name': 'English'},
885 'name': 'Pakistan',
886 'unicode': 'U+1F1F5 U+1F1F0'},
887 {'alpha2': 'PL',
888 'flag': '🇵🇱',
889 'language': {'code': 'pl', 'name': 'Polish'},
890 'name': 'Poland',
891 'unicode': 'U+1F1F5 U+1F1F1'},
892 {'alpha2': 'PM',
893 'flag': '🇵🇲',
894 'language': {'code': 'fr', 'name': 'French'},
895 'name': 'Saint Pierre and Miquelon',
896 'unicode': 'U+1F1F5 U+1F1F2'},
897 {'alpha2': 'PR',
898 'flag': '🇵🇷',
899 'language': {'code': 'es', 'name': 'Spanish'},
900 'name': 'Puerto Rico',
901 'unicode': 'U+1F1F5 U+1F1F7'},
902 {'alpha2': 'PS',
903 'flag': '🇵🇸',
904 'language': {'code': 'ar', 'name': 'Arabic'},
905 'name': 'Palestine, State of',
906 'unicode': 'U+1F1F5 U+1F1F8'},
907 {'alpha2': 'PT',
908 'flag': '🇵🇹',
909 'language': {'code': 'pt', 'name': 'Portuguese'},
910 'name': 'Portugal',
911 'unicode': 'U+1F1F5 U+1F1F9'},
912 {'alpha2': 'PW',
913 'flag': '🇵🇼',
914 'language': {'code': 'en', 'name': 'English'},
915 'name': 'Palau',
916 'unicode': 'U+1F1F5 U+1F1FC'},
917 {'alpha2': 'PY',
918 'flag': '🇵🇾',
919 'language': {'code': 'es', 'name': 'Spanish'},
920 'name': 'Paraguay',
921 'unicode': 'U+1F1F5 U+1F1FE'},
922 {'alpha2': 'QA',
923 'flag': '🇶🇦',
924 'language': {'code': 'ar', 'name': 'Arabic'},
925 'name': 'Qatar',
926 'unicode': 'U+1F1F6 U+1F1E6'},
927 {'alpha2': 'RE',
928 'flag': '🇷🇪',
929 'language': {'code': 'fr', 'name': 'French'},
930 'name': 'Réunion',
931 'unicode': 'U+1F1F7 U+1F1EA'},
932 {'alpha2': 'RO',
933 'flag': '🇷🇴',
934 'language': {'code': 'ro', 'name': 'Romanian'},
935 'name': 'Romania',
936 'unicode': 'U+1F1F7 U+1F1F4'},
937 {'alpha2': 'RS',
938 'flag': '🇷🇸',
939 'language': {'code': 'sr', 'name': 'Serbian'},
940 'name': 'Serbia',
941 'unicode': 'U+1F1F7 U+1F1F8'},
942 {'alpha2': 'RU',
943 'flag': '🇷🇺',
944 'language': {'code': 'ru', 'name': 'Russian'},
945 'name': 'Russian Federation',
946 'unicode': 'U+1F1F7 U+1F1FA'},
947 {'alpha2': 'RW',
948 'flag': '🇷🇼',
949 'language': {'code': 'rw', 'name': 'Kinyarwanda'},
950 'name': 'Rwanda',
951 'unicode': 'U+1F1F7 U+1F1FC'},
952 {'alpha2': 'SA',
953 'flag': '🇸🇦',
954 'language': {'code': 'ar', 'name': 'Arabic'},
955 'name': 'Saudi Arabia',
956 'unicode': 'U+1F1F8 U+1F1E6'},
957 {'alpha2': 'SB',
958 'flag': '🇸🇧',
959 'language': {'code': 'en', 'name': 'English'},
960 'name': 'Solomon Islands',
961 'unicode': 'U+1F1F8 U+1F1E7'},
962 {'alpha2': 'SC',
963 'flag': '🇸🇨',
964 'language': {'code': 'fr', 'name': 'French'},
965 'name': 'Seychelles',
966 'unicode': 'U+1F1F8 U+1F1E8'},
967 {'alpha2': 'SD',
968 'flag': '🇸🇩',
969 'language': {'code': 'ar', 'name': 'Arabic'},
970 'name': 'Sudan',
971 'unicode': 'U+1F1F8 U+1F1E9'},
972 {'alpha2': 'SE',
973 'flag': '🇸🇪',
974 'language': {'code': 'sv', 'name': 'Swedish'},
975 'name': 'Sweden',
976 'unicode': 'U+1F1F8 U+1F1EA'},
977 {'alpha2': 'SG',
978 'flag': '🇸🇬',
979 'language': {'code': 'en', 'name': 'English'},
980 'name': 'Singapore',
981 'unicode': 'U+1F1F8 U+1F1EC'},
982 {'alpha2': 'SH',
983 'flag': '🇸🇭',
984 'language': {'code': 'en', 'name': 'English'},
985 'name': 'Saint Helena, Ascension and Tristan da Cunha',
986 'unicode': 'U+1F1F8 U+1F1ED'},
987 {'alpha2': 'SI',
988 'flag': '🇸🇮',
989 'language': {'code': 'sl', 'name': 'Slovene'},
990 'name': 'Slovenia',
991 'unicode': 'U+1F1F8 U+1F1EE'},
992 {'alpha2': 'SK',
993 'flag': '🇸🇰',
994 'language': {'code': 'sk', 'name': 'Slovak'},
995 'name': 'Slovakia',
996 'unicode': 'U+1F1F8 U+1F1F0'},
997 {'alpha2': 'SL',
998 'flag': '🇸🇱',
999 'language': {'code': 'en', 'name': 'English'},
1000 'name': 'Sierra Leone',
1001 'unicode': 'U+1F1F8 U+1F1F1'},
1002 {'alpha2': 'SM',
1003 'flag': '🇸🇲',
1004 'language': {'code': 'it', 'name': 'Italian'},
1005 'name': 'San Marino',
1006 'unicode': 'U+1F1F8 U+1F1F2'},
1007 {'alpha2': 'SN',
1008 'flag': '🇸🇳',
1009 'language': {'code': 'fr', 'name': 'French'},
1010 'name': 'Senegal',
1011 'unicode': 'U+1F1F8 U+1F1F3'},
1012 {'alpha2': 'SO',
1013 'flag': '🇸🇴',
1014 'language': {'code': 'ar', 'name': 'Arabic'},
1015 'name': 'Somalia',
1016 'unicode': 'U+1F1F8 U+1F1F4'},
1017 {'alpha2': 'SR',
1018 'flag': '🇸🇷',
1019 'language': {'code': 'nl', 'name': 'Dutch'},
1020 'name': 'Suriname',
1021 'unicode': 'U+1F1F8 U+1F1F7'},
1022 {'alpha2': 'SS',
1023 'flag': '🇸🇸',
1024 'language': {'code': 'ar', 'name': 'Arabic'},
1025 'name': 'South Sudan',
1026 'unicode': 'U+1F1F8 U+1F1F8'},
1027 {'alpha2': 'ST',
1028 'flag': '🇸🇹',
1029 'language': {'code': 'pt', 'name': 'Portuguese'},
1030 'name': 'Sao Tome and Principe',
1031 'unicode': 'U+1F1F8 U+1F1F9'},
1032 {'alpha2': 'SV',
1033 'flag': '🇸🇻',
1034 'language': {'code': 'es', 'name': 'Spanish'},
1035 'name': 'El Salvador',
1036 'unicode': 'U+1F1F8 U+1F1FB'},
1037 {'alpha2': 'SY',
1038 'flag': '🇸🇾',
1039 'language': {'code': 'ar', 'name': 'Arabic'},
1040 'name': 'Syrian Arab Republic',
1041 'unicode': 'U+1F1F8 U+1F1FE'},
1042 {'alpha2': 'SZ',
1043 'flag': '🇸🇿',
1044 'language': {'code': 'en', 'name': 'English'},
1045 'name': 'Swaziland',
1046 'unicode': 'U+1F1F8 U+1F1FF'},
1047 {'alpha2': 'TC',
1048 'flag': '🇹🇨',
1049 'language': {'code': 'en', 'name': 'English'},
1050 'name': 'Turks and Caicos Islands',
1051 'unicode': 'U+1F1F9 U+1F1E8'},
1052 {'alpha2': 'TD',
1053 'flag': '🇹🇩',
1054 'language': {'code': 'fr', 'name': 'French'},
1055 'name': 'Chad',
1056 'unicode': 'U+1F1F9 U+1F1E6'},
1057 {'alpha2': 'TF',
1058 'flag': '🇹🇫',
1059 'language': {'code': 'fr', 'name': 'French'},
1060 'name': 'French Southern Territories',
1061 'unicode': 'U+1F1F9 U+1F1EB'},
1062 {'alpha2': 'TG',
1063 'flag': '🇹🇬',
1064 'language': {'code': 'fr', 'name': 'French'},
1065 'name': 'Togo',
1066 'unicode': 'U+1F1F9 U+1F1EC'},
1067 {'alpha2': 'TH',
1068 'flag': '🇹🇭',
1069 'language': {'code': 'th', 'name': 'Thai'},
1070 'name': 'Thailand',
1071 'unicode': 'U+1F1F9 U+1F1ED'},
1072 {'alpha2': 'TJ',
1073 'flag': '🇹🇯',
1074 'language': {'code': 'tg', 'name': 'Tajik'},
1075 'name': 'Tajikistan',
1076 'unicode': 'U+1F1F9 U+1F1EF'},
1077 {'alpha2': 'TK',
1078 'flag': '🇹🇰',
1079 'language': {'code': 'en', 'name': 'English'},
1080 'name': 'Tokelau',
1081 'unicode': 'U+1F1F9 U+1F1F0'},
1082 {'alpha2': 'TL',
1083 'flag': '🇹🇱',
1084 'language': {'code': 'pt', 'name': 'Portuguese'},
1085 'name': 'Timor-Leste',
1086 'unicode': 'U+1F1F9 U+1F1F1'},
1087 {'alpha2': 'TM',
1088 'flag': '🇹🇲',
1089 'language': {'code': 'tk', 'name': 'Turkmen'},
1090 'name': 'Turkmenistan',
1091 'unicode': 'U+1F1F9 U+1F1F2'},
1092 {'alpha2': 'TN',
1093 'flag': '🇹🇳',
1094 'language': {'code': 'ar', 'name': 'Arabic'},
1095 'name': 'Tunisia',
1096 'unicode': 'U+1F1F9 U+1F1F3'},
1097 {'alpha2': 'TO',
1098 'flag': '🇹🇴',
1099 'language': {'code': 'en', 'name': 'English'},
1100 'name': 'Tonga',
1101 'unicode': 'U+1F1F9 U+1F1F4'},
1102 {'alpha2': 'TR',
1103 'flag': '🇹🇷',
1104 'language': {'code': 'tr', 'name': 'Turkish'},
1105 'name': 'Turkey',
1106 'unicode': 'U+1F1F9 U+1F1F7'},
1107 {'alpha2': 'TT',
1108 'flag': '🇹🇹',
1109 'language': {'code': 'en', 'name': 'English'},
1110 'name': 'Trinidad and Tobago',
1111 'unicode': 'U+1F1F9 U+1F1F9'},
1112 {'alpha2': 'TV',
1113 'flag': '🇹🇻',
1114 'language': {'code': 'en', 'name': 'English'},
1115 'name': 'Tuvalu',
1116 'unicode': 'U+1F1F9 U+1F1FB'},
1117 {'alpha2': 'TW',
1118 'flag': '🇹🇼',
1119 'language': {'code': 'zh', 'name': 'Chinese'},
1120 'name': 'Taiwan',
1121 'unicode': 'U+1F1F9 U+1F1FC'},
1122 {'alpha2': 'TZ',
1123 'flag': '🇹🇿',
1124 'language': {'code': 'en', 'name': 'English'},
1125 'name': 'United Republic of Tanzania',
1126 'unicode': 'U+1F1F9 U+1F1FF'},
1127 {'alpha2': 'UA',
1128 'flag': '🇺🇦',
1129 'language': {'code': 'uk', 'name': 'Ukrainian'},
1130 'name': 'Ukraine',
1131 'unicode': 'U+1F1FA U+1F1E6'},
1132 {'alpha2': 'UG',
1133 'flag': '🇺🇬',
1134 'language': {'code': 'en', 'name': 'English'},
1135 'name': 'Uganda',
1136 'unicode': 'U+1F1FA U+1F1EC'},
1137 {'alpha2': 'US',
1138 'flag': '🇺🇸',
1139 'language': {'code': 'en',
1140 'iso639_2': 'eng',
1141 'name': 'English',
1142 'nativeName': 'English'},
1143 'name': 'United States of America',
1144 'unicode': 'U+1F1FA U+1F1F8'},
1145 {'alpha2': 'UY',
1146 'flag': '🇺🇾',
1147 'language': {'code': 'es', 'name': 'Spanish'},
1148 'name': 'Uruguay',
1149 'unicode': 'U+1F1FA U+1F1FE'},
1150 {'alpha2': 'UZ',
1151 'flag': '🇺🇿',
1152 'language': {'code': 'uz', 'name': 'Uzbek'},
1153 'name': 'Uzbekistan',
1154 'unicode': 'U+1F1FA U+1F1FF'},
1155 {'alpha2': 'VA',
1156 'flag': '🇻🇦',
1157 'language': {'code': 'fr', 'name': 'French'},
1158 'name': 'Holy See',
1159 'unicode': 'U+1F1FB U+1F1E6'},
1160 {'alpha2': 'VC',
1161 'flag': '🇻🇨',
1162 'language': {'code': 'en', 'name': 'English'},
1163 'name': 'Saint Vincent and the Grenadines',
1164 'unicode': 'U+1F1FB U+1F1E8'},
1165 {'alpha2': 'VE',
1166 'flag': '🇻🇪',
1167 'language': {'code': 'es', 'name': 'Spanish'},
1168 'name': 'Venezuela (Bolivarian Republic of)',
1169 'unicode': 'U+1F1FB U+1F1EA'},
1170 {'alpha2': 'VG',
1171 'flag': '🇻🇬',
1172 'language': {'code': 'en', 'name': 'English'},
1173 'name': 'Virgin Islands (British)',
1174 'unicode': 'U+1F1FB U+1F1EC'},
1175 {'alpha2': 'VI',
1176 'flag': '🇻🇮',
1177 'language': {'code': 'en', 'name': 'English'},
1178 'name': 'Virgin Islands (U.S.)',
1179 'unicode': 'U+1F1FB U+1F1EE'},
1180 {'alpha2': 'VN',
1181 'flag': '🇻🇳',
1182 'language': {'code': 'vi', 'name': 'Vietnamese'},
1183 'name': 'Viet Nam',
1184 'unicode': 'U+1F1FB U+1F1F3'},
1185 {'alpha2': 'VU',
1186 'flag': '🇻🇺',
1187 'language': {'code': 'en', 'name': 'English'},
1188 'name': 'Vanuatu',
1189 'unicode': 'U+1F1FB U+1F1FA'},
1190 {'alpha2': 'WF',
1191 'flag': '🇼🇫',
1192 'language': {'code': 'fr', 'name': 'French'},
1193 'name': 'Wallis and Futuna',
1194 'unicode': 'U+1F1FC U+1F1EB'},
1195 {'alpha2': 'WS',
1196 'flag': '🇼🇸',
1197 'language': {'code': 'sm', 'name': 'Samoan'},
1198 'name': 'Samoa',
1199 'unicode': 'U+1F1FC U+1F1F8'},
1200 {'alpha2': 'XK',
1201 'flag': '🇽🇰',
1202 'language': {'code': 'sq', 'name': 'Albanian'},
1203 'name': 'Republic of Kosovo',
1204 'unicode': 'U+1F1FD U+1F1F0'},
1205 {'alpha2': 'YE',
1206 'flag': '🇾🇪',
1207 'language': {'code': 'ar', 'name': 'Arabic'},
1208 'name': 'Yemen',
1209 'unicode': 'U+1F1FE U+1F1EA'},
1210 {'alpha2': 'YT',
1211 'flag': '🇾🇹',
1212 'language': {'code': 'fr', 'name': 'French'},
1213 'name': 'Mayotte',
1214 'unicode': 'U+1F1FE U+1F1F9'},
1215 {'alpha2': 'ZA',
1216 'flag': '🇿🇦',
1217 'language': {'code': 'en',
1218 'iso639_2': 'eng',
1219 'name': 'English',
1220 'nativeName': 'English'},
1221 'name': 'South Africa',
1222 'unicode': 'U+1F1FF U+1F1E6'},
1223 {'alpha2': 'ZM',
1224 'flag': '🇿🇲',
1225 'language': {'code': 'en', 'name': 'English'},
1226 'name': 'Zambia',
1227 'unicode': 'U+1F1FF U+1F1F2'},
1228 {'alpha2': 'ZW',
1229 'flag': '🇿🇼',
1230 'language': {'code': 'en',
1231 'iso639_2': 'eng',
1232 'name': 'English',
1233 'nativeName': 'English'},
1234 'name': 'Zimbabwe',
1235 'unicode': 'U+1F1FF U+1F1FC'}]
3_countries = [
4 {
5 "alpha2": "AD",
6 "flag": "🇦🇩",
7 "language": {"code": "ca", "name": "Catalan"},
8 "name": "Andorra",
9 "unicode": "U+1F1E6 U+1F1E9",
10 },
11 {
12 "alpha2": "AE",
13 "flag": "🇦🇪",
14 "language": {"code": "ar", "name": "Arabic"},
15 "name": "United Arab Emirates",
16 "unicode": "U+1F1E6 U+1F1EA",
17 },
18 {
19 "alpha2": "AF",
20 "flag": "🇦🇫",
21 "language": {"code": "ps", "name": "Pashto"},
22 "name": "Afghanistan",
23 "unicode": "U+1F1E6 U+1F1EB",
24 },
25 {
26 "alpha2": "AG",
27 "flag": "🇦🇬",
28 "language": {"code": "en", "name": "English"},
29 "name": "Antigua and Barbuda",
30 "unicode": "U+1F1E6 U+1F1EC",
31 },
32 {
33 "alpha2": "AI",
34 "flag": "🇦🇮",
35 "language": {"code": "en", "name": "English"},
36 "name": "Anguilla",
37 "unicode": "U+1F1E6 U+1F1EE",
38 },
39 {
40 "alpha2": "AL",
41 "flag": "🇦🇱",
42 "language": {"code": "sq", "name": "Albanian"},
43 "name": "Albania",
44 "unicode": "U+1F1E6 U+1F1F1",
45 },
46 {
47 "alpha2": "AM",
48 "flag": "🇦🇲",
49 "language": {"code": "hy", "name": "Armenian"},
50 "name": "Armenia",
51 "unicode": "U+1F1E6 U+1F1F2",
52 },
53 {
54 "alpha2": "AO",
55 "flag": "🇦🇴",
56 "language": {"code": "pt", "name": "Portuguese"},
57 "name": "Angola",
58 "unicode": "U+1F1E6 U+1F1F4",
59 },
60 {
61 "alpha2": "AR",
62 "flag": "🇦🇷",
63 "language": {"code": "es", "name": "Spanish"},
64 "name": "Argentina",
65 "unicode": "U+1F1E6 U+1F1F7",
66 },
67 {
68 "alpha2": "AS",
69 "flag": "🇦🇸",
70 "language": {"code": "en", "name": "English"},
71 "name": "American Samoa",
72 "unicode": "U+1F1E6 U+1F1F8",
73 },
74 {
75 "alpha2": "AUT",
76 "flag": "🇦🇹",
77 "language": {"code": "de", "name": "German"},
78 "name": "Austria",
79 "unicode": "U+1F1E6 U+1F1F9",
80 },
81 {
82 "alpha2": "AU",
83 "flag": "🇦🇺",
84 "language": {"code": "en", "name": "English"},
85 "name": "Australia",
86 "unicode": "U+1F1E6 U+1F1FA",
87 },
88 {
89 "alpha2": "AW",
90 "flag": "🇦🇼",
91 "language": {"code": "nl", "name": "Dutch"},
92 "name": "Aruba",
93 "unicode": "U+1F1E6 U+1F1FC",
94 },
95 {
96 "alpha2": "AX",
97 "flag": "🇦🇽",
98 "language": {
99 "code": "fi",
100 "iso639_2": "fin",
101 "name": "Finnish",
102 "nativeName": "suomi",
103 },
104 "name": "Åland Islands",
105 "unicode": "U+1F1E6 U+1F1FD",
106 },
107 {
108 "alpha2": "AZ",
109 "flag": "🇦🇿",
110 "language": {"code": "az", "name": "Azerbaijani"},
111 "name": "Azerbaijan",
112 "unicode": "U+1F1E6 U+1F1FF",
113 },
114 {
115 "alpha2": "BA",
116 "flag": "🇧🇦",
117 "language": {"code": "bs", "name": "Bosnian"},
118 "name": "Bosnia and Herzegovina",
119 "unicode": "U+1F1E7 U+1F1E6",
120 },
121 {
122 "alpha2": "BB",
123 "flag": "🇧🇧",
124 "language": {"code": "en", "name": "English"},
125 "name": "Barbados",
126 "unicode": "U+1F1E7 U+1F1E7",
127 },
128 {
129 "alpha2": "BD",
130 "flag": "🇧🇩",
131 "language": {"code": "bn", "name": "Bengali"},
132 "name": "Bangladesh",
133 "unicode": "U+1F1E7 U+1F1E9",
134 },
135 {
136 "alpha2": "BE",
137 "flag": "🇧🇪",
138 "language": {"code": "nl", "name": "Dutch"},
139 "name": "Belgium",
140 "unicode": "U+1F1E7 U+1F1EA",
141 },
142 {
143 "alpha2": "BF",
144 "flag": "🇧🇫",
145 "language": {"code": "fr", "name": "French"},
146 "name": "Burkina Faso",
147 "unicode": "U+1F1E7 U+1F1EB",
148 },
149 {
150 "alpha2": "BG",
151 "flag": "🇧🇬",
152 "language": {"code": "bg", "name": "Bulgarian"},
153 "name": "Bulgaria",
154 "unicode": "U+1F1E7 U+1F1EC",
155 },
156 {
157 "alpha2": "BH",
158 "flag": "🇧🇭",
159 "language": {"code": "ar", "name": "Arabic"},
160 "name": "Bahrain",
161 "unicode": "U+1F1E7 U+1F1ED",
162 },
163 {
164 "alpha2": "BI",
165 "flag": "🇧🇮",
166 "language": {"code": "fr", "name": "French"},
167 "name": "Burundi",
168 "unicode": "U+1F1E7 U+1F1EE",
169 },
170 {
171 "alpha2": "BJ",
172 "flag": "🇧🇯",
173 "language": {"code": "fr", "name": "French"},
174 "name": "Benin",
175 "unicode": "U+1F1E7 U+1F1EF",
176 },
177 {
178 "alpha2": "BL",
179 "flag": "🇧🇱",
180 "language": {"code": "fr", "name": "French"},
181 "name": "Saint Barthélemy",
182 "unicode": "U+1F1E7 U+1F1F1",
183 },
184 {
185 "alpha2": "BM",
186 "flag": "🇧🇲",
187 "language": {"code": "en", "name": "English"},
188 "name": "Bermuda",
189 "unicode": "U+1F1E7 U+1F1F2",
190 },
191 {
192 "alpha2": "BN",
193 "flag": "🇧🇳",
194 "language": {"code": "ms", "name": "Malay"},
195 "name": "Brunei Darussalam",
196 "unicode": "U+1F1E7 U+1F1F3",
197 },
198 {
199 "alpha2": "BO",
200 "flag": "🇧🇴",
201 "language": {"code": "es", "name": "Spanish"},
202 "name": "Bolivia (Plurinational State of)",
203 "unicode": "U+1F1E7 U+1F1F4",
204 },
205 {
206 "alpha2": "BR",
207 "flag": "🇧🇷",
208 "language": {"code": "pt", "name": "Portuguese"},
209 "name": "Brazil",
210 "unicode": "U+1F1E7 U+1F1F7",
211 },
212 {
213 "alpha2": "BS",
214 "flag": "🇧🇸",
215 "language": {"code": "en", "name": "English"},
216 "name": "Bahamas",
217 "unicode": "U+1F1E7 U+1F1F8",
218 },
219 {
220 "alpha2": "BT",
221 "flag": "🇧🇹",
222 "language": {"code": "dz", "name": "Dzongkha"},
223 "name": "Bhutan",
224 "unicode": "U+1F1E7 U+1F1F9",
225 },
226 {
227 "alpha2": "BV",
228 "flag": "🇧🇻",
229 "language": {"code": "no", "name": "Norwegian"},
230 "name": "Bouvet Island",
231 "unicode": "U+1F1E7 U+1F1FC",
232 },
233 {
234 "alpha2": "BW",
235 "flag": "🇧🇼",
236 "language": {"code": "en", "name": "English"},
237 "name": "Botswana",
238 "unicode": "U+1F1E7 U+1F1FC",
239 },
240 {
241 "alpha2": "BY",
242 "flag": "🇧🇾",
243 "language": {"code": "be", "name": "Belarusian"},
244 "name": "Belarus",
245 "unicode": "U+1F1E7 U+1F1FE",
246 },
247 {
248 "alpha2": "BZ",
249 "flag": "🇧🇿",
250 "language": {"code": "en", "name": "English"},
251 "name": "Belize",
252 "unicode": "U+1F1E7 U+1F1FF",
253 },
254 {
255 "alpha2": "CA",
256 "flag": "🇨🇦",
257 "language": {"code": "en", "name": "English"},
258 "name": "Canada",
259 "unicode": "U+1F1E8 U+1F1E6",
260 },
261 {
262 "alpha2": "CC",
263 "flag": "🇨🇨",
264 "language": {"code": "en", "name": "English"},
265 "name": "Cocos (Keeling) Islands",
266 "unicode": "U+1F1E8 U+1F1E8",
267 },
268 {
269 "alpha2": "CD",
270 "flag": "🇨🇩",
271 "language": {"code": "fr", "name": "French"},
272 "name": "Congo (Democratic Republic of the)",
273 "unicode": "U+1F1E8 U+1F1E9",
274 },
275 {
276 "alpha2": "CF",
277 "flag": "🇨🇫",
278 "language": {"code": "fr", "name": "French"},
279 "name": "Central African Republic",
280 "unicode": "U+1F1E8 U+1F1EB",
281 },
282 {
283 "alpha2": "CG",
284 "flag": "🇨🇬",
285 "language": {"code": "fr", "name": "French"},
286 "name": "Congo",
287 "unicode": "U+1F1E8 U+1F1EC",
288 },
289 {
290 "alpha2": "CH",
291 "flag": "🇨🇭",
292 "language": {"code": "de", "name": "German"},
293 "name": "Switzerland",
294 "unicode": "U+1F1E8 U+1F1ED",
295 },
296 {
297 "alpha2": "CI",
298 "flag": "🇨🇮",
299 "language": {"code": "fr", "name": "French"},
300 "name": "Côte d'Ivoire",
301 "unicode": "U+1F1E8 U+1F1EE",
302 },
303 {
304 "alpha2": "CK",
305 "flag": "🇨🇰",
306 "language": {"code": "en", "name": "English"},
307 "name": "Cook Islands",
308 "unicode": "U+1F1E8 U+1F1F0",
309 },
310 {
311 "alpha2": "CL",
312 "flag": "🇨🇱",
313 "language": {
314 "code": "es",
315 "iso639_2": "spa",
316 "name": "Spanish",
317 "nativeName": "Español",
318 },
319 "name": "Chile",
320 "unicode": "U+1F1E8 U+1F1F1",
321 },
322 {
323 "alpha2": "CM",
324 "flag": "🇨🇲",
325 "language": {"code": "en", "name": "English"},
326 "name": "Cameroon",
327 "unicode": "U+1F1E8 U+1F1F2",
328 },
329 {
330 "alpha2": "CN",
331 "flag": "🇨🇳",
332 "language": {"code": "zh", "name": "Chinese"},
333 "name": "China",
334 "unicode": "U+1F1E8 U+1F1F3",
335 },
336 {
337 "alpha2": "CO",
338 "flag": "🇨🇴",
339 "language": {"code": "es", "name": "Spanish"},
340 "name": "Colombia",
341 "unicode": "U+1F1E8 U+1F1F4",
342 },
343 {
344 "alpha2": "CR",
345 "flag": "🇨🇷",
346 "language": {"code": "es", "name": "Spanish"},
347 "name": "Costa Rica",
348 "unicode": "U+1F1E8 U+1F1F7",
349 },
350 {
351 "alpha2": "CU",
352 "flag": "🇨🇺",
353 "language": {"code": "es", "name": "Spanish"},
354 "name": "Cuba",
355 "unicode": "U+1F1E8 U+1F1FA",
356 },
357 {
358 "alpha2": "CV",
359 "flag": "🇨🇻",
360 "language": {
361 "code": "pt",
362 "iso639_2": "por",
363 "name": "Portuguese",
364 "nativeName": "Português",
365 },
366 "name": "Cabo Verde",
367 "unicode": "U+1F1E8 U+1F1FB",
368 },
369 {
370 "alpha2": "CW",
371 "flag": "🇨🇼",
372 "language": {"code": "nl", "name": "Dutch"},
373 "name": "Curaçao",
374 "unicode": "U+1F1E8 U+1F1FC",
375 },
376 {
377 "alpha2": "CX",
378 "flag": "🇨🇽",
379 "language": {"code": "en", "name": "English"},
380 "name": "Christmas Island",
381 "unicode": "U+1F1E8 U+1F1FD",
382 },
383 {
384 "alpha2": "CY",
385 "flag": "🇨🇾",
386 "language": {"code": "tr", "name": "Turkish"},
387 "name": "Cyprus",
388 "unicode": "U+1F1E8 U+1F1FE",
389 },
390 {
391 "alpha2": "CZ",
392 "flag": "🇨🇿",
393 "language": {"code": "cs", "name": "Czech"},
394 "name": "Czech Republic",
395 "unicode": "U+1F1E8 U+1F1FF",
396 },
397 {
398 "alpha2": "DE",
399 "flag": "🇩🇪",
400 "language": {"code": "de", "name": "German"},
401 "name": "Germany",
402 "unicode": "U+1F1E9 U+1F1EA",
403 },
404 {
405 "alpha2": "DJ",
406 "flag": "🇩🇯",
407 "language": {"code": "fr", "name": "French"},
408 "name": "Djibouti",
409 "unicode": "U+1F1E9 U+1F1EF",
410 },
411 {
412 "alpha2": "DK",
413 "flag": "🇩🇰",
414 "language": {"code": "da", "name": "Danish"},
415 "name": "Denmark",
416 "unicode": "U+1F1E9 U+1F1F0",
417 },
418 {
419 "alpha2": "DM",
420 "flag": "🇩🇲",
421 "language": {"code": "en", "name": "English"},
422 "name": "Dominica",
423 "unicode": "U+1F1E9 U+1F1F2",
424 },
425 {
426 "alpha2": "DO",
427 "flag": "🇩🇴",
428 "language": {"code": "es", "name": "Spanish"},
429 "name": "Dominican Republic",
430 "unicode": "U+1F1E9 U+1F1F4",
431 },
432 {
433 "alpha2": "DZ",
434 "flag": "🇩🇿",
435 "language": {"code": "ar", "name": "Arabic"},
436 "name": "Algeria",
437 "unicode": "U+1F1E9 U+1F1FF",
438 },
439 {
440 "alpha2": "EC",
441 "flag": "🇪🇨",
442 "language": {"code": "es", "name": "Spanish"},
443 "name": "Ecuador",
444 "unicode": "U+1F1EA U+1F1E8",
445 },
446 {
447 "alpha2": "EE",
448 "flag": "🇪🇪",
449 "language": {"code": "et", "name": "Estonian"},
450 "name": "Estonia",
451 "unicode": "U+1F1EA U+1F1EA",
452 },
453 {
454 "alpha2": "EG",
455 "flag": "🇪🇬",
456 "language": {"code": "ar", "name": "Arabic"},
457 "name": "Egypt",
458 "unicode": "U+1F1EA U+1F1EC",
459 },
460 {
461 "alpha2": "ER",
462 "flag": "🇪🇷",
463 "language": {"code": "en", "name": "English"},
464 "name": "Eritrea",
465 "unicode": "U+1F1EA U+1F1F7",
466 },
467 {
468 "alpha2": "ES",
469 "flag": "🇪🇸",
470 "language": {"code": "es", "name": "Spanish"},
471 "name": "Spain",
472 "unicode": "U+1F1EA U+1F1F8",
473 },
474 {
475 "alpha2": "ET",
476 "flag": "🇪🇹",
477 "language": {"code": "am", "name": "Amharic"},
478 "name": "Ethiopia",
479 "unicode": "U+1F1EA U+1F1F9",
480 },
481 {
482 "alpha2": "FI",
483 "flag": "🇫🇮",
484 "language": {
485 "code": "fi",
486 "iso639_2": "fin",
487 "name": "Finnish",
488 "nativeName": "suomi",
489 },
490 "name": "Finland",
491 "unicode": "U+1F1EB U+1F1EE",
492 },
493 {
494 "alpha2": "FJ",
495 "flag": "🇫🇯",
496 "language": {"code": "en", "name": "English"},
497 "name": "Fiji",
498 "unicode": "U+1F1EB U+1F1EF",
499 },
500 {
501 "alpha2": "FK",
502 "flag": "🇫🇰",
503 "language": {"code": "en", "name": "English"},
504 "name": "Falkland Islands (Malvinas)",
505 "unicode": "U+1F1EB U+1F1F0",
506 },
507 {
508 "alpha2": "FM",
509 "flag": "🇫🇲",
510 "language": {"code": "en", "name": "English"},
511 "name": "Micronesia (Federated States of)",
512 "unicode": "U+1F1EB U+1F1F2",
513 },
514 {
515 "alpha2": "FO",
516 "flag": "🇫🇴",
517 "language": {"code": "fo", "name": "Faroese"},
518 "name": "Faroe Islands",
519 "unicode": "U+1F1EB U+1F1F4",
520 },
521 {
522 "alpha2": "FR",
523 "flag": "🇫🇷",
524 "language": {"code": "fr", "name": "French"},
525 "name": "France",
526 "unicode": "U+1F1EB U+1F1F7",
527 },
528 {
529 "alpha2": "GA",
530 "flag": "🇬🇦",
531 "language": {"code": "fr", "name": "French"},
532 "name": "Gabon",
533 "unicode": "U+1F1EC U+1F1E6",
534 },
535 {
536 "alpha2": "GB",
537 "flag": "🇬🇧",
538 "language": {"code": "en", "name": "English"},
539 "name": "United Kingdom of Great Britain and Northern Ireland",
540 "unicode": "U+1F1EC U+1F1E7",
541 },
542 {
543 "alpha2": "GD",
544 "flag": "🇬🇩",
545 "language": {"code": "en", "name": "English"},
546 "name": "Grenada",
547 "unicode": "U+1F1EC U+1F1E9",
548 },
549 {
550 "alpha2": "GE",
551 "flag": "🇬🇪",
552 "language": {"code": "ka", "name": "Georgian"},
553 "name": "Georgia",
554 "unicode": "U+1F1EC U+1F1EA",
555 },
556 {
557 "alpha2": "GF",
558 "flag": "🇬🇫",
559 "language": {"code": "fr", "name": "French"},
560 "name": "French Guiana",
561 "unicode": "U+1F1EC U+1F1EB",
562 },
563 {
564 "alpha2": "GG",
565 "flag": "🇬🇬",
566 "language": {"code": "en", "name": "English"},
567 "name": "Guernsey",
568 "unicode": "U+1F1EC U+1F1EC",
569 },
570 {
571 "alpha2": "GH",
572 "flag": "🇬🇭",
573 "language": {"code": "en", "name": "English"},
574 "name": "Ghana",
575 "unicode": "U+1F1EC U+1F1ED",
576 },
577 {
578 "alpha2": "GI",
579 "flag": "🇬🇮",
580 "language": {"code": "en", "name": "English"},
581 "name": "Gibraltar",
582 "unicode": "U+1F1EC U+1F1EE",
583 },
584 {
585 "alpha2": "GL",
586 "flag": "🇬🇱",
587 "language": {"code": "kl", "name": "Kalaallisut"},
588 "name": "Greenland",
589 "unicode": "U+1F1EC U+1F1F1",
590 },
591 {
592 "alpha2": "GM",
593 "flag": "🇬🇲",
594 "language": {"code": "en", "name": "English"},
595 "name": "Gambia",
596 "unicode": "U+1F1EC U+1F1F2",
597 },
598 {
599 "alpha2": "GN",
600 "flag": "🇬🇳",
601 "language": {"code": "fr", "name": "French"},
602 "name": "Guinea",
603 "unicode": "U+1F1EC U+1F1F3",
604 },
605 {
606 "alpha2": "GP",
607 "flag": "🇬🇵",
608 "language": {"code": "fr", "name": "French"},
609 "name": "Guadeloupe",
610 "unicode": "U+1F1EC U+1F1F5",
611 },
612 {
613 "alpha2": "GQ",
614 "flag": "🇬🇶",
615 "language": {
616 "code": "es",
617 "iso639_2": "spa",
618 "name": "Spanish",
619 "nativeName": "Español",
620 },
621 "name": "Equatorial Guinea",
622 "unicode": "U+1F1EC U+1F1F6",
623 },
624 {
625 "alpha2": "GR",
626 "flag": "🇬🇷",
627 "language": {"code": "el", "name": "Greek (modern)"},
628 "name": "Greece",
629 "unicode": "U+1F1EC U+1F1F7",
630 },
631 {
632 "alpha2": "GT",
633 "flag": "🇬🇹",
634 "language": {"code": "es", "name": "Spanish"},
635 "name": "Guatemala",
636 "unicode": "U+1F1EC U+1F1F9",
637 },
638 {
639 "alpha2": "GU",
640 "flag": "🇬🇺",
641 "language": {"code": "en", "name": "English"},
642 "name": "Guam",
643 "unicode": "U+1F1EC U+1F1FA",
644 },
645 {
646 "alpha2": "GW",
647 "flag": "🇬🇼",
648 "language": {"code": "pt", "name": "Portuguese"},
649 "name": "Guinea-Bissau",
650 "unicode": "U+1F1EC U+1F1FC",
651 },
652 {
653 "alpha2": "GY",
654 "flag": "🇬🇾",
655 "language": {"code": "en", "name": "English"},
656 "name": "Guyana",
657 "unicode": "U+1F1EC U+1F1FE",
658 },
659 {
660 "alpha2": "HK",
661 "flag": "🇭🇰",
662 "language": {"code": "en", "name": "English"},
663 "name": "Hong Kong",
664 "unicode": "U+1F1ED U+1F1F0",
665 },
666 {
667 "alpha2": "HN",
668 "flag": "🇭🇳",
669 "language": {"code": "es", "name": "Spanish"},
670 "name": "Honduras",
671 "unicode": "U+1F1ED U+1F1F3",
672 },
673 {
674 "alpha2": "HR",
675 "flag": "🇭🇷",
676 "language": {"code": "hr", "name": "Croatian"},
677 "name": "Croatia",
678 "unicode": "U+1F1ED U+1F1F7",
679 },
680 {
681 "alpha2": "HT",
682 "flag": "🇭🇹",
683 "language": {"code": "fr", "name": "French"},
684 "name": "Haiti",
685 "unicode": "U+1F1ED U+1F1F9",
686 },
687 {
688 "alpha2": "HU",
689 "flag": "🇭🇺",
690 "language": {"code": "hu", "name": "Hungarian"},
691 "name": "Hungary",
692 "unicode": "U+1F1ED U+1F1FA",
693 },
694 {
695 "alpha2": "ID",
696 "flag": "🇮🇩",
697 "language": {"code": "id", "name": "Indonesian"},
698 "name": "Indonesia",
699 "unicode": "U+1F1EE U+1F1E9",
700 },
701 {
702 "alpha2": "IE",
703 "flag": "🇮🇪",
704 "language": {"code": "ga", "name": "Irish"},
705 "name": "Ireland",
706 "unicode": "U+1F1EE U+1F1EA",
707 },
708 {
709 "alpha2": "IL",
710 "flag": "🇮🇱",
711 "language": {"code": "he", "name": "Hebrew (modern)"},
712 "name": "Israel",
713 "unicode": "U+1F1EE U+1F1F1",
714 },
715 {
716 "alpha2": "IM",
717 "flag": "🇮🇲",
718 "language": {"code": "en", "name": "English"},
719 "name": "Isle of Man",
720 "unicode": "U+1F1EE U+1F1F2",
721 },
722 {
723 "alpha2": "IN",
724 "flag": "🇮🇳",
725 "language": {"code": "hi", "name": "Hindi"},
726 "name": "India",
727 "unicode": "U+1F1EE U+1F1F3",
728 },
729 {
730 "alpha2": "IO",
731 "flag": "🇮🇴",
732 "language": {"code": "en", "name": "English"},
733 "name": "British Indian Ocean Territory",
734 "unicode": "U+1F1EE U+1F1F4",
735 },
736 {
737 "alpha2": "IQ",
738 "flag": "🇮🇶",
739 "language": {"code": "ar", "name": "Arabic"},
740 "name": "Iraq",
741 "unicode": "U+1F1EE U+1F1F6",
742 },
743 {
744 "alpha2": "IR",
745 "flag": "🇮🇷",
746 "language": {"code": "fa", "name": "Persian (Farsi)"},
747 "name": "Iran (Islamic Republic of)",
748 "unicode": "U+1F1EE U+1F1F7",
749 },
750 {
751 "alpha2": "IS",
752 "flag": "🇮🇸",
753 "language": {"code": "is", "name": "Icelandic"},
754 "name": "Iceland",
755 "unicode": "U+1F1EE U+1F1F8",
756 },
757 {
758 "alpha2": "IT",
759 "flag": "🇮🇹",
760 "language": {"code": "it", "name": "Italian"},
761 "name": "Italy",
762 "unicode": "U+1F1EE U+1F1F9",
763 },
764 {
765 "alpha2": "JE",
766 "flag": "🇯🇪",
767 "language": {"code": "en", "name": "English"},
768 "name": "Jersey",
769 "unicode": "U+1F1EF U+1F1EA",
770 },
771 {
772 "alpha2": "JM",
773 "flag": "🇯🇲",
774 "language": {"code": "en", "name": "English"},
775 "name": "Jamaica",
776 "unicode": "U+1F1EF U+1F1F2",
777 },
778 {
779 "alpha2": "JO",
780 "flag": "🇯🇴",
781 "language": {"code": "ar", "name": "Arabic"},
782 "name": "Jordan",
783 "unicode": "U+1F1EF U+1F1F4",
784 },
785 {
786 "alpha2": "JP",
787 "flag": "🇯🇵",
788 "language": {"code": "ja", "name": "Japanese"},
789 "name": "Japan",
790 "unicode": "U+1F1EF U+1F1F5",
791 },
792 {
793 "alpha2": "KE",
794 "flag": "🇰🇪",
795 "language": {"code": "en", "name": "English"},
796 "name": "Kenya",
797 "unicode": "U+1F1F0 U+1F1EA",
798 },
799 {
800 "alpha2": "KG",
801 "flag": "🇰🇬",
802 "language": {"code": "ky", "name": "Kyrgyz"},
803 "name": "Kyrgyzstan",
804 "unicode": "U+1F1F0 U+1F1EC",
805 },
806 {
807 "alpha2": "KH",
808 "flag": "🇰🇭",
809 "language": {"code": "km", "name": "Khmer"},
810 "name": "Cambodia",
811 "unicode": "U+1F1F0 U+1F1ED",
812 },
813 {
814 "alpha2": "KI",
815 "flag": "🇰🇮",
816 "language": {"code": "en", "name": "English"},
817 "name": "Kiribati",
818 "unicode": "U+1F1F0 U+1F1EE",
819 },
820 {
821 "alpha2": "KM",
822 "flag": "🇰🇲",
823 "language": {"code": "fr", "name": "French"},
824 "name": "Comoros",
825 "unicode": "U+1F1F0 U+1F1F2",
826 },
827 {
828 "alpha2": "KN",
829 "flag": "🇰🇳",
830 "language": {"code": "en", "name": "English"},
831 "name": "Saint Kitts and Nevis",
832 "unicode": "U+1F1F0 U+1F1F3",
833 },
834 {
835 "alpha2": "KP",
836 "flag": "🇰🇵",
837 "language": {"code": "ko", "name": "Korean"},
838 "name": "Korea (Democratic People's Republic of)",
839 "unicode": "U+1F1F0 U+1F1F5",
840 },
841 {
842 "alpha2": "KR",
843 "flag": "🇰🇷",
844 "language": {"code": "ko", "name": "Korean"},
845 "name": "Korea (Republic of)",
846 "unicode": "U+1F1F0 U+1F1F7",
847 },
848 {
849 "alpha2": "KW",
850 "flag": "🇰🇼",
851 "language": {"code": "ar", "name": "Arabic"},
852 "name": "Kuwait",
853 "unicode": "U+1F1F0 U+1F1FC",
854 },
855 {
856 "alpha2": "KY",
857 "flag": "🇰🇾",
858 "language": {"code": "en", "name": "English"},
859 "name": "Cayman Islands",
860 "unicode": "U+1F1F0 U+1F1FE",
861 },
862 {
863 "alpha2": "KZ",
864 "flag": "🇰🇿",
865 "language": {"code": "kk", "name": "Kazakh"},
866 "name": "Kazakhstan",
867 "unicode": "U+1F1F0 U+1F1FF",
868 },
869 {
870 "alpha2": "LA",
871 "flag": "🇱🇦",
872 "language": {"code": "lo", "name": "Lao"},
873 "name": "Lao People's Democratic Republic",
874 "unicode": "U+1F1F1 U+1F1E6",
875 },
876 {
877 "alpha2": "LB",
878 "flag": "🇱🇧",
879 "language": {"code": "ar", "name": "Arabic"},
880 "name": "Lebanon",
881 "unicode": "U+1F1F1 U+1F1E7",
882 },
883 {
884 "alpha2": "LC",
885 "flag": "🇱🇨",
886 "language": {"code": "en", "name": "English"},
887 "name": "Saint Lucia",
888 "unicode": "U+1F1F1 U+1F1E8",
889 },
890 {
891 "alpha2": "LI",
892 "flag": "🇱🇮",
893 "language": {"code": "de", "name": "German"},
894 "name": "Liechtenstein",
895 "unicode": "U+1F1F1 U+1F1EE",
896 },
897 {
898 "alpha2": "LK",
899 "flag": "🇱🇰",
900 "language": {
901 "code": "si",
902 "iso639_2": "sin",
903 "name": "Sinhalese",
904 "nativeName": "සිංහල",
905 },
906 "name": "Sri Lanka",
907 "unicode": "U+1F1F1 U+1F1F0",
908 },
909 {
910 "alpha2": "LR",
911 "flag": "🇱🇷",
912 "language": {"code": "en", "name": "English"},
913 "name": "Liberia",
914 "unicode": "U+1F1F1 U+1F1F7",
915 },
916 {
917 "alpha2": "LS",
918 "flag": "🇱🇸",
919 "language": {"code": "en", "name": "English"},
920 "name": "Lesotho",
921 "unicode": "U+1F1F1 U+1F1F8",
922 },
923 {
924 "alpha2": "LT",
925 "flag": "🇱🇹",
926 "language": {"code": "lt", "name": "Lithuanian"},
927 "name": "Lithuania",
928 "unicode": "U+1F1F1 U+1F1F9",
929 },
930 {
931 "alpha2": "LU",
932 "flag": "🇱🇺",
933 "language": {"code": "fr", "name": "French"},
934 "name": "Luxembourg",
935 "unicode": "U+1F1F1 U+1F1FA",
936 },
937 {
938 "alpha2": "LV",
939 "flag": "🇱🇻",
940 "language": {"code": "lv", "name": "Latvian"},
941 "name": "Latvia",
942 "unicode": "U+1F1F1 U+1F1FB",
943 },
944 {
945 "alpha2": "LY",
946 "flag": "🇱🇾",
947 "language": {"code": "ar", "name": "Arabic"},
948 "name": "Libya",
949 "unicode": "U+1F1F1 U+1F1FE",
950 },
951 {
952 "alpha2": "MA",
953 "flag": "🇲🇦",
954 "language": {"code": "ar", "name": "Arabic"},
955 "name": "Morocco",
956 "unicode": "U+1F1F2 U+1F1E6",
957 },
958 {
959 "alpha2": "MC",
960 "flag": "🇲🇨",
961 "language": {"code": "fr", "name": "French"},
962 "name": "Monaco",
963 "unicode": "U+1F1F2 U+1F1E8",
964 },
965 {
966 "alpha2": "MD",
967 "flag": "🇲🇩",
968 "language": {"code": "ro", "name": "Romanian"},
969 "name": "Moldova (Republic of)",
970 "unicode": "U+1F1F2 U+1F1E9",
971 },
972 {
973 "alpha2": "ME",
974 "flag": "🇲🇪",
975 "language": {"code": "sr", "name": "Serbian"},
976 "name": "Montenegro",
977 "unicode": "U+1F1F2 U+1F1EA",
978 },
979 {
980 "alpha2": "MF",
981 "flag": "🇲🇫",
982 "language": {"code": "en", "name": "English"},
983 "name": "Saint Martin (French part)",
984 "unicode": "U+1F1F2 U+1F1EB",
985 },
986 {
987 "alpha2": "MG",
988 "flag": "🇲🇬",
989 "language": {"code": "fr", "name": "French"},
990 "name": "Madagascar",
991 "unicode": "U+1F1F2 U+1F1EC",
992 },
993 {
994 "alpha2": "MH",
995 "flag": "🇲🇭",
996 "language": {"code": "en", "name": "English"},
997 "name": "Marshall Islands",
998 "unicode": "U+1F1F2 U+1F1ED",
999 },
1000 {
1001 "alpha2": "MK",
1002 "flag": "🇲🇰",
1003 "language": {"code": "mk", "name": "Macedonian"},
1004 "name": "Macedonia (the former Yugoslav Republic of)",
1005 "unicode": "U+1F1F2 U+1F1F0",
1006 },
1007 {
1008 "alpha2": "ML",
1009 "flag": "🇲🇱",
1010 "language": {"code": "fr", "name": "French"},
1011 "name": "Mali",
1012 "unicode": "U+1F1F2 U+1F1F1",
1013 },
1014 {
1015 "alpha2": "MM",
1016 "flag": "🇲🇲",
1017 "language": {"code": "my", "name": "Burmese"},
1018 "name": "Myanmar",
1019 "unicode": "U+1F1F2 U+1F1F2",
1020 },
1021 {
1022 "alpha2": "MN",
1023 "flag": "🇲🇳",
1024 "language": {"code": "mn", "name": "Mongolian"},
1025 "name": "Mongolia",
1026 "unicode": "U+1F1F2 U+1F1F3",
1027 },
1028 {
1029 "alpha2": "MO",
1030 "flag": "🇲🇴",
1031 "language": {"code": "zh", "name": "Chinese"},
1032 "name": "Macao",
1033 "unicode": "U+1F1F2 U+1F1F4",
1034 },
1035 {
1036 "alpha2": "MP",
1037 "flag": "🇲🇵",
1038 "language": {"code": "en", "name": "English"},
1039 "name": "Northern Mariana Islands",
1040 "unicode": "U+1F1F2 U+1F1F5",
1041 },
1042 {
1043 "alpha2": "MQ",
1044 "flag": "🇲🇶",
1045 "language": {"code": "fr", "name": "French"},
1046 "name": "Martinique",
1047 "unicode": "U+1F1F2 U+1F1F6",
1048 },
1049 {
1050 "alpha2": "MR",
1051 "flag": "🇲🇷",
1052 "language": {"code": "ar", "name": "Arabic"},
1053 "name": "Mauritania",
1054 "unicode": "U+1F1F2 U+1F1F7",
1055 },
1056 {
1057 "alpha2": "MS",
1058 "flag": "🇲🇸",
1059 "language": {"code": "en", "name": "English"},
1060 "name": "Montserrat",
1061 "unicode": "U+1F1F2 U+1F1F8",
1062 },
1063 {
1064 "alpha2": "MT",
1065 "flag": "🇲🇹",
1066 "language": {"code": "mt", "name": "Maltese"},
1067 "name": "Malta",
1068 "unicode": "U+1F1F2 U+1F1F9",
1069 },
1070 {
1071 "alpha2": "MU",
1072 "flag": "🇲🇺",
1073 "language": {"code": "en", "name": "English"},
1074 "name": "Mauritius",
1075 "unicode": "U+1F1F2 U+1F1FA",
1076 },
1077 {
1078 "alpha2": "MV",
1079 "flag": "🇲🇻",
1080 "language": {"code": "dv", "name": "Divehi"},
1081 "name": "Maldives",
1082 "unicode": "U+1F1F2 U+1F1FB",
1083 },
1084 {
1085 "alpha2": "MW",
1086 "flag": "🇲🇼",
1087 "language": {"code": "en", "name": "English"},
1088 "name": "Malawi",
1089 "unicode": "U+1F1F2 U+1F1FC",
1090 },
1091 {
1092 "alpha2": "MX",
1093 "flag": "🇲🇽",
1094 "language": {"code": "es", "name": "Spanish"},
1095 "name": "Mexico",
1096 "unicode": "U+1F1F2 U+1F1FD",
1097 },
1098 {
1099 "alpha2": "MY",
1100 "flag": "🇲🇾",
1101 "language": {"code": None, "name": "Malaysian"},
1102 "name": "Malaysia",
1103 "unicode": "U+1F1F2 U+1F1FE",
1104 },
1105 {
1106 "alpha2": "MZ",
1107 "flag": "🇲🇿",
1108 "language": {"code": "pt", "name": "Portuguese"},
1109 "name": "Mozambique",
1110 "unicode": "U+1F1F2 U+1F1FF",
1111 },
1112 {
1113 "alpha2": "NA",
1114 "flag": "🇳🇦",
1115 "language": {"code": "en", "name": "English"},
1116 "name": "Namibia",
1117 "unicode": "U+1F1F3 U+1F1E6",
1118 },
1119 {
1120 "alpha2": "NC",
1121 "flag": "🇳🇨",
1122 "language": {"code": "fr", "name": "French"},
1123 "name": "New Caledonia",
1124 "unicode": "U+1F1F3 U+1F1E8",
1125 },
1126 {
1127 "alpha2": "NE",
1128 "flag": "🇳🇪",
1129 "language": {"code": "fr", "name": "French"},
1130 "name": "Niger",
1131 "unicode": "U+1F1F3 U+1F1EA",
1132 },
1133 {
1134 "alpha2": "NF",
1135 "flag": "🇳🇫",
1136 "language": {"code": "en", "name": "English"},
1137 "name": "Norfolk Island",
1138 "unicode": "U+1F1F3 U+1F1EB",
1139 },
1140 {
1141 "alpha2": "NG",
1142 "flag": "🇳🇬",
1143 "language": {"code": "en", "name": "English"},
1144 "name": "Nigeria",
1145 "unicode": "U+1F1F3 U+1F1EC",
1146 },
1147 {
1148 "alpha2": "NI",
1149 "flag": "🇳🇮",
1150 "language": {"code": "es", "name": "Spanish"},
1151 "name": "Nicaragua",
1152 "unicode": "U+1F1F3 U+1F1EE",
1153 },
1154 {
1155 "alpha2": "NL",
1156 "flag": "🇳🇱",
1157 "language": {"code": "nl", "name": "Dutch"},
1158 "name": "Netherlands",
1159 "unicode": "U+1F1F3 U+1F1F1",
1160 },
1161 {
1162 "alpha2": "NO",
1163 "flag": "🇳🇴",
1164 "language": {"code": "no", "name": "Norwegian"},
1165 "name": "Norway",
1166 "unicode": "U+1F1F3 U+1F1F4",
1167 },
1168 {
1169 "alpha2": "NP",
1170 "flag": "🇳🇵",
1171 "language": {"code": "ne", "name": "Nepali"},
1172 "name": "Nepal",
1173 "unicode": "U+1F1F3 U+1F1F5",
1174 },
1175 {
1176 "alpha2": "NR",
1177 "flag": "🇳🇷",
1178 "language": {"code": "en", "name": "English"},
1179 "name": "Nauru",
1180 "unicode": "U+1F1F3 U+1F1F7",
1181 },
1182 {
1183 "alpha2": "NU",
1184 "flag": "🇳🇺",
1185 "language": {"code": "en", "name": "English"},
1186 "name": "Niue",
1187 "unicode": "U+1F1F3 U+1F1FA",
1188 },
1189 {
1190 "alpha2": "NZ",
1191 "flag": "🇳🇿",
1192 "language": {"code": "en", "name": "English"},
1193 "name": "New Zealand",
1194 "unicode": "U+1F1F3 U+1F1FF",
1195 },
1196 {
1197 "alpha2": "OM",
1198 "flag": "🇴🇲",
1199 "language": {"code": "ar", "name": "Arabic"},
1200 "name": "Oman",
1201 "unicode": "U+1F1F4 U+1F1F2",
1202 },
1203 {
1204 "alpha2": "PA",
1205 "flag": "🇵🇦",
1206 "language": {"code": "es", "name": "Spanish"},
1207 "name": "Panama",
1208 "unicode": "U+1F1F5 U+1F1E6",
1209 },
1210 {
1211 "alpha2": "PE",
1212 "flag": "🇵🇪",
1213 "language": {"code": "es", "name": "Spanish"},
1214 "name": "Peru",
1215 "unicode": "U+1F1F5 U+1F1EA",
1216 },
1217 {
1218 "alpha2": "PF",
1219 "flag": "🇵🇫",
1220 "language": {"code": "fr", "name": "French"},
1221 "name": "French Polynesia",
1222 "unicode": "U+1F1F5 U+1F1EB",
1223 },
1224 {
1225 "alpha2": "PG",
1226 "flag": "🇵🇬",
1227 "language": {"code": "en", "name": "English"},
1228 "name": "Papua New Guinea",
1229 "unicode": "U+1F1F5 U+1F1EC",
1230 },
1231 {
1232 "alpha2": "PH",
1233 "flag": "🇵🇭",
1234 "language": {"code": "en", "name": "English"},
1235 "name": "Philippines",
1236 "unicode": "U+1F1F5 U+1F1ED",
1237 },
1238 {
1239 "alpha2": "PK",
1240 "flag": "🇵🇰",
1241 "language": {"code": "en", "name": "English"},
1242 "name": "Pakistan",
1243 "unicode": "U+1F1F5 U+1F1F0",
1244 },
1245 {
1246 "alpha2": "PL",
1247 "flag": "🇵🇱",
1248 "language": {"code": "pl", "name": "Polish"},
1249 "name": "Poland",
1250 "unicode": "U+1F1F5 U+1F1F1",
1251 },
1252 {
1253 "alpha2": "PM",
1254 "flag": "🇵🇲",
1255 "language": {"code": "fr", "name": "French"},
1256 "name": "Saint Pierre and Miquelon",
1257 "unicode": "U+1F1F5 U+1F1F2",
1258 },
1259 {
1260 "alpha2": "PR",
1261 "flag": "🇵🇷",
1262 "language": {"code": "es", "name": "Spanish"},
1263 "name": "Puerto Rico",
1264 "unicode": "U+1F1F5 U+1F1F7",
1265 },
1266 {
1267 "alpha2": "PS",
1268 "flag": "🇵🇸",
1269 "language": {"code": "ar", "name": "Arabic"},
1270 "name": "Palestine, State of",
1271 "unicode": "U+1F1F5 U+1F1F8",
1272 },
1273 {
1274 "alpha2": "PT",
1275 "flag": "🇵🇹",
1276 "language": {"code": "pt", "name": "Portuguese"},
1277 "name": "Portugal",
1278 "unicode": "U+1F1F5 U+1F1F9",
1279 },
1280 {
1281 "alpha2": "PW",
1282 "flag": "🇵🇼",
1283 "language": {"code": "en", "name": "English"},
1284 "name": "Palau",
1285 "unicode": "U+1F1F5 U+1F1FC",
1286 },
1287 {
1288 "alpha2": "PY",
1289 "flag": "🇵🇾",
1290 "language": {"code": "es", "name": "Spanish"},
1291 "name": "Paraguay",
1292 "unicode": "U+1F1F5 U+1F1FE",
1293 },
1294 {
1295 "alpha2": "QA",
1296 "flag": "🇶🇦",
1297 "language": {"code": "ar", "name": "Arabic"},
1298 "name": "Qatar",
1299 "unicode": "U+1F1F6 U+1F1E6",
1300 },
1301 {
1302 "alpha2": "RE",
1303 "flag": "🇷🇪",
1304 "language": {"code": "fr", "name": "French"},
1305 "name": "Réunion",
1306 "unicode": "U+1F1F7 U+1F1EA",
1307 },
1308 {
1309 "alpha2": "RO",
1310 "flag": "🇷🇴",
1311 "language": {"code": "ro", "name": "Romanian"},
1312 "name": "Romania",
1313 "unicode": "U+1F1F7 U+1F1F4",
1314 },
1315 {
1316 "alpha2": "RS",
1317 "flag": "🇷🇸",
1318 "language": {"code": "sr", "name": "Serbian"},
1319 "name": "Serbia",
1320 "unicode": "U+1F1F7 U+1F1F8",
1321 },
1322 {
1323 "alpha2": "RU",
1324 "flag": "🇷🇺",
1325 "language": {"code": "ru", "name": "Russian"},
1326 "name": "Russian Federation",
1327 "unicode": "U+1F1F7 U+1F1FA",
1328 },
1329 {
1330 "alpha2": "RW",
1331 "flag": "🇷🇼",
1332 "language": {"code": "rw", "name": "Kinyarwanda"},
1333 "name": "Rwanda",
1334 "unicode": "U+1F1F7 U+1F1FC",
1335 },
1336 {
1337 "alpha2": "SA",
1338 "flag": "🇸🇦",
1339 "language": {"code": "ar", "name": "Arabic"},
1340 "name": "Saudi Arabia",
1341 "unicode": "U+1F1F8 U+1F1E6",
1342 },
1343 {
1344 "alpha2": "SB",
1345 "flag": "🇸🇧",
1346 "language": {"code": "en", "name": "English"},
1347 "name": "Solomon Islands",
1348 "unicode": "U+1F1F8 U+1F1E7",
1349 },
1350 {
1351 "alpha2": "SC",
1352 "flag": "🇸🇨",
1353 "language": {"code": "fr", "name": "French"},
1354 "name": "Seychelles",
1355 "unicode": "U+1F1F8 U+1F1E8",
1356 },
1357 {
1358 "alpha2": "SD",
1359 "flag": "🇸🇩",
1360 "language": {"code": "ar", "name": "Arabic"},
1361 "name": "Sudan",
1362 "unicode": "U+1F1F8 U+1F1E9",
1363 },
1364 {
1365 "alpha2": "SE",
1366 "flag": "🇸🇪",
1367 "language": {"code": "sv", "name": "Swedish"},
1368 "name": "Sweden",
1369 "unicode": "U+1F1F8 U+1F1EA",
1370 },
1371 {
1372 "alpha2": "SG",
1373 "flag": "🇸🇬",
1374 "language": {"code": "en", "name": "English"},
1375 "name": "Singapore",
1376 "unicode": "U+1F1F8 U+1F1EC",
1377 },
1378 {
1379 "alpha2": "SH",
1380 "flag": "🇸🇭",
1381 "language": {"code": "en", "name": "English"},
1382 "name": "Saint Helena, Ascension and Tristan da Cunha",
1383 "unicode": "U+1F1F8 U+1F1ED",
1384 },
1385 {
1386 "alpha2": "SI",
1387 "flag": "🇸🇮",
1388 "language": {"code": "sl", "name": "Slovene"},
1389 "name": "Slovenia",
1390 "unicode": "U+1F1F8 U+1F1EE",
1391 },
1392 {
1393 "alpha2": "SK",
1394 "flag": "🇸🇰",
1395 "language": {"code": "sk", "name": "Slovak"},
1396 "name": "Slovakia",
1397 "unicode": "U+1F1F8 U+1F1F0",
1398 },
1399 {
1400 "alpha2": "SL",
1401 "flag": "🇸🇱",
1402 "language": {"code": "en", "name": "English"},
1403 "name": "Sierra Leone",
1404 "unicode": "U+1F1F8 U+1F1F1",
1405 },
1406 {
1407 "alpha2": "SM",
1408 "flag": "🇸🇲",
1409 "language": {"code": "it", "name": "Italian"},
1410 "name": "San Marino",
1411 "unicode": "U+1F1F8 U+1F1F2",
1412 },
1413 {
1414 "alpha2": "SN",
1415 "flag": "🇸🇳",
1416 "language": {"code": "fr", "name": "French"},
1417 "name": "Senegal",
1418 "unicode": "U+1F1F8 U+1F1F3",
1419 },
1420 {
1421 "alpha2": "SO",
1422 "flag": "🇸🇴",
1423 "language": {"code": "ar", "name": "Arabic"},
1424 "name": "Somalia",
1425 "unicode": "U+1F1F8 U+1F1F4",
1426 },
1427 {
1428 "alpha2": "SR",
1429 "flag": "🇸🇷",
1430 "language": {"code": "nl", "name": "Dutch"},
1431 "name": "Suriname",
1432 "unicode": "U+1F1F8 U+1F1F7",
1433 },
1434 {
1435 "alpha2": "SS",
1436 "flag": "🇸🇸",
1437 "language": {"code": "ar", "name": "Arabic"},
1438 "name": "South Sudan",
1439 "unicode": "U+1F1F8 U+1F1F8",
1440 },
1441 {
1442 "alpha2": "ST",
1443 "flag": "🇸🇹",
1444 "language": {"code": "pt", "name": "Portuguese"},
1445 "name": "Sao Tome and Principe",
1446 "unicode": "U+1F1F8 U+1F1F9",
1447 },
1448 {
1449 "alpha2": "SV",
1450 "flag": "🇸🇻",
1451 "language": {"code": "es", "name": "Spanish"},
1452 "name": "El Salvador",
1453 "unicode": "U+1F1F8 U+1F1FB",
1454 },
1455 {
1456 "alpha2": "SY",
1457 "flag": "🇸🇾",
1458 "language": {"code": "ar", "name": "Arabic"},
1459 "name": "Syrian Arab Republic",
1460 "unicode": "U+1F1F8 U+1F1FE",
1461 },
1462 {
1463 "alpha2": "SZ",
1464 "flag": "🇸🇿",
1465 "language": {"code": "en", "name": "English"},
1466 "name": "Swaziland",
1467 "unicode": "U+1F1F8 U+1F1FF",
1468 },
1469 {
1470 "alpha2": "TC",
1471 "flag": "🇹🇨",
1472 "language": {"code": "en", "name": "English"},
1473 "name": "Turks and Caicos Islands",
1474 "unicode": "U+1F1F9 U+1F1E8",
1475 },
1476 {
1477 "alpha2": "TD",
1478 "flag": "🇹🇩",
1479 "language": {"code": "fr", "name": "French"},
1480 "name": "Chad",
1481 "unicode": "U+1F1F9 U+1F1E6",
1482 },
1483 {
1484 "alpha2": "TF",
1485 "flag": "🇹🇫",
1486 "language": {"code": "fr", "name": "French"},
1487 "name": "French Southern Territories",
1488 "unicode": "U+1F1F9 U+1F1EB",
1489 },
1490 {
1491 "alpha2": "TG",
1492 "flag": "🇹🇬",
1493 "language": {"code": "fr", "name": "French"},
1494 "name": "Togo",
1495 "unicode": "U+1F1F9 U+1F1EC",
1496 },
1497 {
1498 "alpha2": "TH",
1499 "flag": "🇹🇭",
1500 "language": {"code": "th", "name": "Thai"},
1501 "name": "Thailand",
1502 "unicode": "U+1F1F9 U+1F1ED",
1503 },
1504 {
1505 "alpha2": "TJ",
1506 "flag": "🇹🇯",
1507 "language": {"code": "tg", "name": "Tajik"},
1508 "name": "Tajikistan",
1509 "unicode": "U+1F1F9 U+1F1EF",
1510 },
1511 {
1512 "alpha2": "TK",
1513 "flag": "🇹🇰",
1514 "language": {"code": "en", "name": "English"},
1515 "name": "Tokelau",
1516 "unicode": "U+1F1F9 U+1F1F0",
1517 },
1518 {
1519 "alpha2": "TL",
1520 "flag": "🇹🇱",
1521 "language": {"code": "pt", "name": "Portuguese"},
1522 "name": "Timor-Leste",
1523 "unicode": "U+1F1F9 U+1F1F1",
1524 },
1525 {
1526 "alpha2": "TM",
1527 "flag": "🇹🇲",
1528 "language": {"code": "tk", "name": "Turkmen"},
1529 "name": "Turkmenistan",
1530 "unicode": "U+1F1F9 U+1F1F2",
1531 },
1532 {
1533 "alpha2": "TN",
1534 "flag": "🇹🇳",
1535 "language": {"code": "ar", "name": "Arabic"},
1536 "name": "Tunisia",
1537 "unicode": "U+1F1F9 U+1F1F3",
1538 },
1539 {
1540 "alpha2": "TO",
1541 "flag": "🇹🇴",
1542 "language": {"code": "en", "name": "English"},
1543 "name": "Tonga",
1544 "unicode": "U+1F1F9 U+1F1F4",
1545 },
1546 {
1547 "alpha2": "TR",
1548 "flag": "🇹🇷",
1549 "language": {"code": "tr", "name": "Turkish"},
1550 "name": "Turkey",
1551 "unicode": "U+1F1F9 U+1F1F7",
1552 },
1553 {
1554 "alpha2": "TT",
1555 "flag": "🇹🇹",
1556 "language": {"code": "en", "name": "English"},
1557 "name": "Trinidad and Tobago",
1558 "unicode": "U+1F1F9 U+1F1F9",
1559 },
1560 {
1561 "alpha2": "TV",
1562 "flag": "🇹🇻",
1563 "language": {"code": "en", "name": "English"},
1564 "name": "Tuvalu",
1565 "unicode": "U+1F1F9 U+1F1FB",
1566 },
1567 {
1568 "alpha2": "TW",
1569 "flag": "🇹🇼",
1570 "language": {"code": "zh", "name": "Chinese"},
1571 "name": "Taiwan",
1572 "unicode": "U+1F1F9 U+1F1FC",
1573 },
1574 {
1575 "alpha2": "TZ",
1576 "flag": "🇹🇿",
1577 "language": {"code": "en", "name": "English"},
1578 "name": "United Republic of Tanzania",
1579 "unicode": "U+1F1F9 U+1F1FF",
1580 },
1581 {
1582 "alpha2": "UA",
1583 "flag": "🇺🇦",
1584 "language": {"code": "uk", "name": "Ukrainian"},
1585 "name": "Ukraine",
1586 "unicode": "U+1F1FA U+1F1E6",
1587 },
1588 {
1589 "alpha2": "UG",
1590 "flag": "🇺🇬",
1591 "language": {"code": "en", "name": "English"},
1592 "name": "Uganda",
1593 "unicode": "U+1F1FA U+1F1EC",
1594 },
1595 {
1596 "alpha2": "US",
1597 "flag": "🇺🇸",
1598 "language": {
1599 "code": "en",
1600 "iso639_2": "eng",
1601 "name": "English",
1602 "nativeName": "English",
1603 },
1604 "name": "United States of America",
1605 "unicode": "U+1F1FA U+1F1F8",
1606 },
1607 {
1608 "alpha2": "UY",
1609 "flag": "🇺🇾",
1610 "language": {"code": "es", "name": "Spanish"},
1611 "name": "Uruguay",
1612 "unicode": "U+1F1FA U+1F1FE",
1613 },
1614 {
1615 "alpha2": "UZ",
1616 "flag": "🇺🇿",
1617 "language": {"code": "uz", "name": "Uzbek"},
1618 "name": "Uzbekistan",
1619 "unicode": "U+1F1FA U+1F1FF",
1620 },
1621 {
1622 "alpha2": "VA",
1623 "flag": "🇻🇦",
1624 "language": {"code": "fr", "name": "French"},
1625 "name": "Holy See",
1626 "unicode": "U+1F1FB U+1F1E6",
1627 },
1628 {
1629 "alpha2": "VC",
1630 "flag": "🇻🇨",
1631 "language": {"code": "en", "name": "English"},
1632 "name": "Saint Vincent and the Grenadines",
1633 "unicode": "U+1F1FB U+1F1E8",
1634 },
1635 {
1636 "alpha2": "VE",
1637 "flag": "🇻🇪",
1638 "language": {"code": "es", "name": "Spanish"},
1639 "name": "Venezuela (Bolivarian Republic of)",
1640 "unicode": "U+1F1FB U+1F1EA",
1641 },
1642 {
1643 "alpha2": "VG",
1644 "flag": "🇻🇬",
1645 "language": {"code": "en", "name": "English"},
1646 "name": "Virgin Islands (British)",
1647 "unicode": "U+1F1FB U+1F1EC",
1648 },
1649 {
1650 "alpha2": "VI",
1651 "flag": "🇻🇮",
1652 "language": {"code": "en", "name": "English"},
1653 "name": "Virgin Islands (U.S.)",
1654 "unicode": "U+1F1FB U+1F1EE",
1655 },
1656 {
1657 "alpha2": "VN",
1658 "flag": "🇻🇳",
1659 "language": {"code": "vi", "name": "Vietnamese"},
1660 "name": "Viet Nam",
1661 "unicode": "U+1F1FB U+1F1F3",
1662 },
1663 {
1664 "alpha2": "VU",
1665 "flag": "🇻🇺",
1666 "language": {"code": "en", "name": "English"},
1667 "name": "Vanuatu",
1668 "unicode": "U+1F1FB U+1F1FA",
1669 },
1670 {
1671 "alpha2": "WF",
1672 "flag": "🇼🇫",
1673 "language": {"code": "fr", "name": "French"},
1674 "name": "Wallis and Futuna",
1675 "unicode": "U+1F1FC U+1F1EB",
1676 },
1677 {
1678 "alpha2": "WS",
1679 "flag": "🇼🇸",
1680 "language": {"code": "sm", "name": "Samoan"},
1681 "name": "Samoa",
1682 "unicode": "U+1F1FC U+1F1F8",
1683 },
1684 {
1685 "alpha2": "XK",
1686 "flag": "🇽🇰",
1687 "language": {"code": "sq", "name": "Albanian"},
1688 "name": "Republic of Kosovo",
1689 "unicode": "U+1F1FD U+1F1F0",
1690 },
1691 {
1692 "alpha2": "YE",
1693 "flag": "🇾🇪",
1694 "language": {"code": "ar", "name": "Arabic"},
1695 "name": "Yemen",
1696 "unicode": "U+1F1FE U+1F1EA",
1697 },
1698 {
1699 "alpha2": "YT",
1700 "flag": "🇾🇹",
1701 "language": {"code": "fr", "name": "French"},
1702 "name": "Mayotte",
1703 "unicode": "U+1F1FE U+1F1F9",
1704 },
1705 {
1706 "alpha2": "ZA",
1707 "flag": "🇿🇦",
1708 "language": {
1709 "code": "en",
1710 "iso639_2": "eng",
1711 "name": "English",
1712 "nativeName": "English",
1713 },
1714 "name": "South Africa",
1715 "unicode": "U+1F1FF U+1F1E6",
1716 },
1717 {
1718 "alpha2": "ZM",
1719 "flag": "🇿🇲",
1720 "language": {"code": "en", "name": "English"},
1721 "name": "Zambia",
1722 "unicode": "U+1F1FF U+1F1F2",
1723 },
1724 {
1725 "alpha2": "ZW",
1726 "flag": "🇿🇼",
1727 "language": {
1728 "code": "en",
1729 "iso639_2": "eng",
1730 "name": "English",
1731 "nativeName": "English",
1732 },
1733 "name": "Zimbabwe",
1734 "unicode": "U+1F1FF U+1F1FC",
1735 },
1736]
12361737
12371738LANGUAGE_NAME_TO_CODE = {
12381739 "English": "en",
……@@ -1255,31 +1756,47 @@ LANGUAGE_NAME_TO_CODE = {
12551756 "Vietnamese": "vi",
12561757 "Chinese": "zh",
12571758 "Persian": "fa",
1258 "None": "None"
1759 "None": "None",
12591760}
12601761
1762
12611763def _check_flag(flag, country):
1262 if country['flag'] == flag: return True
1263 return False
1764 return country["flag"] == flag
1765
12641766
12651767def _return_language(country):
1266 return country['language']['name']
1768 return country["language"]["name"]
1769
12671770
12681771def _flag_is_minor_than(flag, country):
1269 if country['flag'] < flag: return True
1270 return False
1772 return country["flag"] < flag
1773
12711774
12721775def _binary_search(validate_func, return_func, is_minor_func, array, target, low, high):
1273 # array MUST be ordered
1274 if low > high: return None
1275 else:
1276 mid = (low + high)//2
1277 if validate_func(target, array[mid]):
1278 return return_func(array[mid])
1279 elif is_minor_func(target, array[mid]):
1280 return _binary_search(validate_func,return_func,is_minor_func,array,target,mid+1,high)
1281 else:
1282 return _binary_search(validate_func,return_func,is_minor_func,array,target,low,mid-1)
1776 # array MUST be ordered
1777 if low > high:
1778 return None
1779 else:
1780 mid = (low + high) // 2
1781 if validate_func(target, array[mid]):
1782 return return_func(array[mid])
1783 elif is_minor_func(target, array[mid]):
1784 return _binary_search(
1785 validate_func, return_func, is_minor_func, array, target, mid + 1, high
1786 )
1787 else:
1788 return _binary_search(
1789 validate_func, return_func, is_minor_func, array, target, low, mid - 1
1790 )
1791
12831792
12841793def find_language_by_unicode(flag_unicode):
1285 return _binary_search(_check_flag, _return_language, _flag_is_minor_than, _countries, flag_unicode, 0, len(_countries) - 1)
1794 return _binary_search(
1795 _check_flag,
1796 _return_language,
1797 _flag_is_minor_than,
1798 _countries,
1799 flag_unicode,
1800 0,
1801 len(_countries) - 1,
1802 )
plugin.video.jacktook/lib/utils/localization/language_detection.py +3 -4
……@@ -1,6 +1,7 @@
1from lib.utils.localization.countries import find_language_by_unicode
21import re
32
3from lib.utils.localization.countries import find_language_by_unicode
4
45langsSet = {
56 "bosnian",
67 "ro",
……@@ -541,9 +542,7 @@ def extract_unicode_flags(text):
541542
542543def find_languages_in_string(s: str) -> set:
543544 pattern = r"\b(?:" + "|".join(re.escape(word) for word in langsSet) + r")\b"
544 flags = " ".join(
545 [find_language_by_unicode(flag) for flag in extract_unicode_flags(s)]
546 )
545 flags = " ".join([find_language_by_unicode(flag) for flag in extract_unicode_flags(s)])
547546 swf = f"{s} {flags}"
548547 matches = re.findall(pattern, swf.lower())
549548 matches = [language_codes.get(match) for match in matches]
plugin.video.jacktook/lib/utils/parsers/title_parser.py +221 -68
……@@ -1,56 +1,168 @@
11import re
22
3
34def get_color_tag(label, text, color):
45 # Standardized format [B]LABEL: VALUE[/B]
56 clean_color = color.replace("FF", "CC")
67 return f"[B][COLOR CCFFFFFF]{label}[/COLOR] [COLOR {clean_color}]{text}[/COLOR][/B]"
78
9
810def clean_display_title(title):
911 if not title:
1012 return ""
11
13
1214 # Redundant tags to remove (expanded list)
1315 tags_to_remove = [
14 r"\b2160P\b", r"\b1080P\b", r"\b720P\b", r"\b480P\b", r"\b4K\b",
15 r"\bHEVC\b", r"\bH265\b", r"\bH\.265\b", r"\bX265\b",
16 r"\bH264\b", r"\bH\.264\b", r"\bX264\b", r"\bAVC\b",
17 r"\bDV\b", r"\bDOLBY VISION\b", r"\bDOLBYVISION\b",
18 r"\bHDR10\+\b", r"\bHDR10\b", r"\bHDR\b", r"\bHYBRID\b",
19 r"\bREMUX\b", r"\bWEB-DL\b", r"\bWEBDL\b", r"\bWEB\b", r"\bBRRIP\b", r"\bBDRIP\b", r"\bBLURAY\b",
20 r"\b10BIT\b", r"\b8BIT\b", r"\b12BIT\b",
21 r"\.MKV\b", r"\.MP4\b", r"\.AVI\b", r"\.TS\b", r"\.M2TS\b",
22 r"\bIMAX\b", r"\bEXTENDED\b", r"\bREMASTERED\b", r"\bUNRATED\b", r"\bDIRECTORS CUT\b",
23 r"\bPROPER\b", r"\bREPACK\b", r"\bREAL\b",
24 r"\b7\.1\b", r"\b5\.1\b", r"\b2\.0\b", r"\b8CH\b", r"\b6CH\b",
25 r"\bLATINO\b", r"\bCASTELLANO\b", r"\bSPANISH\b", r"\bDUAL\b", r"\bMULTI\b"
16 r"\b2160P\b",
17 r"\b1080P\b",
18 r"\b720P\b",
19 r"\b480P\b",
20 r"\b4K\b",
21 r"\bHEVC\b",
22 r"\bH265\b",
23 r"\bH\.265\b",
24 r"\bX265\b",
25 r"\bH264\b",
26 r"\bH\.264\b",
27 r"\bX264\b",
28 r"\bAVC\b",
29 r"\bAV1\b",
30 r"\bDV\b",
31 r"\bDOLBY VISION\b",
32 r"\bDOLBYVISION\b",
33 r"\bHDR10\+\b",
34 r"\bHDR10\b",
35 r"\bHDR\b",
36 r"\bHYBRID\b",
37 r"\bREMUX\b",
38 r"\bWEB-DL\b",
39 r"\bWEBDL\b",
40 r"\bWEB\b",
41 r"\bBRRIP\b",
42 r"\bBDRIP\b",
43 r"\bBLURAY\b",
44 r"\b10BIT\b",
45 r"\b8BIT\b",
46 r"\b12BIT\b",
47 r"\.MKV\b",
48 r"\.MP4\b",
49 r"\.AVI\b",
50 r"\.TS\b",
51 r"\.M2TS\b",
52 r"\bIMAX\b",
53 r"\bEXTENDED\b",
54 r"\bREMASTERED\b",
55 r"\bUNRATED\b",
56 r"\bDIRECTORS CUT\b",
57 r"\bPROPER\b",
58 r"\bREPACK\b",
59 r"\bREAL\b",
60 r"\b7\.1\b",
61 r"\b5\.1\b",
62 r"\b2\.0\b",
63 r"\b8CH\b",
64 r"\b6CH\b",
65 r"\bLATINO\b",
66 r"\bCASTELLANO\b",
67 r"\bSPANISH\b",
68 r"\bDUAL\b",
69 r"\bMULTI\b",
2670 ]
27
71
2872 clean_title = title
2973 for tag in tags_to_remove:
3074 clean_title = re.sub(tag, "", clean_title, flags=re.IGNORECASE)
31
75
3276 # Clean up extra dots, underscores, and spaces
33 clean_title = clean_title.replace(".", " ").replace("_", " ").replace("[", " ").replace("]", " ").replace("(", " ").replace(")", " ")
77 clean_title = (
78 clean_title.replace(".", " ")
79 .replace("_", " ")
80 .replace("[", " ")
81 .replace("]", " ")
82 .replace("(", " ")
83 .replace(")", " ")
84 )
3485 clean_title = re.sub(r"\s+", " ", clean_title).strip()
35
86
3687 if clean_title:
3788 clean_title = clean_title.capitalize()
38
89
3990 return clean_title
4091
92
93def extract_codec_hdr(title):
94 if not title:
95 return "", ""
96
97 title_upper = (
98 title.upper()
99 .replace(".", " ")
100 .replace("_", " ")
101 .replace("-", " ")
102 .replace("[", " ")
103 .replace("]", " ")
104 )
105
106 codec = ""
107 if any(x in title_upper for x in ["HEVC", "H265", "X265"]):
108 codec = "HEVC"
109 elif any(x in title_upper for x in ["H264", "X264", "AVC"]):
110 codec = "H.264"
111 elif "AV1" in title_upper:
112 codec = "AV1"
113
114 hdr = ""
115 if any(x in title_upper for x in [" DV ", "DOLBY VISION"]):
116 hdr = "DV"
117 elif "HDR10+" in title_upper:
118 hdr = "HDR10+"
119 elif "HDR10" in title_upper:
120 hdr = "HDR10"
121 elif "HDR" in title_upper:
122 hdr = "HDR"
123
124 return codec, hdr
125
126
41127def parse_title_info(title):
42128 if not title:
43 return {"codec": "", "audio": "", "hdr": "", "source": "", "format": "", "edition": "", "note": "", "lang_detail": "", "clean_title": "", "badges": "", "release_group": ""}
44
129 return {
130 "codec": "",
131 "audio": "",
132 "hdr": "",
133 "source": "",
134 "format": "",
135 "edition": "",
136 "note": "",
137 "lang_detail": "",
138 "clean_title": "",
139 "badges": "",
140 "release_group": "",
141 }
142
45143 info = {
46 "codec": "", "audio": "", "hdr": "", "source": "", "format": "",
47 "edition": "", "note": "", "lang_detail": "", "release_group": "",
144 "codec": "",
145 "audio": "",
146 "hdr": "",
147 "source": "",
148 "format": "",
149 "edition": "",
150 "note": "",
151 "lang_detail": "",
152 "release_group": "",
48153 "clean_title": clean_display_title(title),
49 "badges": ""
154 "badges": "",
50155 }
51
52 title_upper = title.upper().replace(".", " ").replace("_", " ").replace("-", " ").replace("[", " ").replace("]", " ")
53
156
157 title_upper = (
158 title.upper()
159 .replace(".", " ")
160 .replace("_", " ")
161 .replace("-", " ")
162 .replace("[", " ")
163 .replace("]", " ")
164 )
165
54166 # 1. Video & Bit Depth
55167 bit_depth = "10bit" if "10BIT" in title_upper else "12bit" if "12BIT" in title_upper else ""
56168 if any(x in title_upper for x in ["HEVC", "H265", "X265"]):
……@@ -59,78 +171,119 @@ def parse_title_info(title):
59171 elif any(x in title_upper for x in ["H264", "X264", "AVC"]):
60172 val = f"H.264 {bit_depth}".strip()
61173 info["codec"] = get_color_tag("VIDEO:", val, "FF9ACD32")
62
174 elif "AV1" in title_upper:
175 val = f"AV1 {bit_depth}".strip()
176 info["codec"] = get_color_tag("VIDEO:", val, "FF9ACD32")
177
63178 # 2. Audio & Channels
64179 channels = ""
65 if "7 1" in title_upper or "8CH" in title_upper: channels = "7.1"
66 elif "5 1" in title_upper or "6CH" in title_upper: channels = "5.1"
67 elif "2 0" in title_upper or "2CH" in title_upper: channels = "2.0"
68
180 if "7 1" in title_upper or "8CH" in title_upper:
181 channels = "7.1"
182 elif "5 1" in title_upper or "6CH" in title_upper:
183 channels = "5.1"
184 elif "2 0" in title_upper or "2CH" in title_upper:
185 channels = "2.0"
186
69187 audio_format = ""
70 if "ATMOS" in title_upper: audio_format = "ATMOS"
71 elif "DTS-HD" in title_upper or "DTSHD" in title_upper: audio_format = "DTS-HD"
72 elif "DTS-X" in title_upper or "DTSX" in title_upper: audio_format = "DTS:X"
73 elif "TRUEHD" in title_upper: audio_format = "TRUEHD"
74 elif any(x in title_upper for x in ["DD+", "DDP", "E-AC3"]): audio_format = "DD+"
75 elif any(x in title_upper for x in ["AC3", "DD5 1"]): audio_format = "AC3"
76
188 if "ATMOS" in title_upper:
189 audio_format = "ATMOS"
190 elif "DTS-HD" in title_upper or "DTSHD" in title_upper:
191 audio_format = "DTS-HD"
192 elif "DTS-X" in title_upper or "DTSX" in title_upper:
193 audio_format = "DTS:X"
194 elif "TRUEHD" in title_upper:
195 audio_format = "TRUEHD"
196 elif any(x in title_upper for x in ["DD+", "DDP", "E-AC3"]):
197 audio_format = "DD+"
198 elif any(x in title_upper for x in ["AC3", "DD5 1"]):
199 audio_format = "AC3"
200
77201 if audio_format or channels:
78202 val = f"{audio_format} {channels}".strip()
79203 info["audio"] = get_color_tag("AUDIO:", val, "FF00BFFF")
80
204
81205 # 3. HDR & Hybrid
82206 hdr_val = ""
83207 is_hybrid = "HYBRID" in title_upper
84 if any(x in title_upper for x in [" DV ", "DOLBY VISION"]): hdr_val = "DV"
85 elif "HDR10+" in title_upper: hdr_val = "HDR10+"
86 elif "HDR10" in title_upper: hdr_val = "HDR10"
87 elif "HDR" in title_upper: hdr_val = "HDR"
88
208 if any(x in title_upper for x in [" DV ", "DOLBY VISION"]):
209 hdr_val = "DV"
210 elif "HDR10+" in title_upper:
211 hdr_val = "HDR10+"
212 elif "HDR10" in title_upper:
213 hdr_val = "HDR10"
214 elif "HDR" in title_upper:
215 hdr_val = "HDR"
216
89217 if hdr_val:
90218 val = f"{hdr_val} HYBRID" if is_hybrid else hdr_val
91219 info["hdr"] = get_color_tag("HDR:", val, "FFFFA500")
92
220
93221 # 4. Editions
94222 editions = []
95 if "IMAX" in title_upper: editions.append("IMAX")
96 if "EXTENDED" in title_upper: editions.append("EXTENDED")
97 if "DIRECTOR" in title_upper and "CUT" in title_upper: editions.append("DIRECTOR'S CUT")
98 if "UNRATED" in title_upper: editions.append("UNRATED")
99 if "REMASTERED" in title_upper: editions.append("REMASTERED")
223 if "IMAX" in title_upper:
224 editions.append("IMAX")
225 if "EXTENDED" in title_upper:
226 editions.append("EXTENDED")
227 if "DIRECTOR" in title_upper and "CUT" in title_upper:
228 editions.append("DIRECTOR'S CUT")
229 if "UNRATED" in title_upper:
230 editions.append("UNRATED")
231 if "REMASTERED" in title_upper:
232 editions.append("REMASTERED")
100233 if editions:
101234 info["edition"] = get_color_tag("EDITION:", " ".join(editions), "FFDA70D6")
102
235
103236 # 5. Integrity Notes
104237 notes = []
105 if "PROPER" in title_upper: notes.append("PROPER")
106 if "REPACK" in title_upper: notes.append("REPACK")
107 if "REAL" in title_upper: notes.append("REAL")
238 if "PROPER" in title_upper:
239 notes.append("PROPER")
240 if "REPACK" in title_upper:
241 notes.append("REPACK")
242 if "REAL" in title_upper:
243 notes.append("REAL")
108244 if notes:
109245 info["note"] = get_color_tag("NOTE:", " ".join(notes), "FFFF4500")
110
246
111247 # 6. Language Details
112248 lang = ""
113 if "LATINO" in title_upper or " LAT " in title_upper: lang = "LATINO"
114 elif "CASTELLANO" in title_upper or " SPA " in title_upper: lang = "CASTELLANO"
115 elif "DUAL" in title_upper: lang = "DUAL"
116 elif "MULTI" in title_upper: lang = "MULTI"
249 if "LATINO" in title_upper or " LAT " in title_upper:
250 lang = "LATINO"
251 elif "CASTELLANO" in title_upper or " SPA " in title_upper:
252 lang = "CASTELLANO"
253 elif "DUAL" in title_upper:
254 lang = "DUAL"
255 elif "MULTI" in title_upper:
256 lang = "MULTI"
117257 if lang:
118258 info["lang_detail"] = get_color_tag("LANG:", lang, "FFFFD700")
119259
120260 # 7. Format & Source
121 if "MKV" in title_upper: info["format"] = get_color_tag("FORMAT:", "MKV", "FF87CEEB")
122 if "REMUX" in title_upper: info["source"] = get_color_tag("SOURCE:", "REMUX", "FFDA70D6")
123
261 if "MKV" in title_upper:
262 info["format"] = get_color_tag("FORMAT:", "MKV", "FF87CEEB")
263 if "REMUX" in title_upper:
264 info["source"] = get_color_tag("SOURCE:", "REMUX", "FFDA70D6")
265
124266 # Build Badge Line
125267 badges_list = []
126 for key in ["codec", "audio", "hdr", "edition", "lang_detail", "format", "source", "note"]:
127 if info[key]: badges_list.append(info[key])
128
268 for key in [
269 "codec",
270 "audio",
271 "hdr",
272 "edition",
273 "lang_detail",
274 "format",
275 "source",
276 "note",
277 ]:
278 if info[key]:
279 badges_list.append(info[key])
280
129281 bullet = "[COLOR 80FFFFFF] • [/COLOR]"
130282 info["badges"] = f" {bullet} ".join(badges_list)
131
283
132284 # Release Group
133285 match = re.search(r"-([a-zA-Z0-9]+)$", title)
134 if match: info["release_group"] = match.group(1)
135
286 if match:
287 info["release_group"] = match.group(1)
288
136289 return info
plugin.video.jacktook/lib/utils/parsers/xmltodict.py +113 -85
……@@ -1,5 +1,5 @@
11#!/usr/bin/env python
2"Makes working with XML feel like you are working with JSON"
2"""Makes working with XML feel like you are working with JSON"""
33
44try:
55 from defusedexpat import pyexpat as expat
……@@ -7,6 +7,7 @@ except ImportError:
77 from xml.parsers import expat
88from xml.sax.saxutils import XMLGenerator
99from xml.sax.xmlreader import AttributesImpl
10
1011try: # pragma no cover
1112 from cStringIO import StringIO
1213except ImportError: # pragma no cover
……@@ -17,6 +18,7 @@ except ImportError: # pragma no cover
1718
1819_dict = dict
1920import platform
21
2022if tuple(map(int, platform.python_version_tuple()[:2])) < (3, 7):
2123 from collections import OrderedDict as _dict
2224
……@@ -31,31 +33,33 @@ try: # pragma no cover
3133except NameError: # pragma no cover
3234 _unicode = str
3335
34__author__ = 'Martin Blech'
35__version__ = '0.13.0'
36__license__ = 'MIT'
36__author__ = "Martin Blech"
37__version__ = "0.13.0"
38__license__ = "MIT"
3739
3840
3941class ParsingInterrupted(Exception):
4042 pass
4143
4244
43class _DictSAXHandler(object):
44 def __init__(self,
45 item_depth=0,
46 item_callback=lambda *args: True,
47 xml_attribs=True,
48 attr_prefix='@',
49 cdata_key='#text',
50 force_cdata=False,
51 cdata_separator='',
52 postprocessor=None,
53 dict_constructor=_dict,
54 strip_whitespace=True,
55 namespace_separator=':',
56 namespaces=None,
57 force_list=None,
58 comment_key='#comment'):
45class _DictSAXHandler:
46 def __init__(
47 self,
48 item_depth=0,
49 item_callback=lambda *args: True,
50 xml_attribs=True,
51 attr_prefix="@",
52 cdata_key="#text",
53 force_cdata=False,
54 cdata_separator="",
55 postprocessor=None,
56 dict_constructor=_dict,
57 strip_whitespace=True,
58 namespace_separator=":",
59 namespaces=None,
60 force_list=None,
61 comment_key="#comment",
62 ):
5963 self.path = []
6064 self.stack = []
6165 self.data = []
……@@ -82,7 +86,7 @@ class _DictSAXHandler(object):
8286 i = full_name.rfind(self.namespace_separator)
8387 if i == -1:
8488 return full_name
85 namespace, name = full_name[:i], full_name[i+1:]
89 namespace, name = full_name[:i], full_name[i + 1 :]
8690 try:
8791 short_namespace = self.namespaces[namespace]
8892 except KeyError:
……@@ -98,13 +102,13 @@ class _DictSAXHandler(object):
98102 return self.dict_constructor(zip(attrs[0::2], attrs[1::2]))
99103
100104 def startNamespaceDecl(self, prefix, uri):
101 self.namespace_declarations[prefix or ''] = uri
105 self.namespace_declarations[prefix or ""] = uri
102106
103107 def startElement(self, full_name, attrs):
104108 name = self._build_name(full_name)
105109 attrs = self._attrs_to_dict(attrs)
106110 if attrs and self.namespace_declarations:
107 attrs['xmlns'] = self.namespace_declarations
111 attrs["xmlns"] = self.namespace_declarations
108112 self.namespace_declarations = self.dict_constructor()
109113 self.path.append((name, attrs or None))
110114 if len(self.path) >= self.item_depth:
……@@ -112,7 +116,7 @@ class _DictSAXHandler(object):
112116 if self.xml_attribs:
113117 attr_entries = []
114118 for key, value in attrs.items():
115 key = self.attr_prefix+self._build_name(key)
119 key = self.attr_prefix + self._build_name(key)
116120 if self.postprocessor:
117121 entry = self.postprocessor(self.path, key, value)
118122 else:
……@@ -130,15 +134,13 @@ class _DictSAXHandler(object):
130134 if len(self.path) == self.item_depth:
131135 item = self.item
132136 if item is None:
133 item = (None if not self.data
134 else self.cdata_separator.join(self.data))
137 item = None if not self.data else self.cdata_separator.join(self.data)
135138
136139 should_continue = self.item_callback(self.path, item)
137140 if not should_continue:
138141 raise ParsingInterrupted()
139142 if self.stack:
140 data = (None if not self.data
141 else self.cdata_separator.join(self.data))
143 data = None if not self.data else self.cdata_separator.join(self.data)
142144 item = self.item
143145 self.item, self.data = self.stack.pop()
144146 if self.strip_whitespace and data and item:
……@@ -199,8 +201,16 @@ class _DictSAXHandler(object):
199201 return self.force_list(self.path[:-1], key, value)
200202
201203
202def parse(xml_input, encoding=None, expat=expat, process_namespaces=False,
203 namespace_separator=':', disable_entities=True, process_comments=False, **kwargs):
204def parse(
205 xml_input,
206 encoding=None,
207 expat=expat,
208 process_namespaces=False,
209 namespace_separator=":",
210 disable_entities=True,
211 process_comments=False,
212 **kwargs,
213):
204214 """Parse the given XML input and convert it into a dictionary.
205215
206216 `xml_input` can either be a `string`, a file-like object, or a generator of strings.
……@@ -333,18 +343,14 @@ def parse(xml_input, encoding=None, expat=expat, process_namespaces=False,
333343 },
334344 }
335345 """
336 handler = _DictSAXHandler(namespace_separator=namespace_separator,
337 **kwargs)
346 handler = _DictSAXHandler(namespace_separator=namespace_separator, **kwargs)
338347 if isinstance(xml_input, _unicode):
339348 if not encoding:
340 encoding = 'utf-8'
349 encoding = "utf-8"
341350 xml_input = xml_input.encode(encoding)
342351 if not process_namespaces:
343352 namespace_separator = None
344 parser = expat.ParserCreate(
345 encoding,
346 namespace_separator
347 )
353 parser = expat.ParserCreate(encoding, namespace_separator)
348354 try:
349355 parser.ordered_attributes = True
350356 except AttributeError:
……@@ -368,18 +374,18 @@ def parse(xml_input, encoding=None, expat=expat, process_namespaces=False,
368374 parser.DefaultHandler = lambda x: None
369375 # Expects an integer return; zero means failure -> expat.ExpatError.
370376 parser.ExternalEntityRefHandler = lambda *x: 1
371 if hasattr(xml_input, 'read'):
377 if hasattr(xml_input, "read"):
372378 parser.ParseFile(xml_input)
373379 elif isgenerator(xml_input):
374380 for chunk in xml_input:
375381 parser.Parse(chunk, False)
376 parser.Parse(b'', True)
382 parser.Parse(b"", True)
377383 else:
378384 parser.Parse(xml_input, True)
379385 return handler.item
380386
381387
382def _process_namespace(name, namespaces, ns_sep=':', attr_prefix='@'):
388def _process_namespace(name, namespaces, ns_sep=":", attr_prefix="@"):
383389 if not namespaces:
384390 return name
385391 try:
……@@ -388,46 +394,52 @@ def _process_namespace(name, namespaces, ns_sep=':', attr_prefix='@'):
388394 pass
389395 else:
390396 ns_res = namespaces.get(ns.strip(attr_prefix))
391 name = '{}{}{}{}'.format(
392 attr_prefix if ns.startswith(attr_prefix) else '',
393 ns_res, ns_sep, name) if ns_res else name
397 name = (
398 "{}{}{}{}".format(
399 attr_prefix if ns.startswith(attr_prefix) else "", ns_res, ns_sep, name
400 )
401 if ns_res
402 else name
403 )
394404 return name
395405
396406
397def _emit(key, value, content_handler,
398 attr_prefix='@',
399 cdata_key='#text',
400 depth=0,
401 preprocessor=None,
402 pretty=False,
403 newl='\n',
404 indent='\t',
405 namespace_separator=':',
406 namespaces=None,
407 full_document=True,
408 expand_iter=None):
407def _emit(
408 key,
409 value,
410 content_handler,
411 attr_prefix="@",
412 cdata_key="#text",
413 depth=0,
414 preprocessor=None,
415 pretty=False,
416 newl="\n",
417 indent="\t",
418 namespace_separator=":",
419 namespaces=None,
420 full_document=True,
421 expand_iter=None,
422):
409423 key = _process_namespace(key, namespaces, namespace_separator, attr_prefix)
410424 if preprocessor is not None:
411425 result = preprocessor(key, value)
412426 if result is None:
413427 return
414428 key, value = result
415 if (not hasattr(value, '__iter__')
416 or isinstance(value, _basestring)
417 or isinstance(value, dict)):
429 if not hasattr(value, "__iter__") or isinstance(value, _basestring) or isinstance(value, dict):
418430 value = [value]
419431 for index, v in enumerate(value):
420432 if full_document and depth == 0 and index > 0:
421 raise ValueError('document with multiple roots')
433 raise ValueError("document with multiple roots")
422434 if v is None:
423435 v = _dict()
424436 elif isinstance(v, bool):
425437 if v:
426 v = _unicode('true')
438 v = _unicode("true")
427439 else:
428 v = _unicode('false')
440 v = _unicode("false")
429441 elif not isinstance(v, dict):
430 if expand_iter and hasattr(v, '__iter__') and not isinstance(v, _basestring):
442 if expand_iter and hasattr(v, "__iter__") and not isinstance(v, _basestring):
431443 v = _dict(((expand_iter, v),))
432444 else:
433445 v = _unicode(v)
……@@ -441,31 +453,40 @@ def _emit(key, value, content_handler,
441453 cdata = iv
442454 continue
443455 if ik.startswith(attr_prefix):
444 ik = _process_namespace(ik, namespaces, namespace_separator,
445 attr_prefix)
446 if ik == '@xmlns' and isinstance(iv, dict):
456 ik = _process_namespace(ik, namespaces, namespace_separator, attr_prefix)
457 if ik == "@xmlns" and isinstance(iv, dict):
447458 for k, v in iv.items():
448 attr = 'xmlns{}'.format(':{}'.format(k) if k else '')
459 attr = "xmlns{}".format(f":{k}" if k else "")
449460 attrs[attr] = _unicode(v)
450461 continue
451462 if not isinstance(iv, _unicode):
452463 iv = _unicode(iv)
453 attrs[ik[len(attr_prefix):]] = iv
464 attrs[ik[len(attr_prefix) :]] = iv
454465 continue
455466 children.append((ik, iv))
456467 if type(indent) is int:
457 indent = ' ' * indent
468 indent = " " * indent
458469 if pretty:
459470 content_handler.ignorableWhitespace(depth * indent)
460471 content_handler.startElement(key, AttributesImpl(attrs))
461472 if pretty and children:
462473 content_handler.ignorableWhitespace(newl)
463474 for child_key, child_value in children:
464 _emit(child_key, child_value, content_handler,
465 attr_prefix, cdata_key, depth+1, preprocessor,
466 pretty, newl, indent, namespaces=namespaces,
467 namespace_separator=namespace_separator,
468 expand_iter=expand_iter)
475 _emit(
476 child_key,
477 child_value,
478 content_handler,
479 attr_prefix,
480 cdata_key,
481 depth + 1,
482 preprocessor,
483 pretty,
484 newl,
485 indent,
486 namespaces=namespaces,
487 namespace_separator=namespace_separator,
488 expand_iter=expand_iter,
489 )
469490 if cdata is not None:
470491 content_handler.characters(cdata)
471492 if pretty and children:
……@@ -475,9 +496,14 @@ def _emit(key, value, content_handler,
475496 content_handler.ignorableWhitespace(newl)
476497
477498
478def unparse(input_dict, output=None, encoding='utf-8', full_document=True,
479 short_empty_elements=False,
480 **kwargs):
499def unparse(
500 input_dict,
501 output=None,
502 encoding="utf-8",
503 full_document=True,
504 short_empty_elements=False,
505 **kwargs,
506):
481507 """Emit an XML document for the given `input_dict` (reverse of `parse`).
482508
483509 The resulting XML document is returned as a string, but if `output` (a
……@@ -493,7 +519,7 @@ def unparse(input_dict, output=None, encoding='utf-8', full_document=True,
493519
494520 """
495521 if full_document and len(input_dict) != 1:
496 raise ValueError('Document must have exactly one root.')
522 raise ValueError("Document must have exactly one root.")
497523 must_return = False
498524 if output is None:
499525 output = StringIO()
……@@ -505,8 +531,7 @@ def unparse(input_dict, output=None, encoding='utf-8', full_document=True,
505531 if full_document:
506532 content_handler.startDocument()
507533 for key, value in input_dict.items():
508 _emit(key, value, content_handler, full_document=full_document,
509 **kwargs)
534 _emit(key, value, content_handler, full_document=full_document, **kwargs)
510535 if full_document:
511536 content_handler.endDocument()
512537 if must_return:
……@@ -518,9 +543,10 @@ def unparse(input_dict, output=None, encoding='utf-8', full_document=True,
518543 return value
519544
520545
521if __name__ == '__main__': # pragma: no cover
522 import sys
546if __name__ == "__main__": # pragma: no cover
523547 import marshal
548 import sys
549
524550 try:
525551 stdin = sys.stdin.buffer
526552 stdout = sys.stdout.buffer
……@@ -536,10 +562,12 @@ if __name__ == '__main__': # pragma: no cover
536562 return True
537563
538564 try:
539 root = parse(stdin,
540 item_depth=item_depth,
541 item_callback=handle_item,
542 dict_constructor=dict)
565 root = parse(
566 stdin,
567 item_depth=item_depth,
568 item_callback=handle_item,
569 dict_constructor=dict,
570 )
543571 if item_depth == 0:
544572 handle_item([], root)
545573 except KeyboardInterrupt:
plugin.video.jacktook/lib/utils/player/utils.py +203 -85
……@@ -1,10 +1,26 @@
1from datetime import timedelta
2from typing import Any, Dict, List, Optional, Tuple
13from urllib.parse import quote
4
5from xbmc import LOGDEBUG
6from xbmcgui import Dialog
7
8from lib.db.cached import cache
9from lib.domain.torrent import TorrentStream
210from lib.jacktook.utils import kodilog
311from lib.utils.debrid.debrid_utils import (
412 get_debrid_direct_url,
513 get_debrid_pack_direct_url,
614 is_supported_debrid_type,
715)
16from lib.utils.general.utils import (
17 DebridType,
18 Indexer,
19 IndexerType,
20 Players,
21 torrent_clients,
22)
23from lib.utils.kodi.logging import summarize_locator_for_log
824from lib.utils.kodi.utils import (
925 execute_builtin,
1026 get_setting,
……@@ -14,24 +30,13 @@ from lib.utils.kodi.utils import (
1430 notification,
1531 translation,
1632)
17from lib.utils.kodi.logging import summarize_locator_for_log
18from lib.utils.general.utils import (
19 DebridType,
20 IndexerType,
21 Indexer,
22 Players,
23 torrent_clients,
24)
25from xbmcgui import Dialog
26from xbmc import LOGDEBUG
27from typing import Any, Dict, Optional
2833
2934
3035def append_proxy_headers(url: str, headers: Dict[str, str]) -> str:
3136 """Attach a Stremio source's required request headers to a direct URL.
3237
33 Kodi hands headers to its HTTP layer via a `|key=value&key=value` suffix —
34 the same mechanism used for Jackgram's Authorization above. Values must be
38 Kodi hands headers to its HTTP layer via a `|key=value&key=value` suffix --
39 the same mechanism used for Jackgram's Authorization below. Values must be
3540 percent-encoded, or a Referer's own `?`/`&`/`=` would split the header list.
3641
3742 Without this, hotlink-protected CDNs answer the playlist but 403 the video
……@@ -121,19 +126,24 @@ def get_torrent_url(data: Dict[str, Any]) -> Optional[str]:
121126 )
122127
123128 if get_setting("torrent_enable"):
124 return get_torrent_url_for_client(magnet, url, mode, ids)
129 return get_torrent_url_for_client(magnet, url, mode, ids, data=data)
125130
126131 if data.get("is_torrent"):
127132 selected_client = get_torrent_client_selection(magnet, url, mode, ids)
128133 if selected_client:
129 return get_torrent_url_for_client(magnet, url, mode, ids, selected_client)
134 return get_torrent_url_for_client(magnet, url, mode, ids, selected_client, data=data)
130135 else:
131136 raise TorrentException("No torrent client selected")
132137 return None
133138
134139
135140def get_torrent_url_for_client(
136 magnet: str, url: str, mode: str, ids: Any, client: str = ""
141 magnet: str,
142 url: str,
143 mode: str,
144 ids: Any,
145 client: str = "",
146 data: Optional[Dict[str, Any]] = None,
137147) -> Optional[str]:
138148 torrent_client = client or str(get_setting("torrent_client"))
139149 if torrent_client in [Players.TORREST]:
……@@ -141,14 +151,12 @@ def get_torrent_url_for_client(
141151 elif torrent_client in [Players.ELEMENTUM]:
142152 return get_elementum_url(magnet, url, mode, ids)
143153 elif torrent_client in [Players.JACKTORR]:
144 return get_jacktorr_url(magnet, url)
154 return get_jacktorr_url(magnet, url, data=data)
145155 else:
146156 raise TorrentException(f"Unknown torrent client selected: {torrent_client}")
147157
148158
149def get_torrent_client_selection(
150 magnet: str, url: str, mode: str, ids: Any
151) -> Optional[str]:
159def get_torrent_client_selection(magnet: str, url: str, mode: str, ids: Any) -> Optional[str]:
152160 chosen_client = Dialog().select(translation(90341), torrent_clients)
153161 if chosen_client < 0:
154162 return None
……@@ -158,13 +166,18 @@ def get_torrent_client_selection(
158166def get_debrid_url(
159167 data: Dict[str, Any], debrid_type: str, is_pack: bool
160168) -> Optional[Dict[str, Any]]:
161 if is_pack and debrid_type in [DebridType.RD, DebridType.TB, DebridType.AD]:
169 if is_pack and debrid_type in [DebridType.RD, DebridType.TB, DebridType.OC, DebridType.AD]:
162170 return get_debrid_pack_direct_url(debrid_type, data)
163171 else:
164172 return get_debrid_direct_url(debrid_type, data)
165173
166174
167def get_elementum_url(magnet: str, url: str, mode: str, ids: Any) -> Optional[str]:
175def get_elementum_url(
176 magnet: str,
177 url: str,
178 mode: str,
179 ids: Any,
180) -> Optional[str]:
168181 if not is_elementum_addon():
169182 if Dialog().yesno(
170183 translation(30252),
……@@ -179,20 +192,17 @@ def get_elementum_url(magnet: str, url: str, mode: str, ids: Any) -> Optional[st
179192
180193 tmdb_id = ids.get("tmdb_id", "") if isinstance(ids, dict) else ""
181194
182 if magnet or url:
183 return f"plugin://plugin.video.elementum/play?uri={quote(magnet or url)}&type={mode}&tmdb={tmdb_id}"
195 uri = url or magnet
196
197 if uri:
198 return f"plugin://plugin.video.elementum/play?uri={quote(uri)}&type={mode}&tmdb={tmdb_id}"
184199 else:
185200 raise TorrentException("No magnet or url found for Elementum playback")
186201
187202
188def get_jacktorr_url(magnet: str, url: str) -> Optional[str]:
203def get_jacktorr_url(magnet: str, url: str, data: Optional[Dict[str, Any]] = None) -> Optional[str]:
189204 kodilog(
190 "Preparing Jacktorr URL with magnet={!r}, url={!r}, has_magnet={}, has_url={}".format(
191 summarize_locator_for_log(magnet),
192 summarize_locator_for_log(url),
193 bool(magnet),
194 bool(url),
195 ),
205 f"Preparing Jacktorr URL with magnet={summarize_locator_for_log(magnet)!r}, url={summarize_locator_for_log(url)!r}, has_magnet={bool(magnet)}, has_url={bool(url)}",
196206 level=LOGDEBUG,
197207 )
198208 if not is_jacktorr_addon():
……@@ -206,6 +216,9 @@ def get_jacktorr_url(magnet: str, url: str) -> Optional[str]:
206216 else:
207217 notification(translation(30253))
208218 return None
219
220 _save_jacktorr_playback_metadata(magnet, data or {})
221
209222 if magnet:
210223 _url = f"plugin://plugin.video.jacktorr/play_magnet?magnet={quote(magnet)}"
211224 elif url:
……@@ -216,6 +229,47 @@ def get_jacktorr_url(magnet: str, url: str) -> Optional[str]:
216229 return _url
217230
218231
232def _save_jacktorr_playback_metadata(magnet: str, data: Dict[str, Any]) -> None:
233 ids = data.get("ids") if isinstance(data.get("ids"), dict) else {}
234 ids.get("imdb_id")
235 mode = data.get("mode", "")
236 tv_data = data.get("tv_data") or {}
237 title = data.get("title", "")
238 info_hash = data.get("info_hash", "")
239
240 meta = {
241 "title": title,
242 "mode": mode,
243 "ids": ids,
244 "tv_data": tv_data,
245 }
246
247 hash_candidates = []
248 if info_hash:
249 hash_candidates.append(info_hash)
250 if magnet:
251 try:
252 from lib.utils.general.utils import get_info_hash_from_magnet
253
254 magnet_hash = get_info_hash_from_magnet(magnet)
255 if magnet_hash and magnet_hash not in hash_candidates:
256 hash_candidates.append(magnet_hash)
257 except Exception as exc:
258 kodilog(f"Failed to extract Jacktorr playback metadata hash from magnet: {exc}")
259
260 if not hash_candidates:
261 kodilog("Jacktorr playback metadata not saved: no info_hash or magnet hash")
262 return
263
264 try:
265 from lib.utils.torrent.torrserver_utils import save_torrent_meta
266
267 for hash_candidate in hash_candidates:
268 save_torrent_meta(hash_candidate, meta)
269 except Exception as exc:
270 kodilog(f"Failed to save Jacktorr playback metadata: {exc}")
271
272
219273def get_torrest_url(magnet: str, url: str) -> Optional[str]:
220274 if not is_torrest_addon():
221275 if Dialog().yesno(
……@@ -237,82 +291,146 @@ def get_torrest_url(magnet: str, url: str) -> Optional[str]:
237291 return _url
238292
239293
240def precache_next_episodes(item_data):
241 kodilog("Precaching next episodes...")
294class TorrentException(Exception):
295 def __init__(self, message: str, status_code: Optional[int] = None, error_content: Any = None):
296 self.message = message
297 self.status_code = status_code
298 self.error_content = error_content
299 details = f"{self.message}"
300 if self.status_code is not None:
301 details += f" (Status code: {self.status_code})"
302 if self.error_content is not None:
303 details += f"\nError content: {self.error_content}"
304 super().__init__(details)
305 notification(details)
306
307
308def get_autoscrape_cache_key(id_value: Any, season: Any, episode: Any) -> str:
309 """Build autoscrape cache key using as:{id}_{season}_{episode} format."""
310 return f"as:{id_value}_{season}_{episode}"
242311
243 from lib.search import search_client
244 from lib.clients.tmdb.utils.utils import tmdb_get
245312
246 if not get_setting("precaching_enabled", False):
247 return
313def get_autoscrape_results_cache_key(id_value: Any, season: Any, episode: Any) -> str:
314 """Build autoscrape results cache key using as_results:{id}_{season}_{episode} format."""
315 return f"as_results:{id_value}_{season}_{episode}"
316
317
318def cache_autoscrape_result(
319 key: str, data: Dict[str, Any], ttl_hours: Optional[int] = None
320) -> None:
321 """Cache resolved playback data with a TTL (default from autoscrape_ttl setting)."""
322 if ttl_hours is None:
323 ttl_hours = int(get_setting("autoscrape_ttl", 4) or 4)
324 cache.set(key, data, expires=timedelta(hours=ttl_hours))
325
248326
249 if item_data.get("mode") != "tv":
327# Quality pattern -> label mapping, matching PreProcessBuilder.filter_by_quality().
328# Keep in priority order: more specific patterns first.
329_QUALITY_PATTERNS: List[Tuple[str, str]] = [
330 ("2160", "[B][COLOR yellow]4k[/COLOR][/B]"),
331 ("1080p", "[B][COLOR blue]1080p[/COLOR][/B]"),
332 ("720p", "[B][COLOR orange]720p[/COLOR][/B]"),
333 ("480p", "[B][COLOR orange]480p[/COLOR][/B]"),
334]
335_UNKNOWN_QUALITY_LABEL = "[B][COLOR yellow]N/A[/COLOR][/B]"
336
337
338def _extract_quality_from_titles(results: List[TorrentStream]) -> None:
339 """Mutate each result's quality field by matching patterns in the title."""
340 for res in results:
341 matched = False
342 for pattern, label in _QUALITY_PATTERNS:
343 if pattern in res.title:
344 res.quality = label
345 matched = True
346 break
347 if not matched:
348 res.quality = _UNKNOWN_QUALITY_LABEL
349
350
351def autoscrape_next_episode(item_data: Dict[str, Any], next_tv_data: Dict[str, Any]) -> None:
352 """Background thread: search, select, resolve, and cache next episode."""
353 if not get_setting("autoscrape_next_episode", False):
250354 return
251355
252356 ids = item_data.get("ids")
253357 if not ids:
254358 return
255359
256 tmdb_id = ids.get("tmdb_id")
257 if not tmdb_id:
360 id_value = ids.get("original_id") or ids.get("imdb_id") or ids.get("tmdb_id")
361 if not id_value:
258362 return
259363
260 details = tmdb_get("tv_details", tmdb_id)
261 tv_data = item_data.get("tv_data", {})
262 season = tv_data.get("season")
263 episode = tv_data.get("episode")
264
364 season = next_tv_data.get("season")
365 episode = next_tv_data.get("episode")
265366 if season is None or episode is None:
266 kodilog("Invalid season or episode data")
267367 return
268368
269 season_details = tmdb_get("season_details", {"id": tmdb_id, "season": season})
270 if not season_details or not hasattr(season_details, "episodes"):
271 kodilog("Invalid season details")
272 return
273
274 episodes_to_cache = []
275 for e in getattr(season_details, "episodes"):
276 episode_number = getattr(e, "episode_number", 0)
277 if episode_number > int(episode):
278 episodes_to_cache.append(e)
279
280 kodilog(f"Found {len(episodes_to_cache)} episodes to cache")
281
282 if not episodes_to_cache:
283 return
369 cache_key = get_autoscrape_cache_key(id_value, season, episode)
370 results_cache_key = get_autoscrape_results_cache_key(id_value, season, episode)
284371
285 count = int(get_setting("precaching_episode_count", 1))
286 for i in range(min(count, len(episodes_to_cache))):
287 next_episode = episodes_to_cache[i]
288 episode_number = getattr(next_episode, "episode_number", 0)
289 query = getattr(details, "name", "")
372 try:
373 from lib.search import search_client
290374
291 search_client(
292 query=query,
375 results = search_client(
376 query=item_data.get("title", ""),
293377 ids=ids,
294 mode=item_data["mode"],
378 mode=item_data.get("mode", ""),
295379 media_type=item_data.get("media_type", ""),
296380 rescrape=True,
297381 season=season,
298 episode=episode_number,
382 episode=episode,
299383 show_dialog=False,
300384 )
301385
302 kodilog(f"Precaching episode {season}x{episode_number}")
386 if not results:
387 kodilog("Autoscrape: no results found")
388 return
389
390 # Populate quality from title for each result (search_client
391 # returns raw TorrentStream objects with quality="N/A"). This
392 # matches what PreProcessBuilder.filter_by_quality() does without
393 # importing Kodi-dependent modules in this background thread.
394 _extract_quality_from_titles(results)
395
396 # Cache results for source select (when autoplay is disabled)
397 cache_autoscrape_result(results_cache_key, results)
398 kodilog(f"Autoscrape: cached results {results_cache_key} ({len(results)} sources)")
399
400 # Apply auto_play heuristics to select best source
401 preferred_quality = str(get_setting("auto_play_quality", "1080p"))
402 quality_matches = [r for r in results if preferred_quality.lower() in r.quality.lower()]
403 if not quality_matches:
404 quality_matches = results
405
406 selected_result = quality_matches[0]
407
408 playback_info = resolve_playback_url(
409 data={
410 "title": selected_result.title,
411 "mode": item_data.get("mode", ""),
412 "indexer": selected_result.indexer,
413 "type": selected_result.type,
414 "debrid_type": selected_result.debridType,
415 "ids": ids,
416 "info_hash": selected_result.infoHash,
417 "url": selected_result.url,
418 "tv_data": next_tv_data,
419 "is_torrent": False,
420 },
421 )
303422
423 if not playback_info:
424 kodilog("Autoscrape: failed to resolve playback URL")
425 return
304426
305class TorrentException(Exception):
306 def __init__(
307 self, message: str, status_code: Optional[int] = None, error_content: Any = None
308 ):
309 self.message = message
310 self.status_code = status_code
311 self.error_content = error_content
312 details = f"{self.message}"
313 if self.status_code is not None:
314 details += f" (Status code: {self.status_code})"
315 if self.error_content is not None:
316 details += f"\nError content: {self.error_content}"
317 super().__init__(details)
318 notification(details)
427 # Enrich with metadata needed for direct playback
428 playback_info["ids"] = ids
429 playback_info["tv_data"] = next_tv_data
430 playback_info["mode"] = item_data.get("mode", "")
431 playback_info["title"] = selected_result.title
432
433 cache_autoscrape_result(cache_key, playback_info)
434 kodilog(f"Autoscrape: cached next episode {cache_key}")
435 except Exception as e:
436 kodilog(f"Autoscrape: error during background scrape: {e}")
plugin.video.jacktook/lib/utils/plex/utils.py +6 -4
……@@ -1,20 +1,21 @@
1from xbmcgui import Dialog
2
13from lib.api.plex.media_server import check_server_connection, get_servers
24from lib.api.plex.plex import PlexApi
35from lib.utils.kodi.utils import get_setting, kodilog, set_setting
4from xbmcgui import Dialog
5
66
77plex = PlexApi()
88
99server_config = {"discoveryUrl": [], "streamingUrl": []}
1010
11
1112def plex_login():
1213 success = plex.login()
1314 if success:
1415 user = plex.get_plex_user()
1516 if user:
1617 set_setting("plex_user", user.username)
17
18
1819
1920def validate_server():
2021 servers = get_servers("https://plex.tv/api/v2", get_setting("plex_token"))
……@@ -55,5 +56,6 @@ def streaming_test(urls, access_token):
5556 set_setting("plex_streaming_url", url)
5657 break
5758
59
5860def plex_logout():
59 plex.logout()
61 plex.logout()
plugin.video.jacktook/lib/utils/stremio/catalogs_utils.py +9 -16
……@@ -1,8 +1,9 @@
11from datetime import timedelta
2
23from lib.clients.stremio.addon_client import StremioAddonCatalogsClient
34from lib.db.cached import cache
4from lib.utils.kodi.utils import kodilog
55from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled
6from lib.utils.kodi.utils import kodilog
67
78
89def catalogs_get_cache(path, params, *args, **kwargs):
……@@ -12,22 +13,14 @@ def catalogs_get_cache(path, params, *args, **kwargs):
1213 return data
1314
1415 handlers = {
15 "search_catalog": lambda query: StremioAddonCatalogsClient(
16 params
17 ).search_catalog(query),
18 "list_catalog": lambda **kwargs: StremioAddonCatalogsClient(
19 params
20 ).get_catalog_info(**kwargs),
21 "list_stremio_seasons": lambda: StremioAddonCatalogsClient(
22 params
23 ).get_meta_info(),
24 "list_stremio_episodes": lambda: StremioAddonCatalogsClient(
25 params
26 ).get_meta_info(),
16 "search_catalog": lambda query: StremioAddonCatalogsClient(params).search_catalog(query),
17 "list_catalog": lambda **kwargs: StremioAddonCatalogsClient(params).get_catalog_info(
18 **kwargs
19 ),
20 "list_stremio_seasons": lambda: StremioAddonCatalogsClient(params).get_meta_info(),
21 "list_stremio_episodes": lambda: StremioAddonCatalogsClient(params).get_meta_info(),
2722 "list_stremio_tv": lambda: StremioAddonCatalogsClient(params).get_stream_info(),
28 "list_stremio_movie": lambda: StremioAddonCatalogsClient(
29 params
30 ).get_stream_info(),
23 "list_stremio_movie": lambda: StremioAddonCatalogsClient(params).get_stream_info(),
3124 }
3225
3326 try:
plugin.video.jacktook/lib/utils/tmdb_init.py +1 -7
……@@ -11,14 +11,9 @@ from lib.api.tmdbv3api.tmdb import TMDb
1111from lib.clients.tmdb.utils.utils import LANGUAGES
1212from lib.utils.kodi.utils import get_setting
1313
14_tmdb_initialized = False
15
1614
1715def ensure_tmdb_init():
18 """Initialize TMDb API key and language if not already done."""
19 global _tmdb_initialized
20 if _tmdb_initialized:
21 return
16 """Initialize TMDb API key and language from current settings."""
2217 tmdb = TMDb()
2318 tmdb.api_key = get_setting("tmdb_api_key", "b70756b7083d9ee60f849d82d94a0d80")
2419 try:
……@@ -26,4 +21,3 @@ def ensure_tmdb_init():
2621 tmdb.language = LANGUAGES[int(language_index)]
2722 except (IndexError, ValueError):
2823 tmdb.language = "en-US"
29 _tmdb_initialized = True
plugin.video.jacktook/lib/utils/torrent/flatbencode.py +15 -14
……@@ -3,14 +3,14 @@ import io
33import itertools
44
55ONE_CHAR = 1
6INTEGER_START = b'i'
7LIST_START = b'l'
8DICT_START = b'd'
9END = b'e'
10NEGATIVE_SIGN = b'-'
11STRING_LENGTH_SEPARATOR = b':'
6INTEGER_START = b"i"
7LIST_START = b"l"
8DICT_START = b"d"
9END = b"e"
10NEGATIVE_SIGN = b"-"
11STRING_LENGTH_SEPARATOR = b":"
1212
13__all__ = ['decode', 'DecodingError', 'encode']
13__all__ = ["DecodingError", "decode", "encode"]
1414
1515
1616class DecodingError(ValueError):
……@@ -18,12 +18,13 @@ class DecodingError(ValueError):
1818
1919
2020def byte_is_integer(b):
21 return b'0' <= b <= b'9'
21 return b"0" <= b <= b"9"
2222
2323
2424def group_by(it, n):
2525 """
2626 >>> list(group_by([1, 2, 3, 4], 2))
27
2728 [(1, 2), (3, 4)]
2829 """
2930 return zip(*[itertools.islice(it2, i, None, n) for i, it2 in enumerate(itertools.tee(it))])
……@@ -53,7 +54,7 @@ def _read_integer(buf):
5354 c = buf.read(ONE_CHAR)
5455
5556 n = acc.getvalue()
56 if n.startswith(b'0') and len(n) > 1: # '03' is illegal
57 if n.startswith(b"0") and len(n) > 1: # '03' is illegal
5758 raise DecodingError
5859 n = int(n)
5960 if n == 0 and negative: # '-0' is illegal
……@@ -132,7 +133,7 @@ def decode(s):
132133def encode(obj):
133134 def generator(obj):
134135 if isinstance(obj, dict):
135 if not all(isinstance(k, bytes) for k in obj.keys()):
136 if not all(isinstance(k, bytes) for k in obj):
136137 raise ValueError("Dictionary keys must be strings")
137138 yield DICT_START
138139 # Dictionary keys should be sorted according to the BEP-0003:
……@@ -148,14 +149,14 @@ def encode(obj):
148149 yield from generator(elem)
149150 yield END
150151 elif isinstance(obj, bytes):
151 yield str(len(obj)).encode('ascii')
152 yield str(len(obj)).encode("ascii")
152153 yield STRING_LENGTH_SEPARATOR
153154 yield obj
154155 elif isinstance(obj, int):
155156 yield INTEGER_START
156 yield str(obj).encode('ascii')
157 yield str(obj).encode("ascii")
157158 yield END
158159 else:
159 raise ValueError("type {} not supported".format(type(obj)))
160 raise ValueError(f"type {type(obj)} not supported")
160161
161 return b''.join(generator(obj))
162 return b"".join(generator(obj))
plugin.video.jacktook/lib/utils/torrent/torrserver_init.py +2 -2
……@@ -1,9 +1,9 @@
11from lib.api.jacktorr.jacktorr import TorrServer
22from lib.utils.general.utils import (
3 get_service_host,
3 get_password,
44 get_port,
5 get_service_host,
56 get_username,
6 get_password,
77 ssl_enabled,
88)
99from lib.utils.kodi.utils import JACKTORR_ADDON
plugin.video.jacktook/lib/utils/torrent/torrserver_utils.py +289 -17
……@@ -1,28 +1,66 @@
11import hashlib
2import json
3import os
4from datetime import timedelta
25from io import BytesIO
6from urllib.parse import quote
7
38import requests
4from lib.utils.kodi.kodi_formats import is_picture, is_text, is_video, is_music
9import xbmc
10import xbmcgui
11from xbmcgui import Dialog
12from xbmcplugin import addDirectoryItem
13
14from lib.clients.subtitle.deepl import DeepLTranslator # noqa: F401 — imported for test mocking
15from lib.clients.subtitle.submanager import SubtitleManager
16from lib.db.cached import cache
17from lib.utils.general.utils import (
18 USER_AGENT_HEADER,
19 build_list_item,
20 get_info_hash_from_magnet,
21 set_pluging_category,
22)
23from lib.utils.kodi.kodi_formats import is_music, is_picture, is_text, is_video
524from lib.utils.kodi.utils import (
625 ADDON_HANDLE,
26 ADDON_PROFILE_PATH,
727 JACKTORR_ADDON,
28 action_url_run,
829 buffer_and_play,
930 build_url,
31 end_of_directory,
32 get_setting, # noqa: F401 — imported for test mocking
1033 kodilog,
1134 notification,
1235 refresh,
1336 show_picture,
1437 translation,
1538)
16from lib.utils.general.utils import (
17 USER_AGENT_HEADER,
18 build_list_item,
19)
39from lib.utils.torrent.torrserver_init import get_torrserver_api
2040from lib.vendor.bencodepy import bencodepy
2141
22from xbmcplugin import addDirectoryItem, endOfDirectory
23from xbmcgui import Dialog
2442
25from lib.utils.torrent.torrserver_init import get_torrserver_api
43def _buffer_and_play_url(info_hash, file_id, path):
44 return (
45 "plugin://plugin.video.jacktorr/buffer_and_play"
46 f"?info_hash={info_hash}&file_id={file_id}&path={quote(path or '')}"
47 )
48
49
50def _select_torrent_video_file(info_hash):
51 info = get_torrserver_api().get_torrent_info(link=info_hash)
52 files = info.get("file_stats") or []
53 video_files = [f for f in files if is_video(f.get("path", ""))]
54 if not video_files:
55 notification("No video files found")
56 return None
57 if len(video_files) == 1:
58 return video_files[0]
59
60 selected = Dialog().select("Select file", [f.get("path", "") for f in video_files])
61 if selected < 0:
62 return None
63 return video_files[selected]
2664
2765
2866def torrent_status(info_hash):
……@@ -40,6 +78,8 @@ def torrent_files(params):
4078 info = get_torrserver_api().get_torrent_info(link=info_hash)
4179 file_stats = info.get("file_stats")
4280
81 set_pluging_category(info.get("title", ""))
82
4383 for f in file_stats:
4484 name = f.get("path")
4585 id = f.get("id")
……@@ -67,8 +107,13 @@ def torrent_files(params):
67107 info_type = "music"
68108
69109 if info_type is not None:
70 file_li.setInfo(info_type, info_labels)
110 jacktorr_url = _buffer_and_play_url(info_hash, id, name)
111 if info_type == "video":
112 file_li.getVideoInfoTag().setTitle(info_labels.get("title", ""))
113 elif info_type == "music":
114 file_li.getMusicInfoTag().setTitle(info_labels.get("title", ""))
71115 file_li.setProperty("IsPlayable", "true")
116 file_li.setPath(jacktorr_url)
72117
73118 context_menu_items.append(
74119 (
……@@ -77,17 +122,69 @@ def torrent_files(params):
77122 )
78123 )
79124
125 if info_type == "video":
126 parsed_data = _parse_torrent_meta(info)
127 parsed_ids = (
128 parsed_data.get("ids", {})
129 if isinstance(parsed_data.get("ids", {}), dict)
130 else {}
131 )
132 meta = {
133 "title": parsed_data.get("title") or info.get("title", ""),
134 "mode": parsed_data.get("mode", ""),
135 "ids": {
136 "tmdb_id": parsed_ids.get("tmdb_id", ""),
137 "tvdb_id": parsed_ids.get("tvdb_id", ""),
138 "imdb_id": parsed_ids.get("imdb_id"),
139 "original_id": parsed_ids.get("original_id", ""),
140 },
141 "tv_data": parsed_data.get("tv_data", {}),
142 }
143 context_menu_items.append(
144 (
145 translation(90082),
146 action_url_run(
147 "download_and_play_subtitles",
148 hash=info_hash,
149 file_id=id,
150 path=name,
151 meta=json.dumps(meta),
152 ),
153 )
154 )
155
156 context_menu_items.append(
157 (
158 translation(30705),
159 action_url_run(
160 "torrent_action",
161 info_hash=info_hash,
162 action_str="remove_torrent",
163 ),
164 )
165 )
166 context_menu_items.append(
167 (
168 translation(30707),
169 action_url_run(
170 "torrent_action",
171 info_hash=info_hash,
172 action_str="torrent_status",
173 ),
174 )
175 )
176
80177 file_li.addContextMenuItems(context_menu_items)
81178
82179 if info_type is not None:
83180 addDirectoryItem(
84181 ADDON_HANDLE,
85 build_url("play_url", url=serve_url, name=name),
182 jacktorr_url,
86183 file_li,
87184 )
88185 else:
89186 addDirectoryItem(ADDON_HANDLE, url, file_li)
90 endOfDirectory(ADDON_HANDLE)
187 end_of_directory()
91188
92189
93190def torrent_action(params):
……@@ -132,9 +229,7 @@ def display_text(params):
132229 Dialog().textviewer(params.get("path"), r.text)
133230
134231
135def add_source_to_torrserver(
136 magnet="", url="", info_hash="", title="", poster=""
137):
232def add_source_to_torrserver(magnet="", url="", info_hash="", title="", poster="", data=""):
138233 if not JACKTORR_ADDON:
139234 notification(translation(30253))
140235 return None
……@@ -146,6 +241,9 @@ def add_source_to_torrserver(
146241
147242 try:
148243 added_hash = None
244 kodilog(
245 f"add_source_to_torrserver input: info_hash={info_hash!r}, title={title!r}, has_magnet={bool(magnet)}, has_url={bool(url)}, has_data={bool(data)}"
246 )
149247
150248 if url and url.startswith("http"):
151249 try:
……@@ -153,9 +251,8 @@ def add_source_to_torrserver(
153251 response.raise_for_status()
154252 torrent_obj = BytesIO(response.content)
155253 torrent_obj.name = "torrent.torrent"
156 added_hash = api.add_torrent_obj(
157 torrent_obj, title=title, poster=poster
158 )
254 added_hash = api.add_torrent_obj(torrent_obj, title=title, poster=poster)
255 kodilog(f"add_source_to_torrserver added torrent URL: returned_hash={added_hash!r}")
159256 except Exception as exc:
160257 kodilog(f"Failed to add torrent URL to TorrServer: {exc}")
161258
……@@ -165,10 +262,29 @@ def add_source_to_torrserver(
165262
166263 if magnet:
167264 added_hash = api.add_magnet(magnet, title=title, poster=poster)
265 kodilog(f"add_source_to_torrserver added magnet: returned_hash={added_hash!r}")
168266 else:
169267 notification(translation(90361))
170268 return None
171269
270 if added_hash and data:
271 try:
272 meta = json.loads(data) if isinstance(data, str) else {}
273 magnet_hash = ""
274 if magnet:
275 try:
276 magnet_hash = get_info_hash_from_magnet(magnet)
277 except Exception as exc:
278 kodilog(f"Failed to extract metadata hash from magnet: {exc}")
279
280 hash_candidates = _metadata_hash_candidates(added_hash, info_hash, magnet_hash)
281 for hash_candidate in hash_candidates:
282 save_torrent_meta(hash_candidate, meta)
283 except Exception as exc:
284 kodilog(f"Failed to parse/save torrent metadata for {added_hash}: {exc}")
285 elif added_hash:
286 kodilog(f"add_source_to_torrserver no metadata data provided for {added_hash}")
287
172288 notification(translation(90360))
173289 return added_hash
174290 except Exception as exc:
……@@ -205,3 +321,159 @@ def extract_torrent_metadata(content: bytes):
205321
206322def convert_info_hash_to_magnet(info_hash: str) -> str:
207323 return f"magnet:?xt=urn:btih:{info_hash}"
324
325
326_TORRENT_META_CACHE_PREFIX = "torrent_meta:"
327
328
329def _normalize_torrent_hash(info_hash):
330 return str(info_hash or "").strip().lower()
331
332
333def _metadata_hash_candidates(*hashes):
334 candidates = []
335 for info_hash in hashes:
336 normalized = _normalize_torrent_hash(info_hash)
337 if normalized and normalized not in candidates:
338 candidates.append(normalized)
339 return candidates
340
341
342def save_torrent_meta(info_hash, meta):
343 """Persist torrent metadata locally, keyed by info hash.
344
345 This avoids reliance on TorrServer's undocumented ``data`` field.
346 """
347 info_hash = _normalize_torrent_hash(info_hash)
348 if not info_hash:
349 return
350
351 key = f"{_TORRENT_META_CACHE_PREFIX}{info_hash}"
352 try:
353 cache.set(key, meta, expires=timedelta(days=365))
354 except Exception as exc:
355 kodilog(f"Failed to save torrent meta for {info_hash}: {exc}")
356
357
358def get_torrent_meta(info_hash):
359 """Retrieve torrent metadata from local cache.
360
361 Returns the stored dict or an empty dict if not found.
362 """
363 info_hash = _normalize_torrent_hash(info_hash)
364 if not info_hash:
365 return {}
366
367 key = f"{_TORRENT_META_CACHE_PREFIX}{info_hash}"
368 try:
369 result = cache.get(key)
370 if result and isinstance(result, dict):
371 return result
372 except Exception as exc:
373 kodilog(f"Failed to load torrent meta for {info_hash}: {exc}")
374 return {}
375
376
377def _parse_torrent_meta(info):
378 """Retrieve torrent metadata from local cache, keyed by hash."""
379 info_hash = info.get("hash")
380 if info_hash:
381 local_meta = get_torrent_meta(info_hash)
382 if local_meta:
383 return local_meta
384 return {}
385
386
387def _normalize_media_ids(ids):
388 normalized = ids.copy() if isinstance(ids, dict) else {}
389 normalized.setdefault("tmdb_id", "")
390 normalized.setdefault("tvdb_id", "")
391 normalized.setdefault("imdb_id", "")
392 normalized.setdefault("original_id", "")
393 return normalized
394
395
396def download_torrent_subtitles(params):
397 info_hash = params.get("hash")
398 meta_str = params.get("meta", "{}")
399 try:
400 meta = json.loads(meta_str)
401 except Exception:
402 meta = {}
403
404 ids = _normalize_media_ids(meta.get("ids", {}))
405 imdb_id = ids.get("imdb_id")
406 title = meta.get("title")
407 mode = meta.get("mode") or ("tv" if meta.get("tv_data", {}).get("season") else "movies")
408 tv_data = meta.get("tv_data", {})
409
410 if not imdb_id and not title:
411 notification("Insufficient metadata")
412 return
413
414 data = {"title": title, "mode": mode, "ids": ids, "tv_data": tv_data}
415
416 folder_path = os.path.join(ADDON_PROFILE_PATH, "subtitles", info_hash)
417 if not os.path.exists(folder_path):
418 os.makedirs(folder_path)
419
420 manager = SubtitleManager(data=data, notification=notification)
421 result = manager.fetch_subtitles(auto_select=False, folder_path=folder_path)
422
423 if result:
424 notification("Subtitles downloaded successfully")
425 selected_file = _select_torrent_video_file(info_hash)
426 if selected_file:
427 playback_url = _buffer_and_play_url(
428 info_hash, selected_file.get("id"), selected_file.get("path")
429 )
430 listitem = xbmcgui.ListItem(label=title, path=playback_url)
431 listitem.setSubtitles(result)
432 xbmc.Player().play(playback_url, listitem)
433 else:
434 if manager.last_fetch_status == "not_found":
435 notification(translation(90252))
436 elif manager.last_fetch_status == "not_selected":
437 notification(translation(90253))
438 elif manager.last_fetch_status == "no_imdb":
439 notification(translation(90299))
440 else:
441 notification(translation(90252))
442
443
444def download_and_play_subtitles(params):
445 info_hash = params.get("hash")
446 file_id = params.get("file_id")
447 path = params.get("path")
448 meta_str = params.get("meta", "{}")
449 try:
450 meta = json.loads(meta_str)
451 except Exception:
452 meta = {}
453
454 ids = _normalize_media_ids(meta.get("ids", {}))
455 imdb_id = ids.get("imdb_id")
456 title = meta.get("title")
457 mode = meta.get("mode") or ("tv" if meta.get("tv_data", {}).get("season") else "movies")
458 tv_data = meta.get("tv_data", {})
459
460 if not imdb_id and not title:
461 notification("Insufficient metadata")
462 return
463
464 data = {"title": title, "mode": mode, "ids": ids, "tv_data": tv_data}
465
466 manager = SubtitleManager(data=data, notification=notification)
467 subtitle_paths = manager.fetch_subtitles(auto_select=False)
468
469 if not subtitle_paths:
470 if manager.last_fetch_status == "no_imdb":
471 notification(translation(90299))
472 else:
473 notification(translation(90252))
474 return
475
476 playback_url = _buffer_and_play_url(info_hash, file_id, path)
477 listitem = xbmcgui.ListItem(label=title, path=playback_url)
478 listitem.setSubtitles(subtitle_paths)
479 xbmc.Player().play(playback_url, listitem)
plugin.video.jacktook/lib/utils/torrentio/utils.py +4 -5
……@@ -1,9 +1,10 @@
11from datetime import timedelta
2
3import xbmcgui
4
25from lib.db.cached import cache
36from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled
47from lib.utils.kodi.utils import translation
5import xbmcgui
6
78
89items = [
910 "YTS",
……@@ -54,9 +55,7 @@ def providers_selection(identifier="torrentio_providers"):
5455 providers,
5556 timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0),
5657 )
57 xbmcgui.Dialog().ok(
58 translation(90573), translation(90574) % ",".join(providers)
59 )
58 xbmcgui.Dialog().ok(translation(90573), translation(90574) % ",".join(providers))
6059 else:
6160 xbmcgui.Dialog().notification(
6261 translation(90575), translation(90576), xbmcgui.NOTIFICATION_INFO
plugin.video.jacktook/lib/utils/views/continue_watching.py +12 -11
……@@ -1,28 +1,31 @@
1# -*- coding: utf-8 -*-
2from lib.jacktook.utils import kodilog
31import json
42import os
3
54from xbmc import executebuiltin
6from xbmcplugin import endOfDirectory, setContent
5from xbmcplugin import setContent
6
7from lib.db.pickle_db import PickleDatabase
8from lib.utils.general.utils import (
9 format_season_episode,
10 set_pluging_category,
11 truncate_text,
12)
713from lib.utils.kodi.utils import (
814 ADDON_HANDLE,
915 ADDON_PATH,
1016 add_directory_items_batch,
1117 apply_section_view,
1218 build_url,
19 end_of_directory,
1320 make_list_item,
1421 translation,
1522)
16from lib.db.pickle_db import PickleDatabase
17from lib.utils.general.utils import set_pluging_category
1823from lib.utils.views.last_files import add_last_files_context_menu, parse_time
19from lib.utils.kodi.utils import end_of_directory
20from lib.utils.general.utils import format_season_episode, truncate_text
2124
2225
2326def has_continue_watching_items():
2427 all_items = PickleDatabase().get_key("jt:lfh").items()
25 for title, data in all_items:
28 for _title, data in all_items:
2629 progress = float(data.get("progress", 0))
2730 if 5 < progress < 90:
2831 return True
……@@ -56,9 +59,7 @@ def show_continue_watching():
5659
5760 if tv_data:
5861 show_name = tv_data.get("name", "")
59 episode_label = format_season_episode(
60 tv_data.get("season"), tv_data.get("episode")
61 )
62 episode_label = format_season_episode(tv_data.get("season"), tv_data.get("episode"))
6263 if episode_label:
6364 label = f"{show_name or label_title} {episode_label}"
6465 else:
plugin.video.jacktook/lib/utils/views/last_files.py +13 -13
……@@ -1,5 +1,6 @@
11import json
22import os
3
34from lib.db.pickle_db import PickleDatabase
45from lib.utils.general.utils import (
56 format_season_episode,
……@@ -7,10 +8,15 @@ from lib.utils.general.utils import (
78 set_pluging_category,
89)
910from lib.utils.kodi.last_files_actions import add_last_files_context_menu
10from lib.utils.kodi.utils import ADDON_HANDLE, ADDON_PATH, add_directory_items_batch, apply_section_view, build_url, end_of_directory, make_list_item, translation
11
12
13
11from lib.utils.kodi.utils import (
12 ADDON_PATH,
13 add_directory_items_batch,
14 apply_section_view,
15 build_url,
16 end_of_directory,
17 make_list_item,
18 translation,
19)
1420
1521pickle_db = PickleDatabase()
1622
……@@ -21,9 +27,7 @@ def show_last_files():
2127 directory_items = []
2228
2329 list_item = make_list_item(label="Clear Files")
24 list_item.setArt(
25 {"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")}
26 )
30 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")})
2731 directory_items.append((build_url("clear_history", type="lfh"), list_item, True))
2832
2933 all_items = list(reversed(pickle_db.get_key("jt:lfh").items()))
……@@ -35,9 +39,7 @@ def show_last_files():
3539 tv_data = data.get("tv_data", {})
3640 if tv_data:
3741 name = tv_data.get("name", "")
38 episode_label = format_season_episode(
39 tv_data.get("season"), tv_data.get("episode")
40 )
42 episode_label = format_season_episode(tv_data.get("season"), tv_data.get("episode"))
4143 if episode_label:
4244 label = f"{title} {episode_label} - {name} — {formatted_time}"
4345 else:
……@@ -46,9 +48,7 @@ def show_last_files():
4648 label = f"{title}—{formatted_time}"
4749
4850 list_item = make_list_item(label=label)
49 list_item.setArt(
50 {"icon": os.path.join(ADDON_PATH, "resources", "img", "magnet.png")}
51 )
51 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "magnet.png")})
5252 list_item.setProperty("IsPlayable", "true")
5353 list_item.addContextMenuItems(add_last_files_context_menu(data))
5454 directory_items.append(
plugin.video.jacktook/lib/utils/views/last_titles.py +7 -16
……@@ -6,7 +6,6 @@ from lib.db.pickle_db import PickleDatabase
66from lib.jacktook.utils import kodilog
77from lib.utils.general.utils import parse_time, set_media_infoTag, set_pluging_category
88from lib.utils.kodi.utils import (
9 ADDON_HANDLE,
109 ADDON_PATH,
1110 add_directory_items_batch,
1211 apply_section_view,
……@@ -17,9 +16,6 @@ from lib.utils.kodi.utils import (
1716 translation,
1817)
1918
20
21
22
2319pickle_db = PickleDatabase()
2420
2521
……@@ -50,9 +46,7 @@ def show_last_titles(params):
5046 directory_items = []
5147
5248 list_item = make_list_item(label="Clear Titles")
53 list_item.setArt(
54 {"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")}
55 )
49 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")})
5650 directory_items.append((build_url("clear_history", type="lth"), list_item, True))
5751
5852 for title, data in items:
……@@ -71,14 +65,12 @@ def show_last_titles(params):
7165
7266 list_item = make_list_item(label=f"{title} — {formatted_time}")
7367 set_media_infoTag(list_item, data=details, mode=mode)
74 list_item.setArt(
75 {"icon": os.path.join(ADDON_PATH, "resources", "img", "trending.png")}
76 )
68 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "trending.png")})
7769 list_item.addContextMenuItems(
7870 [
7971 (
8072 "Delete from history",
81 f'RunPlugin({build_url("delete_last_title_entry", title=title)})',
73 f"RunPlugin({build_url('delete_last_title_entry', title=title)})",
8274 )
8375 ]
8476 )
……@@ -96,7 +88,8 @@ def show_last_titles(params):
9688 directory_items.append(
9789 (
9890 build_url(
99 "list_jackgram_title_sources", data=json.dumps(data.get("tg_data"))
91 "list_jackgram_title_sources",
92 data=json.dumps(data.get("tg_data")),
10093 ),
10194 list_item,
10295 True,
……@@ -105,10 +98,8 @@ def show_last_titles(params):
10598
10699 # "Next Page"
107100 if end < total:
108 list_item = make_list_item(label=f"Next Page")
109 list_item.setArt(
110 {"icon": os.path.join(ADDON_PATH, "resources", "img", "nextpage.png")}
111 )
101 list_item = make_list_item(label="Next Page")
102 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "nextpage.png")})
112103 directory_items.append((build_url("titles_history", page=page + 1), list_item, True))
113104
114105 add_directory_items_batch(directory_items)
plugin.video.jacktook/lib/utils/views/library.py +19 -10
……@@ -1,20 +1,31 @@
1# -*- coding: utf-8 -*-
21import os
32
4from xbmcgui import ListItem
5from xbmcplugin import addDirectoryItem, setContent
3from xbmcplugin import setContent
64
75from lib.clients.tmdb.utils.utils import tmdb_get
86from lib.db.cached import cache
97from lib.db.pickle_db import PickleDatabase
10from lib.jacktook.utils import kodilog
11from lib.utils.general.utils import remove_from_library, set_media_infoTag, set_pluging_category
12from lib.utils.kodi.utils import ADDON_HANDLE, ADDON_PATH, add_directory_items_batch, apply_section_view, build_url, end_of_directory, make_list_item, notification, translation
8from lib.utils.general.utils import (
9 remove_from_library,
10 set_media_infoTag,
11 set_pluging_category,
12)
13from lib.utils.kodi.utils import (
14 ADDON_HANDLE,
15 ADDON_PATH,
16 add_directory_items_batch,
17 apply_section_view,
18 build_url,
19 end_of_directory,
20 make_list_item,
21 notification,
22 translation,
23)
1324from lib.utils.views.last_titles import parse_time
1425
1526
1627def _library_cache_key(mode):
17 return "library_view|{}".format(mode)
28 return f"library_view|{mode}"
1829
1930
2031def _is_stremio_library_item(data):
……@@ -166,9 +177,7 @@ def show_library_items(mode="tv"):
166177 list_item.addContextMenuItems(_build_library_context_menu(title))
167178
168179 # Keep a simple local fallback icon for entries without artwork.
169 list_item.setArt(
170 {"icon": os.path.join(ADDON_PATH, "resources", "img", "trending.png")}
171 )
180 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "trending.png")})
172181
173182 item_url = _get_library_item_url(entry, mode)
174183 is_folder = mode == "tv"
plugin.video.jacktook/lib/utils/views/shows.py +40 -22
……@@ -1,27 +1,27 @@
11import json
2from urllib.parse import quote
3
24from lib.api.trakt.trakt_utils import add_trakt_watched_context_menu, is_trakt_auth
35from lib.clients.tmdb.utils.utils import (
46 add_tmdb_episode_context_menu,
57 add_tmdb_show_context_menu,
68 tmdb_get,
79)
10from lib.utils.general.utils import (
11 execute_thread_pool_collection,
12 get_fanart_details,
13 set_content_type,
14 set_media_infoTag,
15)
816from lib.utils.kodi.utils import (
9 ADDON_HANDLE,
1017 add_directory_items_batch,
1118 apply_section_view,
1219 build_url,
1320 end_of_directory,
1421 get_setting,
22 kodilog,
1523 make_list_item,
1624)
17from lib.utils.general.utils import (
18 execute_thread_pool_collection,
19 get_fanart_details,
20 set_content_type,
21 set_media_infoTag,
22)
23
24
2525
2626
2727def show_seasons_details(params):
……@@ -49,8 +49,8 @@ def show_season_info(ids, mode, media_type):
4949 ids = {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id}
5050
5151 details = tmdb_get("tv_details", tmdb_id)
52 name = getattr(details, "name")
53 seasons = getattr(details, "seasons")
52 name = details.name
53 seasons = details.seasons
5454 fanart_details = get_fanart_details(tvdb_id=tvdb_id, mode=mode)
5555
5656 results = execute_thread_pool_collection(
……@@ -91,9 +91,7 @@ def _process_season(season, details, name, ids, mode, media_type, fanart_details
9191
9292 context_menu = add_tmdb_show_context_menu(mode, ids)
9393 if is_trakt_auth():
94 context_menu += add_trakt_watched_context_menu(
95 "shows", season=season_number, ids=ids
96 )
94 context_menu += add_trakt_watched_context_menu("shows", season=season_number, ids=ids)
9795 list_item.addContextMenuItems(context_menu)
9896
9997 url = build_url(
……@@ -117,19 +115,35 @@ def show_episodes_details(params):
117115 mode = params.get("mode", "")
118116 media_type = params.get("media_type", "")
119117
120 show_episode_info(tv_name, season, ids, mode, media_type)
118 kodilog(
119 f"[EPISODES] show_episodes_details: tv_name={tv_name!r}, season={season}, "
120 f"mode={mode!r}, media_type={media_type!r}, ids={ids}"
121 )
122 item_count = show_episode_info(tv_name, season, ids, mode, media_type)
123 kodilog(f"[EPISODES] show_episodes_details: added item_count={item_count}")
121124 end_of_directory()
125 kodilog("[EPISODES] show_episodes_details: end_of_directory called")
122126 apply_section_view("view.episodes", content_type="episodes")
123127
124128
125129def show_episode_info(tv_name, season, ids, mode, media_type):
126 season_details = tmdb_get(
127 "season_details", {"id": ids.get("tmdb_id"), "season": season}
130 season_details = tmdb_get("season_details", {"id": ids.get("tmdb_id"), "season": season})
131 if not season_details:
132 kodilog(
133 f"[EPISODES] show_episode_info: no season_details for "
134 f"tmdb_id={ids.get('tmdb_id')}, season={season}"
135 )
136 return 0
137
138 episodes = getattr(season_details, "episodes", []) or []
139 kodilog(
140 f"[EPISODES] show_episode_info: fetched episodes_count={len(episodes)} "
141 f"for tmdb_id={ids.get('tmdb_id')}, season={season}"
128142 )
129143 fanart_details = get_fanart_details(tvdb_id=ids.get("tvdb_id"), mode=mode)
130144
131145 results = execute_thread_pool_collection(
132 getattr(season_details, "episodes"),
146 episodes,
133147 _process_episode,
134148 tv_name,
135149 season,
……@@ -141,23 +155,27 @@ def show_episode_info(tv_name, season, ids, mode, media_type):
141155
142156 # Sort by episode number
143157 results.sort(key=lambda x: x[0])
158 item_count = len([list_item for _, _, list_item in results if list_item is not None])
159 kodilog(
160 f"[EPISODES] show_episode_info: processed results_count={len(results)}, "
161 f"item_count={item_count}"
162 )
144163
145164 add_directory_items_batch(
146165 [(url, list_item, False) for _, url, list_item in results if list_item is not None]
147166 )
167 return item_count
148168
149169
150170def _process_episode(episode, tv_name, season, ids, mode, media_type, fanart_details):
151171 ep_name = episode.name
152172 episode_number = episode.episode_number
153173
154 tv_data = {"name": ep_name, "episode": episode_number, "season": season}
174 tv_data = {"name": quote(ep_name), "episode": episode_number, "season": season}
155175
156176 list_item = make_list_item(label=f"{season}x{episode_number}. {ep_name}")
157177 list_item.setProperty("IsPlayable", "true")
158 set_media_infoTag(
159 list_item, data=episode, fanart_data=fanart_details, mode="episode"
160 )
178 set_media_infoTag(list_item, data=episode, fanart_data=fanart_details, mode="episode")
161179
162180 context_menu = add_tmdb_episode_context_menu(mode, tv_name, tv_data, ids)
163181 if is_trakt_auth():
plugin.video.jacktook/lib/utils/views/weekly_calendar.py +23 -29
……@@ -1,31 +1,29 @@
1from datetime import datetime, date, timedelta
2from copy import copy
31import os
42import re
3from copy import copy
4from datetime import date, datetime, timedelta
5from urllib.parse import quote
56
67from lib.clients.tmdb.utils.utils import tmdb_get
78from lib.db.pickle_db import PickleDatabase
89from lib.jacktook.utils import kodilog
10from lib.utils.general.utils import (
11 execute_thread_pool,
12 set_media_infoTag,
13 set_pluging_category,
14 translate_weekday,
15)
916from lib.utils.kodi.utils import (
10 ADDON_HANDLE,
1117 ADDON_PATH,
1218 add_directory_items_batch,
1319 apply_section_view,
1420 build_url,
1521 end_of_directory,
16 translation,
22 get_setting,
1723 kodi_play_media,
1824 make_list_item,
25 translation,
1926)
20from lib.utils.general.utils import (
21 execute_thread_pool,
22 set_media_infoTag,
23 set_pluging_category,
24 translate_weekday,
25)
26from lib.utils.kodi.utils import get_setting
27
28
2927
3028
3129def show_weekly_calendar(library=False):
……@@ -36,9 +34,7 @@ def show_weekly_calendar(library=False):
3634 else:
3735 source_dict = PickleDatabase().get_key("jt:lth")
3836
39 tv_shows = [
40 (title, data) for title, data in source_dict.items() if data.get("mode") == "tv"
41 ]
37 tv_shows = [(title, data) for title, data in source_dict.items() if data.get("mode") == "tv"]
4238
4339 results = []
4440
……@@ -50,9 +46,11 @@ def show_weekly_calendar(library=False):
5046 def calculate_adjusted_broadcast(date_str, offset_hours):
5147 """
5248 Calculates an adjusted date and time based on a manual hour offset.
49
5350 Assumes the original air time is midnight (00:00).
5451 """
5552 if not date_str:
53
5654 return None, None
5755
5856 try:
……@@ -98,9 +96,7 @@ def show_weekly_calendar(library=False):
9896 date_item = make_list_item(
9997 label=f"[UPPERCASE][COLOR=orange]Today: {current_date}[/COLOR][/UPPERCASE]"
10098 )
101 date_item.setArt(
102 {"icon": os.path.join(ADDON_PATH, "resources", "img", "history.png")}
103 )
99 date_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "history.png")})
104100 directory_items.append(("", date_item, False))
105101
106102 today_str = datetime.now().strftime("%Y-%m-%d")
……@@ -132,7 +128,7 @@ def show_weekly_calendar(library=False):
132128 ids["tvdb_id"] = external_ids.get("tvdb_id")
133129
134130 tv_data = {
135 "name": ep_name or title,
131 "name": quote(ep_name or title),
136132 "episode": ep["number"],
137133 "season": ep["season"],
138134 }
……@@ -144,9 +140,7 @@ def show_weekly_calendar(library=False):
144140
145141 # Mark if episode is released today
146142 is_today = ep["air_date"] == today_str
147 mark = (
148 f"[UPPERCASE][COLOR=orange]TODAY- [/COLOR][/UPPERCASE]" if is_today else ""
149 )
143 mark = "[UPPERCASE][COLOR=orange]TODAY- [/COLOR][/UPPERCASE]" if is_today else ""
150144
151145 ep_title = f"{mark}{weekday_name_translated} - ({ep['air_date']} {ep.get('air_time', '00:00')}) - {title} - S{ep['season']:02}E{ep['number']:02}"
152146
……@@ -201,18 +195,16 @@ def get_episodes_for_show(ids):
201195
202196 try:
203197 show_details = tmdb_get("tv_details", tmdb_id)
204 seasons = getattr(show_details, "seasons")
198 seasons = show_details.seasons
205199 seasons = [s for s in seasons if s.get("season_number", 0) > 0]
206200 if not seasons:
207201 return [], show_details
208202 latest_season = max(seasons, key=lambda s: s.get("season_number", 0))
209203 season_number = latest_season.get("season_number")
210 season_details = tmdb_get(
211 "season_details", {"id": tmdb_id, "season": season_number}
212 )
204 season_details = tmdb_get("season_details", {"id": tmdb_id, "season": season_number})
213205
214206 episodes = []
215 for ep in getattr(season_details, "episodes"):
207 for ep in season_details.episodes:
216208 air_date = ep.get("air_date")
217209 if air_date:
218210 episodes.append(
……@@ -232,18 +224,20 @@ def get_episodes_for_show(ids):
232224def is_this_week(date_str):
233225 """Check if the date_str (YYYY-MM-DD) is in the current week."""
234226 try:
227
235228 d = parse_date_str(date_str)
236229 today = date.today()
237230 year, week, _ = today.isocalendar()
238231 d_year, d_week, _ = d.isocalendar()
239232 return (d_year, d_week) == (year, week)
240233 except Exception as e:
241 kodilog(f"Error: {str(e)}")
234 kodilog(f"Error: {e!s}")
242235
243236
244237def parse_date_str(date_str: str) -> date:
245238 """Parse a date string (YYYY-MM-DD or similar) into a date object without using datetime.strptime."""
246239 date_str_cleaned = date_str.strip().split("T")[0]
240
247241 match = re.search(r"(\d{4})[\-\/]?(\d{1,2})[\-\/]?(\d{1,2})", date_str_cleaned)
248242 if match:
249243 year, month, day = map(int, match.groups())
plugin.video.jacktook/lib/vendor/bencodepy/bencode/BTL.py +1 -4
……@@ -3,7 +3,4 @@
33# Compatibility with previous versions:
44from bencode.exceptions import BencodeDecodeError as BTFailure # noqa: F401
55
6
7__all__ = (
8 'BTFailure'
9)
6__all__ = "BTFailure"
plugin.video.jacktook/lib/vendor/bencodepy/bencode/__init__.py +14 -14
……@@ -12,29 +12,29 @@
1212
1313"""bencode.py - bencode encoder + decoder."""
1414
15from bencodepy import Bencached, Bencode
16
1517from bencode.BTL import BTFailure
1618from bencode.exceptions import BencodeDecodeError
1719
18from bencodepy import Bencached, Bencode
19
2020__all__ = (
21 'BTFailure',
22 'Bencached',
23 'BencodeDecodeError',
24 'bencode',
25 'bdecode',
26 'bread',
27 'bwrite',
28 'encode',
29 'decode'
21 "BTFailure",
22 "Bencached",
23 "BencodeDecodeError",
24 "bdecode",
25 "bencode",
26 "bread",
27 "bwrite",
28 "decode",
29 "encode",
3030)
3131
3232
3333DEFAULT = Bencode(
34 encoding='utf-8',
35 encoding_fallback='value',
34 encoding="utf-8",
35 encoding_fallback="value",
3636 dict_ordered=True,
37 dict_ordered_sort=True
37 dict_ordered_sort=True,
3838)
3939
4040bdecode = DEFAULT.decode
plugin.video.jacktook/lib/vendor/bencodepy/bencode/exceptions.py +2 -3
……@@ -1,6 +1,5 @@
11"""bencode.py - encoder + decode exceptions."""
2
23from bencodepy.exceptions import BencodeDecodeError
34
4__all__ = (
5 'BencodeDecodeError',
6)
5__all__ = ("BencodeDecodeError",)
plugin.video.jacktook/lib/vendor/bencodepy/bencodepy/__init__.py +41 -31
……@@ -18,7 +18,7 @@ from .encoder import BencodeEncoder
1818from .exceptions import BencodeDecodeError
1919
2020try:
21 from typing import Dict, List, Tuple, Deque, Union, TextIO, BinaryIO, Any
21 from typing import Any, BinaryIO, Deque, Dict, List, TextIO, Tuple, Union
2222except ImportError:
2323 Dict = List = Tuple = Deque = Union = TextIO = BinaryIO = Any = None
2424
……@@ -33,27 +33,33 @@ except ImportError:
3333 pathlib = None
3434
3535__all__ = (
36 'Bencached',
37 'Bencode',
38 'BencodeDecoder',
39 'BencodeDecodeError',
40 'BencodeEncoder',
41 'bencode',
42 'bdecode',
43 'bread',
44 'bwrite',
45 'encode',
46 'decode'
36 "Bencached",
37 "Bencode",
38 "BencodeDecodeError",
39 "BencodeDecoder",
40 "BencodeEncoder",
41 "bdecode",
42 "bencode",
43 "bread",
44 "bwrite",
45 "decode",
46 "encode",
4747)
4848
4949
50class Bencode(object):
51 def __init__(self, encoding=None, encoding_fallback=None, dict_ordered=False, dict_ordered_sort=False):
50class Bencode:
51 def __init__(
52 self,
53 encoding=None,
54 encoding_fallback=None,
55 dict_ordered=False,
56 dict_ordered_sort=False,
57 ):
5258 self.decoder = BencodeDecoder(
5359 encoding=encoding,
5460 encoding_fallback=encoding_fallback,
5561 dict_ordered=dict_ordered,
56 dict_ordered_sort=dict_ordered_sort
62 dict_ordered_sort=dict_ordered_sort,
5763 )
5864
5965 self.encoder = BencodeEncoder()
……@@ -84,9 +90,10 @@ class Bencode(object):
8490 """
8591 return self.encoder.encode(value)
8692
87 def read(self,
88 fd # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO]
89 ):
93 def read(
94 self,
95 fd, # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO]
96 ):
9097 # type: (...) -> Union[Tuple, List, OrderedDict, bool, int, str, bytes]
9198 """Return bdecoded data from filename, file, or file-like object.
9299
……@@ -95,18 +102,19 @@ class Bencode(object):
95102 raised.
96103 """
97104 if isinstance(fd, (bytes, str)):
98 with open(fd, 'rb') as fd:
105 with open(fd, "rb") as fd:
99106 return self.decode(fd.read())
100107 elif pathlib is not None and isinstance(fd, (pathlib.Path, pathlib.PurePath)):
101 with open(str(fd), 'rb') as fd:
108 with open(str(fd), "rb") as fd:
102109 return self.decode(fd.read())
103110 else:
104111 return self.decode(fd.read())
105112
106 def write(self,
107 data, # type: Union[Tuple, List, OrderedDict, Dict, bool, int, str, bytes]
108 fd # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO]
109 ):
113 def write(
114 self,
115 data, # type: Union[Tuple, List, OrderedDict, Dict, bool, int, str, bytes]
116 fd, # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO]
117 ):
110118 # type: (...) -> None
111119 """Write data in bencoded form to filename, file, or file-like object.
112120
……@@ -115,10 +123,10 @@ class Bencode(object):
115123 exception raised.
116124 """
117125 if isinstance(fd, (bytes, str)):
118 with open(fd, 'wb') as fd:
126 with open(fd, "wb") as fd:
119127 fd.write(self.encode(data))
120128 elif pathlib is not None and isinstance(fd, (pathlib.Path, pathlib.PurePath)):
121 with open(str(fd), 'wb') as fd:
129 with open(str(fd), "wb") as fd:
122130 fd.write(self.encode(data))
123131 else:
124132 fd.write(self.encode(data))
……@@ -155,8 +163,9 @@ def bdecode(value):
155163 return DEFAULT.decode(value)
156164
157165
158def bread(fd # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO]
159 ):
166def bread(
167 fd, # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO]
168):
160169 # type: (...) -> Union[Tuple, List, OrderedDict, bool, int, str, bytes]
161170 """Return bdecoded data from filename, file, or file-like object.
162171
……@@ -167,9 +176,10 @@ def bread(fd # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO,
167176 return DEFAULT.read(fd)
168177
169178
170def bwrite(data, # type: Union[Tuple, List, OrderedDict, Dict, bool, int, str, bytes]
171 fd # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO]
172 ):
179def bwrite(
180 data, # type: Union[Tuple, List, OrderedDict, Dict, bool, int, str, bytes]
181 fd, # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO]
182):
173183 # type: (...) -> None
174184 """Write data in bencoded form to filename, file, or file-like object.
175185
plugin.video.jacktook/lib/vendor/bencodepy/bencodepy/common.py +2 -2
……@@ -1,8 +1,8 @@
11"""bencode.py - common."""
22
33
4class Bencached(object):
5 __slots__ = ['bencoded']
4class Bencached:
5 __slots__ = ["bencoded"]
66
77 def __init__(self, s):
88 self.bencoded = s
plugin.video.jacktook/lib/vendor/bencodepy/bencodepy/compat.py +2 -2
……@@ -25,6 +25,6 @@ def to_binary(s):
2525 return s
2626
2727 if is_text(s):
28 return s.encode('utf-8', 'strict')
28 return s.encode("utf-8", "strict")
2929
30 raise TypeError("expected binary or text (found %s)" % type(s))
30 raise TypeError(f"expected binary or text (found {type(s)})")
plugin.video.jacktook/lib/vendor/bencodepy/bencodepy/decoder.py +42 -34
……@@ -12,12 +12,13 @@
1212
1313"""bencode.py - bencode decoder."""
1414
15from collections import OrderedDict
16
1517from .compat import to_binary
1618from .exceptions import BencodeDecodeError
17from collections import OrderedDict
1819
1920try:
20 from typing import Dict, List, Tuple, Deque, Union, TextIO, BinaryIO, Any
21 from typing import Any, BinaryIO, Deque, Dict, List, TextIO, Tuple, Union
2122except ImportError:
2223 Dict = List = Tuple = Deque = Union = TextIO = BinaryIO = Any = None
2324
……@@ -26,11 +27,17 @@ try:
2627except ImportError:
2728 pathlib = None
2829
29ENCODING_FALLBACK_TYPES = ('key', 'value')
30ENCODING_FALLBACK_TYPES = ("key", "value")
3031
3132
32class BencodeDecoder(object):
33 def __init__(self, encoding=None, encoding_fallback=None, dict_ordered=False, dict_ordered_sort=False):
33class BencodeDecoder:
34 def __init__(
35 self,
36 encoding=None,
37 encoding_fallback=None,
38 dict_ordered=False,
39 dict_ordered_sort=False,
40 ):
3441 self.encoding = encoding
3542 self.dict_ordered = dict_ordered
3643 self.dict_ordered_sort = dict_ordered_sort
……@@ -41,12 +48,14 @@ class BencodeDecoder(object):
4148 )
4249
4350 # Parse encoding fallback
44 if encoding_fallback is not None and encoding_fallback not in ENCODING_FALLBACK_TYPES + ('all',):
51 if encoding_fallback is not None and encoding_fallback not in ENCODING_FALLBACK_TYPES + (
52 "all",
53 ):
4554 raise ValueError(
4655 'Invalid value for "encoding_fallback" (expected "all", "keys", "values" or None)'
4756 )
4857
49 if encoding_fallback == 'all':
58 if encoding_fallback == "all":
5059 self.encoding_fallback = ENCODING_FALLBACK_TYPES
5160 elif encoding_fallback is not None:
5261 self.encoding_fallback = (encoding_fallback,)
……@@ -55,19 +64,19 @@ class BencodeDecoder(object):
5564
5665 # noinspection PyDictCreation
5766 self.decode_func = {}
58 self.decode_func[b'l'] = self.decode_list
59 self.decode_func[b'i'] = self.decode_int
60 self.decode_func[b'0'] = self.decode_string
61 self.decode_func[b'1'] = self.decode_string
62 self.decode_func[b'2'] = self.decode_string
63 self.decode_func[b'3'] = self.decode_string
64 self.decode_func[b'4'] = self.decode_string
65 self.decode_func[b'5'] = self.decode_string
66 self.decode_func[b'6'] = self.decode_string
67 self.decode_func[b'7'] = self.decode_string
68 self.decode_func[b'8'] = self.decode_string
69 self.decode_func[b'9'] = self.decode_string
70 self.decode_func[b'd'] = self.decode_dict
67 self.decode_func[b"l"] = self.decode_list
68 self.decode_func[b"i"] = self.decode_int
69 self.decode_func[b"0"] = self.decode_string
70 self.decode_func[b"1"] = self.decode_string
71 self.decode_func[b"2"] = self.decode_string
72 self.decode_func[b"3"] = self.decode_string
73 self.decode_func[b"4"] = self.decode_string
74 self.decode_func[b"5"] = self.decode_string
75 self.decode_func[b"6"] = self.decode_string
76 self.decode_func[b"7"] = self.decode_string
77 self.decode_func[b"8"] = self.decode_string
78 self.decode_func[b"9"] = self.decode_string
79 self.decode_func[b"d"] = self.decode_dict
7180
7281 def decode(self, value):
7382 # type: (bytes) -> Union[Tuple, List, OrderedDict, bool, int, str, bytes]
……@@ -94,28 +103,28 @@ class BencodeDecoder(object):
94103 def decode_int(self, x, f):
95104 # type: (bytes, int) -> Tuple[int, int]
96105 f += 1
97 newf = x.index(b'e', f)
106 newf = x.index(b"e", f)
98107 n = int(x[f:newf])
99108
100 if x[f:f + 1] == b'-':
101 if x[f + 1:f + 2] == b'0':
109 if x[f : f + 1] == b"-":
110 if x[f + 1 : f + 2] == b"0":
102111 raise ValueError
103 elif x[f:f + 1] == b'0' and newf != f + 1:
112 elif x[f : f + 1] == b"0" and newf != f + 1:
104113 raise ValueError
105114
106115 return n, newf + 1
107116
108 def decode_string(self, x, f, kind='value'):
117 def decode_string(self, x, f, kind="value"):
109118 # type: (bytes, int) -> Tuple[bytes, int]
110119 """Decode torrent bencoded 'string' in x starting at f."""
111 colon = x.index(b':', f)
120 colon = x.index(b":", f)
112121 n = int(x[f:colon])
113122
114 if x[f:f + 1] == b'0' and colon != f + 1:
123 if x[f : f + 1] == b"0" and colon != f + 1:
115124 raise ValueError
116125
117126 colon += 1
118 s = x[colon:colon + n]
127 s = x[colon : colon + n]
119128
120129 if self.encoding:
121130 try:
……@@ -130,8 +139,8 @@ class BencodeDecoder(object):
130139 # type: (bytes, int) -> Tuple[List, int]
131140 r, f = [], f + 1
132141
133 while x[f:f + 1] != b'e':
134 v, f = self.decode_func[x[f:f + 1]](x, f)
142 while x[f : f + 1] != b"e":
143 v, f = self.decode_func[x[f : f + 1]](x, f)
135144 r.append(v)
136145
137146 return r, f + 1
……@@ -139,7 +148,6 @@ class BencodeDecoder(object):
139148 def decode_dict(self, x, f):
140149 # type: (bytes, int) -> Tuple[OrderedDict[str, Any], int]
141150 """Decode bencoded dictionary."""
142
143151 f += 1
144152
145153 if self.dict_ordered:
……@@ -147,9 +155,9 @@ class BencodeDecoder(object):
147155 else:
148156 r = {}
149157
150 while x[f:f + 1] != b'e':
151 k, f = self.decode_string(x, f, kind='key')
152 r[k], f = self.decode_func[x[f:f + 1]](x, f)
158 while x[f : f + 1] != b"e":
159 k, f = self.decode_string(x, f, kind="key")
160 r[k], f = self.decode_func[x[f : f + 1]](x, f)
153161
154162 if self.dict_ordered_sort:
155163 r = OrderedDict(sorted(r.items()))
plugin.video.jacktook/lib/vendor/bencodepy/bencodepy/encoder.py +20 -11
……@@ -12,12 +12,13 @@
1212
1313"""bencode.py - bencode encoder."""
1414
15from collections import deque
16
1517from .common import Bencached
1618from .compat import PY2, to_binary
17from collections import deque
1819
1920try:
20 from typing import Dict, List, Tuple, Deque, Union, TextIO, BinaryIO, Any
21 from typing import Any, BinaryIO, Deque, Dict, List, TextIO, Tuple, Union
2122except ImportError:
2223 Dict = List = Tuple = Deque = Union = TextIO = BinaryIO = Any = None
2324
……@@ -32,14 +33,22 @@ except ImportError:
3233 pathlib = None
3334
3435
35class BencodeEncoder(object):
36class BencodeEncoder:
3637 def __init__(self):
3738 # noinspection PyDictCreation
3839 self.encode_func = {}
3940 self.encode_func[Bencached] = self.encode_bencached
4041
4142 if PY2:
42 from types import DictType, IntType, ListType, LongType, StringType, TupleType, UnicodeType
43 from types import (
44 DictType,
45 IntType,
46 ListType,
47 LongType,
48 StringType,
49 TupleType,
50 UnicodeType,
51 )
4352
4453 self.encode_func[DictType] = self.encode_dict
4554 self.encode_func[IntType] = self.encode_int
……@@ -85,7 +94,7 @@ class BencodeEncoder(object):
8594 self.encode_func[type(value)](value, r)
8695
8796 # Join parts
88 return b''.join(r)
97 return b"".join(r)
8998
9099 def encode_bencached(self, x, r):
91100 # type: (Bencached, Deque[bytes]) -> None
……@@ -93,7 +102,7 @@ class BencodeEncoder(object):
93102
94103 def encode_int(self, x, r):
95104 # type: (int, Deque[bytes]) -> None
96 r.extend((b'i', str(x).encode('utf-8'), b'e'))
105 r.extend((b"i", str(x).encode("utf-8"), b"e"))
97106
98107 def encode_bool(self, x, r):
99108 # type: (bool, Deque[bytes]) -> None
……@@ -104,7 +113,7 @@ class BencodeEncoder(object):
104113
105114 def encode_bytes(self, x, r):
106115 # type: (bytes, Deque[bytes]) -> None
107 r.extend((str(len(x)).encode('utf-8'), b':', x))
116 r.extend((str(len(x)).encode("utf-8"), b":", x))
108117
109118 def encode_string(self, x, r):
110119 # type: (str, Deque[bytes]) -> None
……@@ -112,16 +121,16 @@ class BencodeEncoder(object):
112121
113122 def encode_list(self, x, r):
114123 # type: (List, Deque[bytes]) -> None
115 r.append(b'l')
124 r.append(b"l")
116125
117126 for i in x:
118127 self.encode_func[type(i)](i, r)
119128
120 r.append(b'e')
129 r.append(b"e")
121130
122131 def encode_dict(self, x, r):
123132 # type: (Dict, Deque[bytes]) -> None
124 r.append(b'd')
133 r.append(b"d")
125134
126135 # force all keys to bytes, because str and bytes are incomparable
127136 ilist = [(to_binary(k), v) for k, v in x.items()]
……@@ -131,4 +140,4 @@ class BencodeEncoder(object):
131140 self.encode_func[type(k)](k, r)
132141 self.encode_func[type(v)](v, r)
133142
134 r.append(b'e')
143 r.append(b"e")
plugin.video.jacktook/lib/vendor/segno/__init__.py +72 -38
……@@ -9,27 +9,54 @@ QR Code and Micro QR Code implementation.
99
1010"QR Code" and "Micro QR Code" are registered trademarks of DENSO WAVE INCORPORATED.
1111"""
12import sys
13import io
14from . import encoder
15from .encoder import DataOverflowError
16from . import writers, utils
17
18__version__ = '1.6.6'
19
20__all__ = ('make', 'make_qr', 'make_micro', 'make_sequence', 'QRCode',
21 'QRCodeSequence', 'DataOverflowError')
2212
13import io
14import sys
2315
24def make(content, error=None, version=None, mode=None, mask=None, encoding=None,
25 eci=False, micro=None, boost_error=True):
26 return QRCode(encoder.encode(content, error, version, mode, mask, encoding,
27 eci, micro, boost_error=boost_error))
16from . import encoder, utils, writers
17from .encoder import DataOverflowError
2818
19__version__ = "1.6.6"
20
21__all__ = (
22 "DataOverflowError",
23 "QRCode",
24 "QRCodeSequence",
25 "make",
26 "make_micro",
27 "make_qr",
28 "make_sequence",
29)
30
31
32def make(
33 content,
34 error=None,
35 version=None,
36 mode=None,
37 mask=None,
38 encoding=None,
39 eci=False,
40 micro=None,
41 boost_error=True,
42):
43 return QRCode(
44 encoder.encode(
45 content,
46 error,
47 version,
48 mode,
49 mask,
50 encoding,
51 eci,
52 micro,
53 boost_error=boost_error,
54 )
55 )
2956
3057
3158class QRCode:
32 __slots__ = ('_error', '_matrix_size', '_mode', '_version', 'mask', 'matrix')
59 __slots__ = ("_error", "_matrix_size", "_mode", "_version", "mask", "matrix")
3360
3461 def __init__(self, code):
3562 matrix = code.matrix
……@@ -59,7 +86,7 @@ class QRCode:
5986 @property
6087 def designator(self):
6188 version = str(self.version)
62 return '-'.join((version, self.error) if self.error else (version,))
89 return "-".join((version, self.error) if self.error else (version,))
6390
6491 @property
6592 def default_border_size(self):
……@@ -81,13 +108,14 @@ class QRCode:
81108 iterfn = utils.matrix_iter_verbose if verbose else utils.matrix_iter
82109 return iterfn(self.matrix, self._matrix_size, scale, border)
83110
84 def show(self, delete_after=20, scale=10, border=None, dark='#000',
85 light='#fff'): # pragma: no cover
111 def show(
112 self, delete_after=20, scale=10, border=None, dark="#000", light="#fff"
113 ): # pragma: no cover
86114 import os
87 import time
88115 import tempfile
89 import webbrowser
90116 import threading
117 import time
118 import webbrowser
91119 from urllib.parse import urljoin
92120 from urllib.request import pathname2url
93121
……@@ -98,7 +126,7 @@ class QRCode:
98126 except OSError:
99127 pass
100128
101 f = tempfile.NamedTemporaryFile('wb', suffix='.png', delete=False)
129 f = tempfile.NamedTemporaryFile("wb", suffix=".png", delete=False)
102130 try:
103131 self.save(f, scale=scale, dark=dark, light=light, border=border)
104132 except:
……@@ -106,30 +134,36 @@ class QRCode:
106134 os.unlink(f.name)
107135 raise
108136 f.close()
109 webbrowser.open_new_tab(urljoin('file:', pathname2url(f.name)))
137 webbrowser.open_new_tab(urljoin("file:", pathname2url(f.name)))
110138 if delete_after is not None:
111139 t = threading.Thread(target=delete_file, args=(f.name,))
112140 t.start()
113141
114 def svg_data_uri(self, xmldecl=False, encode_minimal=False,
115 omit_charset=False, nl=False, **kw):
116 return writers.as_svg_data_uri(self.matrix, self._matrix_size,
117 xmldecl=xmldecl, nl=nl,
118 encode_minimal=encode_minimal,
119 omit_charset=omit_charset, **kw)
142 def svg_data_uri(self, xmldecl=False, encode_minimal=False, omit_charset=False, nl=False, **kw):
143 return writers.as_svg_data_uri(
144 self.matrix,
145 self._matrix_size,
146 xmldecl=xmldecl,
147 nl=nl,
148 encode_minimal=encode_minimal,
149 omit_charset=omit_charset,
150 **kw,
151 )
120152
121153 def svg_inline(self, **kw):
122154 buff = io.BytesIO()
123 self.save(buff, kind='svg', xmldecl=False, svgns=False, nl=False, **kw)
124 return buff.getvalue().decode(kw.get('encoding', 'utf-8'))
155 self.save(buff, kind="svg", xmldecl=False, svgns=False, nl=False, **kw)
156 return buff.getvalue().decode(kw.get("encoding", "utf-8"))
125157
126158 def png_data_uri(self, **kw):
127159 return writers.as_png_data_uri(self.matrix, self._matrix_size, **kw)
128160
129161 def terminal(self, out=None, border=None, compact=False):
130162 if compact:
131 writers.write_terminal_compact(self.matrix, self._matrix_size, out or sys.stdout, border)
132 elif out is None and sys.platform == 'win32': # pragma: no cover
163 writers.write_terminal_compact(
164 self.matrix, self._matrix_size, out or sys.stdout, border
165 )
166 elif out is None and sys.platform == "win32": # pragma: no cover
133167 try:
134168 writers.write_terminal_win(self.matrix, self._matrix_size, border)
135169 except OSError:
……@@ -141,17 +175,17 @@ class QRCode:
141175 writers.save(self.matrix, self._matrix_size, out, kind, **kw)
142176
143177 def __getattr__(self, name):
144 if name.startswith('to_'):
178 if name.startswith("to_"):
145179 try:
146180 import importlib_metadata as metadata
147181 except ImportError:
148182 from importlib import metadata
149183 from functools import partial
150 for ep in metadata.entry_points(group='segno.plugin.converter',
151 name=name[3:]):
184
185 for ep in metadata.entry_points(group="segno.plugin.converter", name=name[3:]):
152186 plugin = ep.load()
153187 return partial(plugin, self)
154 raise AttributeError(f'{self.__class__} object has no attribute {name}')
188 raise AttributeError(f"{self.__class__} object has no attribute {name}")
155189
156190
157191class QRCodeSequence(tuple):
……@@ -168,9 +202,9 @@ class QRCodeSequence(tuple):
168202 filename = lambda o, n: o # noqa: E731
169203 m = len(self)
170204 if m > 1 and isinstance(out, str):
171 dot_idx = out.rfind('.')
205 dot_idx = out.rfind(".")
172206 if dot_idx > -1:
173 out = out[:dot_idx] + '-{0:02d}-{1:02d}' + out[dot_idx:]
207 out = out[:dot_idx] + "-{0:02d}-{1:02d}" + out[dot_idx:]
174208 filename = lambda o, n: o.format(m, n) # noqa: E731
175209 for n, qrcode in enumerate(self, start=1):
176210 qrcode.save(filename(out, n), kind=kind, **kw)
plugin.video.jacktook/lib/vendor/segno/consts.py +1381 -288
……@@ -9,9 +9,10 @@ Constants.
99
1010Internal module. May change without further warning.
1111"""
12
1213from collections import namedtuple
1314
14ALPHANUMERIC_CHARS = br'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'
15ALPHANUMERIC_CHARS = rb"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
1516
1617# ISO/IEC 18004:2015(E) -- Table 2 — Mode indicators for QR Code (page 23)
1718MODE_NUMERIC = 0x1
……@@ -38,7 +39,7 @@ MODE_TO_RECT_MICRO_MODE_MAPPING = {
3839 MODE_NUMERIC: 0x1,
3940 MODE_ALPHANUMERIC: 0x2,
4041 MODE_BYTE: 0x3,
41 MODE_KANJI: 0x64
42 MODE_KANJI: 0x64,
4243}
4344
4445# Note: These versions must be comparable: Version 1 > M4 > M3 > M2 > M1
……@@ -48,10 +49,10 @@ VERSION_M2 = -2
4849VERSION_M1 = -3
4950
5051MICRO_VERSION_MAPPING = {
51 'M1': VERSION_M1,
52 'M2': VERSION_M2,
53 'M3': VERSION_M3,
54 'M4': VERSION_M4,
52 "M1": VERSION_M1,
53 "M2": VERSION_M2,
54 "M3": VERSION_M3,
55 "M4": VERSION_M4,
5556}
5657
5758MICRO_VERSIONS = tuple(sorted(MICRO_VERSION_MAPPING.values()))
……@@ -65,32 +66,28 @@ ERROR_LEVEL_H = 2
6566
6667ERROR_LEVEL_TO_MICRO_MAPPING = {
6768 VERSION_M1: {None: 0},
68 VERSION_M2: {ERROR_LEVEL_L: 1,
69 ERROR_LEVEL_M: 2},
70 VERSION_M3: {ERROR_LEVEL_L: 3,
71 ERROR_LEVEL_M: 4},
72 VERSION_M4: {ERROR_LEVEL_L: 5,
73 ERROR_LEVEL_M: 6,
74 ERROR_LEVEL_Q: 7},
69 VERSION_M2: {ERROR_LEVEL_L: 1, ERROR_LEVEL_M: 2},
70 VERSION_M3: {ERROR_LEVEL_L: 3, ERROR_LEVEL_M: 4},
71 VERSION_M4: {ERROR_LEVEL_L: 5, ERROR_LEVEL_M: 6, ERROR_LEVEL_Q: 7},
7572}
7673
77DEFAULT_BYTE_ENCODING = 'iso-8859-1'
78KANJI_ENCODING = 'shift_jis'
79HANZI_ENCODING = 'gb2312'
74DEFAULT_BYTE_ENCODING = "iso-8859-1"
75KANJI_ENCODING = "shift_jis"
76HANZI_ENCODING = "gb2312"
8077
8178MODE_MAPPING = {
82 'numeric': MODE_NUMERIC,
83 'alphanumeric': MODE_ALPHANUMERIC,
84 'byte': MODE_BYTE,
85 'kanji': MODE_KANJI,
86 'hanzi': MODE_HANZI,
79 "numeric": MODE_NUMERIC,
80 "alphanumeric": MODE_ALPHANUMERIC,
81 "byte": MODE_BYTE,
82 "kanji": MODE_KANJI,
83 "hanzi": MODE_HANZI,
8784}
8885
8986ERROR_MAPPING = {
90 'L': ERROR_LEVEL_L,
91 'M': ERROR_LEVEL_M,
92 'Q': ERROR_LEVEL_Q,
93 'H': ERROR_LEVEL_H,
87 "L": ERROR_LEVEL_L,
88 "M": ERROR_LEVEL_M,
89 "Q": ERROR_LEVEL_Q,
90 "H": ERROR_LEVEL_H,
9491}
9592
9693#
……@@ -138,33 +135,34 @@ ERROR_MAPPING = {
138135# 000030 Korean Character Set
139136ECI_ASSIGNMENT_NUM = {
140137 # Codecs name (``codecs.lookup(some-charset).name``) -> ECI designator
141 'cp437': 1,
142 'iso8859-1': 3,
143 'iso8859-2': 4,
144 'iso8859-3': 5,
145 'iso8859-4': 6,
146 'iso8859-5': 7,
147 'iso8859-6': 8,
148 'iso8859-7': 9,
149 'iso8859-8': 10,
150 'iso8859-9': 11,
151 'iso8859-10': 12,
152 'iso8859-11': 13,
153 'iso8859-13': 15,
154 'iso8859-14': 16,
155 'iso8859-15': 17,
156 'iso8859-16': 18,
157 'shift_jis': 20,
158 'cp1250': 21,
159 'cp1251': 22,
160 'cp1252': 23,
161 'cp1256': 24,
162 'utf-16-be': 25,
163 'utf-8': 26,
164 'ascii': 27,
165 'big5': 28,
166 'gb18030': 29, 'gbk': 29, # GBK is treated as GB-18030
167 'euc_kr': 30,
138 "cp437": 1,
139 "iso8859-1": 3,
140 "iso8859-2": 4,
141 "iso8859-3": 5,
142 "iso8859-4": 6,
143 "iso8859-5": 7,
144 "iso8859-6": 8,
145 "iso8859-7": 9,
146 "iso8859-8": 10,
147 "iso8859-9": 11,
148 "iso8859-10": 12,
149 "iso8859-11": 13,
150 "iso8859-13": 15,
151 "iso8859-14": 16,
152 "iso8859-15": 17,
153 "iso8859-16": 18,
154 "shift_jis": 20,
155 "cp1250": 21,
156 "cp1251": 22,
157 "cp1252": 23,
158 "cp1256": 24,
159 "utf-16-be": 25,
160 "utf-8": 26,
161 "ascii": 27,
162 "big5": 28,
163 "gb18030": 29,
164 "gbk": 29, # GBK is treated as GB-18030
165 "euc_kr": 30,
168166}
169167
170168
……@@ -184,7 +182,7 @@ TERMINATOR_LENGTH = {
184182 VERSION_M1: 3,
185183 VERSION_M2: 5,
186184 VERSION_M3: 7,
187 VERSION_M4: 9
185 VERSION_M4: 9,
188186}
189187
190188VERSION_RANGE_01_09 = 1 # Version 1 .. 9
……@@ -197,348 +195,590 @@ CHAR_COUNT_INDICATOR_LENGTH = {
197195 MODE_NUMERIC: {
198196 VERSION_RANGE_01_09: 10,
199197 VERSION_RANGE_10_26: 12,
200 VERSION_RANGE_27_40: 14, VERSION_M1: 3, VERSION_M2: 4, VERSION_M3: 5, VERSION_M4: 6},
198 VERSION_RANGE_27_40: 14,
199 VERSION_M1: 3,
200 VERSION_M2: 4,
201 VERSION_M3: 5,
202 VERSION_M4: 6,
203 },
201204 MODE_ALPHANUMERIC: {
202205 VERSION_RANGE_01_09: 9,
203206 VERSION_RANGE_10_26: 11,
204 VERSION_RANGE_27_40: 13, VERSION_M2: 3, VERSION_M3: 4, VERSION_M4: 5},
207 VERSION_RANGE_27_40: 13,
208 VERSION_M2: 3,
209 VERSION_M3: 4,
210 VERSION_M4: 5,
211 },
205212 MODE_BYTE: {
206213 VERSION_RANGE_01_09: 8,
207214 VERSION_RANGE_10_26: 16,
208 VERSION_RANGE_27_40: 16, VERSION_M3: 4, VERSION_M4: 5},
215 VERSION_RANGE_27_40: 16,
216 VERSION_M3: 4,
217 VERSION_M4: 5,
218 },
209219 MODE_KANJI: {
210220 VERSION_RANGE_01_09: 8,
211221 VERSION_RANGE_10_26: 10,
212 VERSION_RANGE_27_40: 12, VERSION_M3: 3, VERSION_M4: 4},
222 VERSION_RANGE_27_40: 12,
223 VERSION_M3: 3,
224 VERSION_M4: 4,
225 },
213226 MODE_HANZI: {
214227 VERSION_RANGE_01_09: 8,
215228 VERSION_RANGE_10_26: 10,
216 VERSION_RANGE_27_40: 12},
229 VERSION_RANGE_27_40: 12,
230 },
217231}
218232
219233
220234# ISO/IEC 18004:2015(E) - 6.4.10 Bit stream to codeword conversion (page 33)
221235# Table 7 — Number of symbol characters and input data capacity for QR Code
222236SYMBOL_CAPACITY = {
223 VERSION_M1: {
224 None: 20},
225 VERSION_M2: {
226 ERROR_LEVEL_L: 40, ERROR_LEVEL_M: 32},
227 VERSION_M3: {
228 ERROR_LEVEL_L: 84, ERROR_LEVEL_M: 68},
229 VERSION_M4: {
230 ERROR_LEVEL_L: 128, ERROR_LEVEL_M: 112, ERROR_LEVEL_Q: 80},
231 1: {ERROR_LEVEL_L: 152, ERROR_LEVEL_M: 128, ERROR_LEVEL_Q: 104, ERROR_LEVEL_H: 72},
232 2: {ERROR_LEVEL_L: 272, ERROR_LEVEL_M: 224, ERROR_LEVEL_Q: 176, ERROR_LEVEL_H: 128},
233 3: {ERROR_LEVEL_L: 440, ERROR_LEVEL_M: 352, ERROR_LEVEL_Q: 272, ERROR_LEVEL_H: 208},
234 4: {ERROR_LEVEL_L: 640, ERROR_LEVEL_M: 512, ERROR_LEVEL_Q: 384, ERROR_LEVEL_H: 288},
235 5: {ERROR_LEVEL_L: 864, ERROR_LEVEL_M: 688, ERROR_LEVEL_Q: 496, ERROR_LEVEL_H: 368},
236 6: {ERROR_LEVEL_L: 1088, ERROR_LEVEL_M: 864, ERROR_LEVEL_Q: 608, ERROR_LEVEL_H: 480},
237 7: {ERROR_LEVEL_L: 1248, ERROR_LEVEL_M: 992, ERROR_LEVEL_Q: 704, ERROR_LEVEL_H: 528},
238 8: {ERROR_LEVEL_L: 1552, ERROR_LEVEL_M: 1232, ERROR_LEVEL_Q: 880, ERROR_LEVEL_H: 688},
239 9: {ERROR_LEVEL_L: 1856, ERROR_LEVEL_M: 1456, ERROR_LEVEL_Q: 1056, ERROR_LEVEL_H: 800},
240 10: {ERROR_LEVEL_L: 2192, ERROR_LEVEL_M: 1728, ERROR_LEVEL_Q: 1232, ERROR_LEVEL_H: 976},
241 11: {ERROR_LEVEL_L: 2592, ERROR_LEVEL_M: 2032, ERROR_LEVEL_Q: 1440, ERROR_LEVEL_H: 1120},
242 12: {ERROR_LEVEL_L: 2960, ERROR_LEVEL_M: 2320, ERROR_LEVEL_Q: 1648, ERROR_LEVEL_H: 1264},
243 13: {ERROR_LEVEL_L: 3424, ERROR_LEVEL_M: 2672, ERROR_LEVEL_Q: 1952, ERROR_LEVEL_H: 1440},
244 14: {ERROR_LEVEL_L: 3688, ERROR_LEVEL_M: 2920, ERROR_LEVEL_Q: 2088, ERROR_LEVEL_H: 1576},
245 15: {ERROR_LEVEL_L: 4184, ERROR_LEVEL_M: 3320, ERROR_LEVEL_Q: 2360, ERROR_LEVEL_H: 1784},
246 16: {ERROR_LEVEL_L: 4712, ERROR_LEVEL_M: 3624, ERROR_LEVEL_Q: 2600, ERROR_LEVEL_H: 2024},
247 17: {ERROR_LEVEL_L: 5176, ERROR_LEVEL_M: 4056, ERROR_LEVEL_Q: 2936, ERROR_LEVEL_H: 2264},
248 18: {ERROR_LEVEL_L: 5768, ERROR_LEVEL_M: 4504, ERROR_LEVEL_Q: 3176, ERROR_LEVEL_H: 2504},
249 19: {ERROR_LEVEL_L: 6360, ERROR_LEVEL_M: 5016, ERROR_LEVEL_Q: 3560, ERROR_LEVEL_H: 2728},
250 20: {ERROR_LEVEL_L: 6888, ERROR_LEVEL_M: 5352, ERROR_LEVEL_Q: 3880, ERROR_LEVEL_H: 3080},
251 21: {ERROR_LEVEL_L: 7456, ERROR_LEVEL_M: 5712, ERROR_LEVEL_Q: 4096, ERROR_LEVEL_H: 3248},
252 22: {ERROR_LEVEL_L: 8048, ERROR_LEVEL_M: 6256, ERROR_LEVEL_Q: 4544, ERROR_LEVEL_H: 3536},
253 23: {ERROR_LEVEL_L: 8752, ERROR_LEVEL_M: 6880, ERROR_LEVEL_Q: 4912, ERROR_LEVEL_H: 3712},
254 24: {ERROR_LEVEL_L: 9392, ERROR_LEVEL_M: 7312, ERROR_LEVEL_Q: 5312, ERROR_LEVEL_H: 4112},
255 25: {ERROR_LEVEL_L: 10208, ERROR_LEVEL_M: 8000, ERROR_LEVEL_Q: 5744, ERROR_LEVEL_H: 4304},
256 26: {ERROR_LEVEL_L: 10960, ERROR_LEVEL_M: 8496, ERROR_LEVEL_Q: 6032, ERROR_LEVEL_H: 4768},
257 27: {ERROR_LEVEL_L: 11744, ERROR_LEVEL_M: 9024, ERROR_LEVEL_Q: 6464, ERROR_LEVEL_H: 5024},
258 28: {ERROR_LEVEL_L: 12248, ERROR_LEVEL_M: 9544, ERROR_LEVEL_Q: 6968, ERROR_LEVEL_H: 5288},
259 29: {ERROR_LEVEL_L: 13048, ERROR_LEVEL_M: 10136, ERROR_LEVEL_Q: 7288, ERROR_LEVEL_H: 5608},
260 30: {ERROR_LEVEL_L: 13880, ERROR_LEVEL_M: 10984, ERROR_LEVEL_Q: 7880, ERROR_LEVEL_H: 5960},
261 31: {ERROR_LEVEL_L: 14744, ERROR_LEVEL_M: 11640, ERROR_LEVEL_Q: 8264, ERROR_LEVEL_H: 6344},
262 32: {ERROR_LEVEL_L: 15640, ERROR_LEVEL_M: 12328, ERROR_LEVEL_Q: 8920, ERROR_LEVEL_H: 6760},
263 33: {ERROR_LEVEL_L: 16568, ERROR_LEVEL_M: 13048, ERROR_LEVEL_Q: 9368, ERROR_LEVEL_H: 7208},
264 34: {ERROR_LEVEL_L: 17528, ERROR_LEVEL_M: 13800, ERROR_LEVEL_Q: 9848, ERROR_LEVEL_H: 7688},
265 35: {ERROR_LEVEL_L: 18448, ERROR_LEVEL_M: 14496, ERROR_LEVEL_Q: 10288, ERROR_LEVEL_H: 7888},
266 36: {ERROR_LEVEL_L: 19472, ERROR_LEVEL_M: 15312, ERROR_LEVEL_Q: 10832, ERROR_LEVEL_H: 8432},
267 37: {ERROR_LEVEL_L: 20528, ERROR_LEVEL_M: 15936, ERROR_LEVEL_Q: 11408, ERROR_LEVEL_H: 8768},
268 38: {ERROR_LEVEL_L: 21616, ERROR_LEVEL_M: 16816, ERROR_LEVEL_Q: 12016, ERROR_LEVEL_H: 9136},
269 39: {ERROR_LEVEL_L: 22496, ERROR_LEVEL_M: 17728, ERROR_LEVEL_Q: 12656, ERROR_LEVEL_H: 9776},
270 40: {ERROR_LEVEL_L: 23648, ERROR_LEVEL_M: 18672, ERROR_LEVEL_Q: 13328, ERROR_LEVEL_H: 10208}
237 VERSION_M1: {None: 20},
238 VERSION_M2: {ERROR_LEVEL_L: 40, ERROR_LEVEL_M: 32},
239 VERSION_M3: {ERROR_LEVEL_L: 84, ERROR_LEVEL_M: 68},
240 VERSION_M4: {ERROR_LEVEL_L: 128, ERROR_LEVEL_M: 112, ERROR_LEVEL_Q: 80},
241 1: {ERROR_LEVEL_L: 152, ERROR_LEVEL_M: 128, ERROR_LEVEL_Q: 104, ERROR_LEVEL_H: 72},
242 2: {ERROR_LEVEL_L: 272, ERROR_LEVEL_M: 224, ERROR_LEVEL_Q: 176, ERROR_LEVEL_H: 128},
243 3: {ERROR_LEVEL_L: 440, ERROR_LEVEL_M: 352, ERROR_LEVEL_Q: 272, ERROR_LEVEL_H: 208},
244 4: {ERROR_LEVEL_L: 640, ERROR_LEVEL_M: 512, ERROR_LEVEL_Q: 384, ERROR_LEVEL_H: 288},
245 5: {ERROR_LEVEL_L: 864, ERROR_LEVEL_M: 688, ERROR_LEVEL_Q: 496, ERROR_LEVEL_H: 368},
246 6: {
247 ERROR_LEVEL_L: 1088,
248 ERROR_LEVEL_M: 864,
249 ERROR_LEVEL_Q: 608,
250 ERROR_LEVEL_H: 480,
251 },
252 7: {
253 ERROR_LEVEL_L: 1248,
254 ERROR_LEVEL_M: 992,
255 ERROR_LEVEL_Q: 704,
256 ERROR_LEVEL_H: 528,
257 },
258 8: {
259 ERROR_LEVEL_L: 1552,
260 ERROR_LEVEL_M: 1232,
261 ERROR_LEVEL_Q: 880,
262 ERROR_LEVEL_H: 688,
263 },
264 9: {
265 ERROR_LEVEL_L: 1856,
266 ERROR_LEVEL_M: 1456,
267 ERROR_LEVEL_Q: 1056,
268 ERROR_LEVEL_H: 800,
269 },
270 10: {
271 ERROR_LEVEL_L: 2192,
272 ERROR_LEVEL_M: 1728,
273 ERROR_LEVEL_Q: 1232,
274 ERROR_LEVEL_H: 976,
275 },
276 11: {
277 ERROR_LEVEL_L: 2592,
278 ERROR_LEVEL_M: 2032,
279 ERROR_LEVEL_Q: 1440,
280 ERROR_LEVEL_H: 1120,
281 },
282 12: {
283 ERROR_LEVEL_L: 2960,
284 ERROR_LEVEL_M: 2320,
285 ERROR_LEVEL_Q: 1648,
286 ERROR_LEVEL_H: 1264,
287 },
288 13: {
289 ERROR_LEVEL_L: 3424,
290 ERROR_LEVEL_M: 2672,
291 ERROR_LEVEL_Q: 1952,
292 ERROR_LEVEL_H: 1440,
293 },
294 14: {
295 ERROR_LEVEL_L: 3688,
296 ERROR_LEVEL_M: 2920,
297 ERROR_LEVEL_Q: 2088,
298 ERROR_LEVEL_H: 1576,
299 },
300 15: {
301 ERROR_LEVEL_L: 4184,
302 ERROR_LEVEL_M: 3320,
303 ERROR_LEVEL_Q: 2360,
304 ERROR_LEVEL_H: 1784,
305 },
306 16: {
307 ERROR_LEVEL_L: 4712,
308 ERROR_LEVEL_M: 3624,
309 ERROR_LEVEL_Q: 2600,
310 ERROR_LEVEL_H: 2024,
311 },
312 17: {
313 ERROR_LEVEL_L: 5176,
314 ERROR_LEVEL_M: 4056,
315 ERROR_LEVEL_Q: 2936,
316 ERROR_LEVEL_H: 2264,
317 },
318 18: {
319 ERROR_LEVEL_L: 5768,
320 ERROR_LEVEL_M: 4504,
321 ERROR_LEVEL_Q: 3176,
322 ERROR_LEVEL_H: 2504,
323 },
324 19: {
325 ERROR_LEVEL_L: 6360,
326 ERROR_LEVEL_M: 5016,
327 ERROR_LEVEL_Q: 3560,
328 ERROR_LEVEL_H: 2728,
329 },
330 20: {
331 ERROR_LEVEL_L: 6888,
332 ERROR_LEVEL_M: 5352,
333 ERROR_LEVEL_Q: 3880,
334 ERROR_LEVEL_H: 3080,
335 },
336 21: {
337 ERROR_LEVEL_L: 7456,
338 ERROR_LEVEL_M: 5712,
339 ERROR_LEVEL_Q: 4096,
340 ERROR_LEVEL_H: 3248,
341 },
342 22: {
343 ERROR_LEVEL_L: 8048,
344 ERROR_LEVEL_M: 6256,
345 ERROR_LEVEL_Q: 4544,
346 ERROR_LEVEL_H: 3536,
347 },
348 23: {
349 ERROR_LEVEL_L: 8752,
350 ERROR_LEVEL_M: 6880,
351 ERROR_LEVEL_Q: 4912,
352 ERROR_LEVEL_H: 3712,
353 },
354 24: {
355 ERROR_LEVEL_L: 9392,
356 ERROR_LEVEL_M: 7312,
357 ERROR_LEVEL_Q: 5312,
358 ERROR_LEVEL_H: 4112,
359 },
360 25: {
361 ERROR_LEVEL_L: 10208,
362 ERROR_LEVEL_M: 8000,
363 ERROR_LEVEL_Q: 5744,
364 ERROR_LEVEL_H: 4304,
365 },
366 26: {
367 ERROR_LEVEL_L: 10960,
368 ERROR_LEVEL_M: 8496,
369 ERROR_LEVEL_Q: 6032,
370 ERROR_LEVEL_H: 4768,
371 },
372 27: {
373 ERROR_LEVEL_L: 11744,
374 ERROR_LEVEL_M: 9024,
375 ERROR_LEVEL_Q: 6464,
376 ERROR_LEVEL_H: 5024,
377 },
378 28: {
379 ERROR_LEVEL_L: 12248,
380 ERROR_LEVEL_M: 9544,
381 ERROR_LEVEL_Q: 6968,
382 ERROR_LEVEL_H: 5288,
383 },
384 29: {
385 ERROR_LEVEL_L: 13048,
386 ERROR_LEVEL_M: 10136,
387 ERROR_LEVEL_Q: 7288,
388 ERROR_LEVEL_H: 5608,
389 },
390 30: {
391 ERROR_LEVEL_L: 13880,
392 ERROR_LEVEL_M: 10984,
393 ERROR_LEVEL_Q: 7880,
394 ERROR_LEVEL_H: 5960,
395 },
396 31: {
397 ERROR_LEVEL_L: 14744,
398 ERROR_LEVEL_M: 11640,
399 ERROR_LEVEL_Q: 8264,
400 ERROR_LEVEL_H: 6344,
401 },
402 32: {
403 ERROR_LEVEL_L: 15640,
404 ERROR_LEVEL_M: 12328,
405 ERROR_LEVEL_Q: 8920,
406 ERROR_LEVEL_H: 6760,
407 },
408 33: {
409 ERROR_LEVEL_L: 16568,
410 ERROR_LEVEL_M: 13048,
411 ERROR_LEVEL_Q: 9368,
412 ERROR_LEVEL_H: 7208,
413 },
414 34: {
415 ERROR_LEVEL_L: 17528,
416 ERROR_LEVEL_M: 13800,
417 ERROR_LEVEL_Q: 9848,
418 ERROR_LEVEL_H: 7688,
419 },
420 35: {
421 ERROR_LEVEL_L: 18448,
422 ERROR_LEVEL_M: 14496,
423 ERROR_LEVEL_Q: 10288,
424 ERROR_LEVEL_H: 7888,
425 },
426 36: {
427 ERROR_LEVEL_L: 19472,
428 ERROR_LEVEL_M: 15312,
429 ERROR_LEVEL_Q: 10832,
430 ERROR_LEVEL_H: 8432,
431 },
432 37: {
433 ERROR_LEVEL_L: 20528,
434 ERROR_LEVEL_M: 15936,
435 ERROR_LEVEL_Q: 11408,
436 ERROR_LEVEL_H: 8768,
437 },
438 38: {
439 ERROR_LEVEL_L: 21616,
440 ERROR_LEVEL_M: 16816,
441 ERROR_LEVEL_Q: 12016,
442 ERROR_LEVEL_H: 9136,
443 },
444 39: {
445 ERROR_LEVEL_L: 22496,
446 ERROR_LEVEL_M: 17728,
447 ERROR_LEVEL_Q: 12656,
448 ERROR_LEVEL_H: 9776,
449 },
450 40: {
451 ERROR_LEVEL_L: 23648,
452 ERROR_LEVEL_M: 18672,
453 ERROR_LEVEL_Q: 13328,
454 ERROR_LEVEL_H: 10208,
455 },
271456}
272457
273458
274459# ISO/IEC 23941:2022(E) - 7.4.10 Bit stream to codeword conversion (page 25)
275460# Table 6 — Number of symbol characters and input data capacity for rMQR
276461RSYMBOL_CAPACITY = {
277 'R7x43': {ERROR_LEVEL_M: 48, ERROR_LEVEL_H: 24},
278 'R7x59': {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56},
279 'R7x77': {ERROR_LEVEL_M: 160, ERROR_LEVEL_H: 80},
280 'R7x99': {ERROR_LEVEL_M: 224, ERROR_LEVEL_H: 112},
281 'R7x139': {ERROR_LEVEL_M: 352, ERROR_LEVEL_H: 192},
282 'R9x43': {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56},
283 'R9x59': {ERROR_LEVEL_M: 168, ERROR_LEVEL_H: 88},
284 'R9x77': {ERROR_LEVEL_M: 248, ERROR_LEVEL_H: 136},
285 'R9x99': {ERROR_LEVEL_M: 336, ERROR_LEVEL_H: 176},
286 'R9x139': {ERROR_LEVEL_M: 504, ERROR_LEVEL_H: 264},
287 'R11x27': {ERROR_LEVEL_M: 56, ERROR_LEVEL_H: 40},
288 'R11x43': {ERROR_LEVEL_M: 152, ERROR_LEVEL_H: 88},
289 'R11x59': {ERROR_LEVEL_M: 248, ERROR_LEVEL_H: 120},
290 'R11x77': {ERROR_LEVEL_M: 344, ERROR_LEVEL_H: 184},
291 'R11x99': {ERROR_LEVEL_M: 456, ERROR_LEVEL_H: 232},
292 'R11x139': {ERROR_LEVEL_M: 672, ERROR_LEVEL_H: 336},
293 'R13x27': {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56},
294 'R13x43': {ERROR_LEVEL_M: 216, ERROR_LEVEL_H: 104},
295 'R13x59': {ERROR_LEVEL_M: 304, ERROR_LEVEL_H: 160},
296 'R13x77': {ERROR_LEVEL_M: 424, ERROR_LEVEL_H: 232},
297 'R13x99': {ERROR_LEVEL_M: 584, ERROR_LEVEL_H: 280},
298 'R13x139': {ERROR_LEVEL_M: 848, ERROR_LEVEL_H: 432},
299 'R15x43': {ERROR_LEVEL_M: 264, ERROR_LEVEL_H: 120},
300 'R15x59': {ERROR_LEVEL_M: 384, ERROR_LEVEL_H: 208},
301 'R15x77': {ERROR_LEVEL_M: 536, ERROR_LEVEL_H: 248},
302 'R15x99': {ERROR_LEVEL_M: 704, ERROR_LEVEL_H: 384},
303 'R15x139': {ERROR_LEVEL_M: 1016, ERROR_LEVEL_H: 552},
304 'R17x43': {ERROR_LEVEL_M: 312, ERROR_LEVEL_H: 168},
305 'R17x59': {ERROR_LEVEL_M: 448, ERROR_LEVEL_H: 224},
306 'R17x77': {ERROR_LEVEL_M: 624, ERROR_LEVEL_H: 304},
307 'R17x99': {ERROR_LEVEL_M: 800, ERROR_LEVEL_H: 448},
308 'R17x139': {ERROR_LEVEL_M: 1216, ERROR_LEVEL_H: 608},
462 "R7x43": {ERROR_LEVEL_M: 48, ERROR_LEVEL_H: 24},
463 "R7x59": {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56},
464 "R7x77": {ERROR_LEVEL_M: 160, ERROR_LEVEL_H: 80},
465 "R7x99": {ERROR_LEVEL_M: 224, ERROR_LEVEL_H: 112},
466 "R7x139": {ERROR_LEVEL_M: 352, ERROR_LEVEL_H: 192},
467 "R9x43": {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56},
468 "R9x59": {ERROR_LEVEL_M: 168, ERROR_LEVEL_H: 88},
469 "R9x77": {ERROR_LEVEL_M: 248, ERROR_LEVEL_H: 136},
470 "R9x99": {ERROR_LEVEL_M: 336, ERROR_LEVEL_H: 176},
471 "R9x139": {ERROR_LEVEL_M: 504, ERROR_LEVEL_H: 264},
472 "R11x27": {ERROR_LEVEL_M: 56, ERROR_LEVEL_H: 40},
473 "R11x43": {ERROR_LEVEL_M: 152, ERROR_LEVEL_H: 88},
474 "R11x59": {ERROR_LEVEL_M: 248, ERROR_LEVEL_H: 120},
475 "R11x77": {ERROR_LEVEL_M: 344, ERROR_LEVEL_H: 184},
476 "R11x99": {ERROR_LEVEL_M: 456, ERROR_LEVEL_H: 232},
477 "R11x139": {ERROR_LEVEL_M: 672, ERROR_LEVEL_H: 336},
478 "R13x27": {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56},
479 "R13x43": {ERROR_LEVEL_M: 216, ERROR_LEVEL_H: 104},
480 "R13x59": {ERROR_LEVEL_M: 304, ERROR_LEVEL_H: 160},
481 "R13x77": {ERROR_LEVEL_M: 424, ERROR_LEVEL_H: 232},
482 "R13x99": {ERROR_LEVEL_M: 584, ERROR_LEVEL_H: 280},
483 "R13x139": {ERROR_LEVEL_M: 848, ERROR_LEVEL_H: 432},
484 "R15x43": {ERROR_LEVEL_M: 264, ERROR_LEVEL_H: 120},
485 "R15x59": {ERROR_LEVEL_M: 384, ERROR_LEVEL_H: 208},
486 "R15x77": {ERROR_LEVEL_M: 536, ERROR_LEVEL_H: 248},
487 "R15x99": {ERROR_LEVEL_M: 704, ERROR_LEVEL_H: 384},
488 "R15x139": {ERROR_LEVEL_M: 1016, ERROR_LEVEL_H: 552},
489 "R17x43": {ERROR_LEVEL_M: 312, ERROR_LEVEL_H: 168},
490 "R17x59": {ERROR_LEVEL_M: 448, ERROR_LEVEL_H: 224},
491 "R17x77": {ERROR_LEVEL_M: 624, ERROR_LEVEL_H: 304},
492 "R17x99": {ERROR_LEVEL_M: 800, ERROR_LEVEL_H: 448},
493 "R17x139": {ERROR_LEVEL_M: 1216, ERROR_LEVEL_H: 608},
309494}
310495
311496
312497# ISO/IEC 18004:2015(E) -- Table 9 — Error correction characteristics for QR Code (page 38)
313498# ISO/IEC 23941:2022(E) -- Table 8 — Error correction characteristics for rMQR (page 29)
314EC = namedtuple('EC', 'num_blocks num_total num_data')
499EC = namedtuple("EC", "num_blocks num_total num_data")
315500
316501ECC = {
317502 VERSION_M1: {None: (EC(1, 5, 3),)},
318503 VERSION_M2: {ERROR_LEVEL_L: (EC(1, 10, 5),), ERROR_LEVEL_M: (EC(1, 10, 4),)},
319504 VERSION_M3: {ERROR_LEVEL_L: (EC(1, 17, 11),), ERROR_LEVEL_M: (EC(1, 17, 9),)},
320 VERSION_M4: {ERROR_LEVEL_L: (EC(1, 24, 16),), ERROR_LEVEL_M: (EC(1, 24, 14),),
321 ERROR_LEVEL_Q: (EC(1, 24, 10),)},
505 VERSION_M4: {
506 ERROR_LEVEL_L: (EC(1, 24, 16),),
507 ERROR_LEVEL_M: (EC(1, 24, 14),),
508 ERROR_LEVEL_Q: (EC(1, 24, 10),),
509 },
322510 1: {
323 ERROR_LEVEL_L: (EC(1, 26, 19),), ERROR_LEVEL_M: (EC(1, 26, 16),),
324 ERROR_LEVEL_Q: (EC(1, 26, 13),), ERROR_LEVEL_H: (EC(1, 26, 9),)},
511 ERROR_LEVEL_L: (EC(1, 26, 19),),
512 ERROR_LEVEL_M: (EC(1, 26, 16),),
513 ERROR_LEVEL_Q: (EC(1, 26, 13),),
514 ERROR_LEVEL_H: (EC(1, 26, 9),),
515 },
325516 2: {
326 ERROR_LEVEL_L: (EC(1, 44, 34),), ERROR_LEVEL_M: (EC(1, 44, 28),),
327 ERROR_LEVEL_Q: (EC(1, 44, 22),), ERROR_LEVEL_H: (EC(1, 44, 16),)},
517 ERROR_LEVEL_L: (EC(1, 44, 34),),
518 ERROR_LEVEL_M: (EC(1, 44, 28),),
519 ERROR_LEVEL_Q: (EC(1, 44, 22),),
520 ERROR_LEVEL_H: (EC(1, 44, 16),),
521 },
328522 3: {
329 ERROR_LEVEL_L: (EC(1, 70, 55),), ERROR_LEVEL_M: (EC(1, 70, 44),),
330 ERROR_LEVEL_Q: (EC(2, 35, 17),), ERROR_LEVEL_H: (EC(2, 35, 13),)},
523 ERROR_LEVEL_L: (EC(1, 70, 55),),
524 ERROR_LEVEL_M: (EC(1, 70, 44),),
525 ERROR_LEVEL_Q: (EC(2, 35, 17),),
526 ERROR_LEVEL_H: (EC(2, 35, 13),),
527 },
331528 4: {
332 ERROR_LEVEL_L: (EC(1, 100, 80),), ERROR_LEVEL_M: (EC(2, 50, 32),),
333 ERROR_LEVEL_Q: (EC(2, 50, 24),), ERROR_LEVEL_H: (EC(4, 25, 9),)},
529 ERROR_LEVEL_L: (EC(1, 100, 80),),
530 ERROR_LEVEL_M: (EC(2, 50, 32),),
531 ERROR_LEVEL_Q: (EC(2, 50, 24),),
532 ERROR_LEVEL_H: (EC(4, 25, 9),),
533 },
334534 5: {
335 ERROR_LEVEL_L: (EC(1, 134, 108),), ERROR_LEVEL_M: (EC(2, 67, 43),),
535 ERROR_LEVEL_L: (EC(1, 134, 108),),
536 ERROR_LEVEL_M: (EC(2, 67, 43),),
336537 ERROR_LEVEL_Q: (EC(2, 33, 15), EC(2, 34, 16)),
337 ERROR_LEVEL_H: (EC(2, 33, 11), EC(2, 34, 12))},
538 ERROR_LEVEL_H: (EC(2, 33, 11), EC(2, 34, 12)),
539 },
338540 6: {
339 ERROR_LEVEL_L: (EC(2, 86, 68),), ERROR_LEVEL_M: (EC(4, 43, 27),),
340 ERROR_LEVEL_Q: (EC(4, 43, 19),), ERROR_LEVEL_H: (EC(4, 43, 15),)},
541 ERROR_LEVEL_L: (EC(2, 86, 68),),
542 ERROR_LEVEL_M: (EC(4, 43, 27),),
543 ERROR_LEVEL_Q: (EC(4, 43, 19),),
544 ERROR_LEVEL_H: (EC(4, 43, 15),),
545 },
341546 7: {
342 ERROR_LEVEL_L: (EC(2, 98, 78),), ERROR_LEVEL_M: (EC(4, 49, 31),),
547 ERROR_LEVEL_L: (EC(2, 98, 78),),
548 ERROR_LEVEL_M: (EC(4, 49, 31),),
343549 ERROR_LEVEL_Q: (EC(2, 32, 14), EC(4, 33, 15)),
344 ERROR_LEVEL_H: (EC(4, 39, 13), EC(1, 40, 14))},
550 ERROR_LEVEL_H: (EC(4, 39, 13), EC(1, 40, 14)),
551 },
345552 8: {
346553 ERROR_LEVEL_L: (EC(2, 121, 97),),
347554 ERROR_LEVEL_M: (EC(2, 60, 38), EC(2, 61, 39)),
348555 ERROR_LEVEL_Q: (EC(4, 40, 18), EC(2, 41, 19)),
349 ERROR_LEVEL_H: (EC(4, 40, 14), EC(2, 41, 15))},
556 ERROR_LEVEL_H: (EC(4, 40, 14), EC(2, 41, 15)),
557 },
350558 9: {
351559 ERROR_LEVEL_L: (EC(2, 146, 116),),
352560 ERROR_LEVEL_M: (EC(3, 58, 36), EC(2, 59, 37)),
353561 ERROR_LEVEL_Q: (EC(4, 36, 16), EC(4, 37, 17)),
354 ERROR_LEVEL_H: (EC(4, 36, 12), EC(4, 37, 13))},
562 ERROR_LEVEL_H: (EC(4, 36, 12), EC(4, 37, 13)),
563 },
355564 10: {
356565 ERROR_LEVEL_L: (EC(2, 86, 68), EC(2, 87, 69)),
357566 ERROR_LEVEL_M: (EC(4, 69, 43), EC(1, 70, 44)),
358567 ERROR_LEVEL_Q: (EC(6, 43, 19), EC(2, 44, 20)),
359 ERROR_LEVEL_H: (EC(6, 43, 15), EC(2, 44, 16))},
568 ERROR_LEVEL_H: (EC(6, 43, 15), EC(2, 44, 16)),
569 },
360570 11: {
361571 ERROR_LEVEL_L: (EC(4, 101, 81),),
362572 ERROR_LEVEL_M: (EC(1, 80, 50), EC(4, 81, 51)),
363573 ERROR_LEVEL_Q: (EC(4, 50, 22), EC(4, 51, 23)),
364 ERROR_LEVEL_H: (EC(3, 36, 12), EC(8, 37, 13))},
574 ERROR_LEVEL_H: (EC(3, 36, 12), EC(8, 37, 13)),
575 },
365576 12: {
366577 ERROR_LEVEL_L: (EC(2, 116, 92), EC(2, 117, 93)),
367578 ERROR_LEVEL_M: (EC(6, 58, 36), EC(2, 59, 37)),
368579 ERROR_LEVEL_Q: (EC(4, 46, 20), EC(6, 47, 21)),
369 ERROR_LEVEL_H: (EC(7, 42, 14), EC(4, 43, 15))},
580 ERROR_LEVEL_H: (EC(7, 42, 14), EC(4, 43, 15)),
581 },
370582 13: {
371583 ERROR_LEVEL_L: (EC(4, 133, 107),),
372584 ERROR_LEVEL_M: (EC(8, 59, 37), EC(1, 60, 38)),
373585 ERROR_LEVEL_Q: (EC(8, 44, 20), EC(4, 45, 21)),
374 ERROR_LEVEL_H: (EC(12, 33, 11), EC(4, 34, 12))},
586 ERROR_LEVEL_H: (EC(12, 33, 11), EC(4, 34, 12)),
587 },
375588 14: {
376589 ERROR_LEVEL_L: (EC(3, 145, 115), EC(1, 146, 116)),
377590 ERROR_LEVEL_M: (EC(4, 64, 40), EC(5, 65, 41)),
378591 ERROR_LEVEL_Q: (EC(11, 36, 16), EC(5, 37, 17)),
379 ERROR_LEVEL_H: (EC(11, 36, 12), EC(5, 37, 13))},
592 ERROR_LEVEL_H: (EC(11, 36, 12), EC(5, 37, 13)),
593 },
380594 15: {
381595 ERROR_LEVEL_L: (EC(5, 109, 87), EC(1, 110, 88)),
382596 ERROR_LEVEL_M: (EC(5, 65, 41), EC(5, 66, 42)),
383597 ERROR_LEVEL_Q: (EC(5, 54, 24), EC(7, 55, 25)),
384 ERROR_LEVEL_H: (EC(11, 36, 12), EC(7, 37, 13))},
598 ERROR_LEVEL_H: (EC(11, 36, 12), EC(7, 37, 13)),
599 },
385600 16: {
386601 ERROR_LEVEL_L: (EC(5, 122, 98), EC(1, 123, 99)),
387602 ERROR_LEVEL_M: (EC(7, 73, 45), EC(3, 74, 46)),
388603 ERROR_LEVEL_Q: (EC(15, 43, 19), EC(2, 44, 20)),
389 ERROR_LEVEL_H: (EC(3, 45, 15), EC(13, 46, 16))},
604 ERROR_LEVEL_H: (EC(3, 45, 15), EC(13, 46, 16)),
605 },
390606 17: {
391607 ERROR_LEVEL_L: (EC(1, 135, 107), EC(5, 136, 108)),
392608 ERROR_LEVEL_M: (EC(10, 74, 46), EC(1, 75, 47)),
393609 ERROR_LEVEL_Q: (EC(1, 50, 22), EC(15, 51, 23)),
394 ERROR_LEVEL_H: (EC(2, 42, 14), EC(17, 43, 15))},
610 ERROR_LEVEL_H: (EC(2, 42, 14), EC(17, 43, 15)),
611 },
395612 18: {
396613 ERROR_LEVEL_L: (EC(5, 150, 120), EC(1, 151, 121)),
397614 ERROR_LEVEL_M: (EC(9, 69, 43), EC(4, 70, 44)),
398615 ERROR_LEVEL_Q: (EC(17, 50, 22), EC(1, 51, 23)),
399 ERROR_LEVEL_H: (EC(2, 42, 14), EC(19, 43, 15))},
616 ERROR_LEVEL_H: (EC(2, 42, 14), EC(19, 43, 15)),
617 },
400618 19: {
401619 ERROR_LEVEL_L: (EC(3, 141, 113), EC(4, 142, 114)),
402620 ERROR_LEVEL_M: (EC(3, 70, 44), EC(11, 71, 45)),
403621 ERROR_LEVEL_Q: (EC(17, 47, 21), EC(4, 48, 22)),
404 ERROR_LEVEL_H: (EC(9, 39, 13), EC(16, 40, 14))},
622 ERROR_LEVEL_H: (EC(9, 39, 13), EC(16, 40, 14)),
623 },
405624 20: {
406625 ERROR_LEVEL_L: (EC(3, 135, 107), EC(5, 136, 108)),
407626 ERROR_LEVEL_M: (EC(3, 67, 41), EC(13, 68, 42)),
408627 ERROR_LEVEL_Q: (EC(15, 54, 24), EC(5, 55, 25)),
409 ERROR_LEVEL_H: (EC(15, 43, 15), EC(10, 44, 16))},
628 ERROR_LEVEL_H: (EC(15, 43, 15), EC(10, 44, 16)),
629 },
410630 21: {
411631 ERROR_LEVEL_L: (EC(4, 144, 116), EC(4, 145, 117)),
412632 ERROR_LEVEL_M: (EC(17, 68, 42),),
413633 ERROR_LEVEL_Q: (EC(17, 50, 22), EC(6, 51, 23)),
414 ERROR_LEVEL_H: (EC(19, 46, 16), EC(6, 47, 17))},
634 ERROR_LEVEL_H: (EC(19, 46, 16), EC(6, 47, 17)),
635 },
415636 22: {
416637 ERROR_LEVEL_L: (EC(2, 139, 111), EC(7, 140, 112)),
417638 ERROR_LEVEL_M: (EC(17, 74, 46),),
418639 ERROR_LEVEL_Q: (EC(7, 54, 24), EC(16, 55, 25)),
419 ERROR_LEVEL_H: (EC(34, 37, 13),)},
640 ERROR_LEVEL_H: (EC(34, 37, 13),),
641 },
420642 23: {
421643 ERROR_LEVEL_L: (EC(4, 151, 121), EC(5, 152, 122)),
422644 ERROR_LEVEL_M: (EC(4, 75, 47), EC(14, 76, 48)),
423645 ERROR_LEVEL_Q: (EC(11, 54, 24), EC(14, 55, 25)),
424 ERROR_LEVEL_H: (EC(16, 45, 15), EC(14, 46, 16))},
646 ERROR_LEVEL_H: (EC(16, 45, 15), EC(14, 46, 16)),
647 },
425648 24: {
426649 ERROR_LEVEL_L: (EC(6, 147, 117), EC(4, 148, 118)),
427650 ERROR_LEVEL_M: (EC(6, 73, 45), EC(14, 74, 46)),
428651 ERROR_LEVEL_Q: (EC(11, 54, 24), EC(16, 55, 25)),
429 ERROR_LEVEL_H: (EC(30, 46, 16), EC(2, 47, 17))},
652 ERROR_LEVEL_H: (EC(30, 46, 16), EC(2, 47, 17)),
653 },
430654 25: {
431655 ERROR_LEVEL_L: (EC(8, 132, 106), EC(4, 133, 107)),
432656 ERROR_LEVEL_M: (EC(8, 75, 47), EC(13, 76, 48)),
433657 ERROR_LEVEL_Q: (EC(7, 54, 24), EC(22, 55, 25)),
434 ERROR_LEVEL_H: (EC(22, 45, 15), EC(13, 46, 16))},
658 ERROR_LEVEL_H: (EC(22, 45, 15), EC(13, 46, 16)),
659 },
435660 26: {
436661 ERROR_LEVEL_L: (EC(10, 142, 114), EC(2, 143, 115)),
437662 ERROR_LEVEL_M: (EC(19, 74, 46), EC(4, 75, 47)),
438663 ERROR_LEVEL_Q: (EC(28, 50, 22), EC(6, 51, 23)),
439 ERROR_LEVEL_H: (EC(33, 46, 16), EC(4, 47, 17))},
664 ERROR_LEVEL_H: (EC(33, 46, 16), EC(4, 47, 17)),
665 },
440666 27: {
441667 ERROR_LEVEL_L: (EC(8, 152, 122), EC(4, 153, 123)),
442668 ERROR_LEVEL_M: (EC(22, 73, 45), EC(3, 74, 46)),
443669 ERROR_LEVEL_Q: (EC(8, 53, 23), EC(26, 54, 24)),
444 ERROR_LEVEL_H: (EC(12, 45, 15), EC(28, 46, 16))},
670 ERROR_LEVEL_H: (EC(12, 45, 15), EC(28, 46, 16)),
671 },
445672 28: {
446673 ERROR_LEVEL_L: (EC(3, 147, 117), EC(10, 148, 118)),
447674 ERROR_LEVEL_M: (EC(3, 73, 45), EC(23, 74, 46)),
448675 ERROR_LEVEL_Q: (EC(4, 54, 24), EC(31, 55, 25)),
449 ERROR_LEVEL_H: (EC(11, 45, 15), EC(31, 46, 16))},
676 ERROR_LEVEL_H: (EC(11, 45, 15), EC(31, 46, 16)),
677 },
450678 29: {
451679 ERROR_LEVEL_L: (EC(7, 146, 116), EC(7, 147, 117)),
452680 ERROR_LEVEL_M: (EC(21, 73, 45), EC(7, 74, 46)),
453681 ERROR_LEVEL_Q: (EC(1, 53, 23), EC(37, 54, 24)),
454 ERROR_LEVEL_H: (EC(19, 45, 15), EC(26, 46, 16))},
682 ERROR_LEVEL_H: (EC(19, 45, 15), EC(26, 46, 16)),
683 },
455684 30: {
456685 ERROR_LEVEL_L: (EC(5, 145, 115), EC(10, 146, 116)),
457686 ERROR_LEVEL_M: (EC(19, 75, 47), EC(10, 76, 48)),
458687 ERROR_LEVEL_Q: (EC(15, 54, 24), EC(25, 55, 25)),
459 ERROR_LEVEL_H: (EC(23, 45, 15), EC(25, 46, 16))},
688 ERROR_LEVEL_H: (EC(23, 45, 15), EC(25, 46, 16)),
689 },
460690 31: {
461691 ERROR_LEVEL_L: (EC(13, 145, 115), EC(3, 146, 116)),
462692 ERROR_LEVEL_M: (EC(2, 74, 46), EC(29, 75, 47)),
463693 ERROR_LEVEL_Q: (EC(42, 54, 24), EC(1, 55, 25)),
464 ERROR_LEVEL_H: (EC(23, 45, 15), EC(28, 46, 16))},
694 ERROR_LEVEL_H: (EC(23, 45, 15), EC(28, 46, 16)),
695 },
465696 32: {
466697 ERROR_LEVEL_L: (EC(17, 145, 115),),
467698 ERROR_LEVEL_M: (EC(10, 74, 46), EC(23, 75, 47)),
468699 ERROR_LEVEL_Q: (EC(10, 54, 24), EC(35, 55, 25)),
469 ERROR_LEVEL_H: (EC(19, 45, 15), EC(35, 46, 16))},
700 ERROR_LEVEL_H: (EC(19, 45, 15), EC(35, 46, 16)),
701 },
470702 33: {
471703 ERROR_LEVEL_L: (EC(17, 145, 115), EC(1, 146, 116)),
472704 ERROR_LEVEL_M: (EC(14, 74, 46), EC(21, 75, 47)),
473705 ERROR_LEVEL_Q: (EC(29, 54, 24), EC(19, 55, 25)),
474 ERROR_LEVEL_H: (EC(11, 45, 15), EC(46, 46, 16))},
706 ERROR_LEVEL_H: (EC(11, 45, 15), EC(46, 46, 16)),
707 },
475708 34: {
476709 ERROR_LEVEL_L: (EC(13, 145, 115), EC(6, 146, 116)),
477710 ERROR_LEVEL_M: (EC(14, 74, 46), EC(23, 75, 47)),
478711 ERROR_LEVEL_Q: (EC(44, 54, 24), EC(7, 55, 25)),
479 ERROR_LEVEL_H: (EC(59, 46, 16), EC(1, 47, 17))},
712 ERROR_LEVEL_H: (EC(59, 46, 16), EC(1, 47, 17)),
713 },
480714 35: {
481715 ERROR_LEVEL_L: (EC(12, 151, 121), EC(7, 152, 122)),
482716 ERROR_LEVEL_M: (EC(12, 75, 47), EC(26, 76, 48)),
483717 ERROR_LEVEL_Q: (EC(39, 54, 24), EC(14, 55, 25)),
484 ERROR_LEVEL_H: (EC(22, 45, 15), EC(41, 46, 16))},
718 ERROR_LEVEL_H: (EC(22, 45, 15), EC(41, 46, 16)),
719 },
485720 36: {
486721 ERROR_LEVEL_L: (EC(6, 151, 121), EC(14, 152, 122)),
487722 ERROR_LEVEL_M: (EC(6, 75, 47), EC(34, 76, 48)),
488723 ERROR_LEVEL_Q: (EC(46, 54, 24), EC(10, 55, 25)),
489 ERROR_LEVEL_H: (EC(2, 45, 15), EC(64, 46, 16))},
724 ERROR_LEVEL_H: (EC(2, 45, 15), EC(64, 46, 16)),
725 },
490726 37: {
491727 ERROR_LEVEL_L: (EC(17, 152, 122), EC(4, 153, 123)),
492728 ERROR_LEVEL_M: (EC(29, 74, 46), EC(14, 75, 47)),
493729 ERROR_LEVEL_Q: (EC(49, 54, 24), EC(10, 55, 25)),
494 ERROR_LEVEL_H: (EC(24, 45, 15), EC(46, 46, 16))},
730 ERROR_LEVEL_H: (EC(24, 45, 15), EC(46, 46, 16)),
731 },
495732 38: {
496733 ERROR_LEVEL_L: (EC(4, 152, 122), EC(18, 153, 123)),
497734 ERROR_LEVEL_M: (EC(13, 74, 46), EC(32, 75, 47)),
498735 ERROR_LEVEL_Q: (EC(48, 54, 24), EC(14, 55, 25)),
499 ERROR_LEVEL_H: (EC(42, 45, 15), EC(32, 46, 16))},
736 ERROR_LEVEL_H: (EC(42, 45, 15), EC(32, 46, 16)),
737 },
500738 39: {
501739 ERROR_LEVEL_L: (EC(20, 147, 117), EC(4, 148, 118)),
502740 ERROR_LEVEL_M: (EC(40, 75, 47), EC(7, 76, 48)),
503741 ERROR_LEVEL_Q: (EC(43, 54, 24), EC(22, 55, 25)),
504 ERROR_LEVEL_H: (EC(10, 45, 15), EC(67, 46, 16))},
742 ERROR_LEVEL_H: (EC(10, 45, 15), EC(67, 46, 16)),
743 },
505744 40: {
506745 ERROR_LEVEL_L: (EC(19, 148, 118), EC(6, 149, 119)),
507746 ERROR_LEVEL_M: (EC(18, 75, 47), EC(31, 76, 48)),
508747 ERROR_LEVEL_Q: (EC(34, 54, 24), EC(34, 55, 25)),
509 ERROR_LEVEL_H: (EC(20, 45, 15), EC(61, 46, 16))},
510 'R7x43': {ERROR_LEVEL_M: 48, ERROR_LEVEL_H: 24},
511 'R7x59': {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56},
512 'R7x77': {ERROR_LEVEL_M: 160, ERROR_LEVEL_H: 80},
513 'R7x99': {ERROR_LEVEL_M: 224, ERROR_LEVEL_H: 112},
514 'R7x139': {ERROR_LEVEL_M: 352, ERROR_LEVEL_H: 192},
515 'R9x43': {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56},
516 'R9x59': {ERROR_LEVEL_M: 168, ERROR_LEVEL_H: 88},
517 'R9x77': {ERROR_LEVEL_M: 248, ERROR_LEVEL_H: 136},
518 'R9x99': {ERROR_LEVEL_M: 336, ERROR_LEVEL_H: 176},
519 'R9x139': {ERROR_LEVEL_M: 504, ERROR_LEVEL_H: 264},
520 'R11x27': {ERROR_LEVEL_M: 56, ERROR_LEVEL_H: 40},
521 'R11x43': {ERROR_LEVEL_M: 152, ERROR_LEVEL_H: 88},
522 'R11x59': {ERROR_LEVEL_M: 248, ERROR_LEVEL_H: 120},
523 'R11x77': {ERROR_LEVEL_M: 344, ERROR_LEVEL_H: 184},
524 'R11x99': {ERROR_LEVEL_M: 456, ERROR_LEVEL_H: 232},
525 'R11x139': {ERROR_LEVEL_M: 672, ERROR_LEVEL_H: 336},
526 'R13x27': {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56},
527 'R13x43': {ERROR_LEVEL_M: 216, ERROR_LEVEL_H: 104},
528 'R13x59': {ERROR_LEVEL_M: 304, ERROR_LEVEL_H: 160},
529 'R13x77': {ERROR_LEVEL_M: 424, ERROR_LEVEL_H: 232},
530 'R13x99': {ERROR_LEVEL_M: 584, ERROR_LEVEL_H: 280},
531 'R13x139': {ERROR_LEVEL_M: 848, ERROR_LEVEL_H: 432},
532 'R15x43': {ERROR_LEVEL_M: 264, ERROR_LEVEL_H: 120},
533 'R15x59': {ERROR_LEVEL_M: 384, ERROR_LEVEL_H: 208},
534 'R15x77': {ERROR_LEVEL_M: 536, ERROR_LEVEL_H: 248},
535 'R15x99': {ERROR_LEVEL_M: 704, ERROR_LEVEL_H: 384},
536 'R15x139': {ERROR_LEVEL_M: 1016, ERROR_LEVEL_H: 552},
537 'R17x43': {ERROR_LEVEL_M: 312, ERROR_LEVEL_H: 168},
538 'R17x59': {ERROR_LEVEL_M: 448, ERROR_LEVEL_H: 224},
539 'R17x77': {ERROR_LEVEL_M: 624, ERROR_LEVEL_H: 304},
540 'R17x99': {ERROR_LEVEL_M: 800, ERROR_LEVEL_H: 448},
541 'R17x139': {ERROR_LEVEL_M: 1216, ERROR_LEVEL_H: 608},
748 ERROR_LEVEL_H: (EC(20, 45, 15), EC(61, 46, 16)),
749 },
750 "R7x43": {ERROR_LEVEL_M: 48, ERROR_LEVEL_H: 24},
751 "R7x59": {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56},
752 "R7x77": {ERROR_LEVEL_M: 160, ERROR_LEVEL_H: 80},
753 "R7x99": {ERROR_LEVEL_M: 224, ERROR_LEVEL_H: 112},
754 "R7x139": {ERROR_LEVEL_M: 352, ERROR_LEVEL_H: 192},
755 "R9x43": {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56},
756 "R9x59": {ERROR_LEVEL_M: 168, ERROR_LEVEL_H: 88},
757 "R9x77": {ERROR_LEVEL_M: 248, ERROR_LEVEL_H: 136},
758 "R9x99": {ERROR_LEVEL_M: 336, ERROR_LEVEL_H: 176},
759 "R9x139": {ERROR_LEVEL_M: 504, ERROR_LEVEL_H: 264},
760 "R11x27": {ERROR_LEVEL_M: 56, ERROR_LEVEL_H: 40},
761 "R11x43": {ERROR_LEVEL_M: 152, ERROR_LEVEL_H: 88},
762 "R11x59": {ERROR_LEVEL_M: 248, ERROR_LEVEL_H: 120},
763 "R11x77": {ERROR_LEVEL_M: 344, ERROR_LEVEL_H: 184},
764 "R11x99": {ERROR_LEVEL_M: 456, ERROR_LEVEL_H: 232},
765 "R11x139": {ERROR_LEVEL_M: 672, ERROR_LEVEL_H: 336},
766 "R13x27": {ERROR_LEVEL_M: 96, ERROR_LEVEL_H: 56},
767 "R13x43": {ERROR_LEVEL_M: 216, ERROR_LEVEL_H: 104},
768 "R13x59": {ERROR_LEVEL_M: 304, ERROR_LEVEL_H: 160},
769 "R13x77": {ERROR_LEVEL_M: 424, ERROR_LEVEL_H: 232},
770 "R13x99": {ERROR_LEVEL_M: 584, ERROR_LEVEL_H: 280},
771 "R13x139": {ERROR_LEVEL_M: 848, ERROR_LEVEL_H: 432},
772 "R15x43": {ERROR_LEVEL_M: 264, ERROR_LEVEL_H: 120},
773 "R15x59": {ERROR_LEVEL_M: 384, ERROR_LEVEL_H: 208},
774 "R15x77": {ERROR_LEVEL_M: 536, ERROR_LEVEL_H: 248},
775 "R15x99": {ERROR_LEVEL_M: 704, ERROR_LEVEL_H: 384},
776 "R15x139": {ERROR_LEVEL_M: 1016, ERROR_LEVEL_H: 552},
777 "R17x43": {ERROR_LEVEL_M: 312, ERROR_LEVEL_H: 168},
778 "R17x59": {ERROR_LEVEL_M: 448, ERROR_LEVEL_H: 224},
779 "R17x77": {ERROR_LEVEL_M: 624, ERROR_LEVEL_H: 304},
780 "R17x99": {ERROR_LEVEL_M: 800, ERROR_LEVEL_H: 448},
781 "R17x139": {ERROR_LEVEL_M: 1216, ERROR_LEVEL_H: 608},
542782}
543783
544784
……@@ -546,40 +786,210 @@ ECC = {
546786# Table C.1 — Valid format information bit sequences (page 80)
547787FORMAT_INFO = (
548788 # M: mask 0, mask 1 .. 7
549 0x5412, 0x5125, 0x5e7c, 0x5b4b, 0x45f9, 0x40ce, 0x4f97, 0x4aa0,
789 0x5412,
790 0x5125,
791 0x5E7C,
792 0x5B4B,
793 0x45F9,
794 0x40CE,
795 0x4F97,
796 0x4AA0,
550797 # L
551 0x77c4, 0x72f3, 0x7daa, 0x789d, 0x662f, 0x6318, 0x6c41, 0x6976,
798 0x77C4,
799 0x72F3,
800 0x7DAA,
801 0x789D,
802 0x662F,
803 0x6318,
804 0x6C41,
805 0x6976,
552806 # H
553 0x1689, 0x13be, 0x1ce7, 0x19d0, 0x0762, 0x0255, 0x0d0c, 0x083b,
807 0x1689,
808 0x13BE,
809 0x1CE7,
810 0x19D0,
811 0x0762,
812 0x0255,
813 0x0D0C,
814 0x083B,
554815 # Q
555 0x355f, 0x3068, 0x3f31, 0x3a06, 0x24b4, 0x2183, 0x2eda, 0x2bed,
816 0x355F,
817 0x3068,
818 0x3F31,
819 0x3A06,
820 0x24B4,
821 0x2183,
822 0x2EDA,
823 0x2BED,
556824)
557825
558826FORMAT_INFO_MICRO = (
559 0x4445, 0x4172, 0x4e2b, 0x4b1c, 0x55ae, 0x5099, 0x5fc0, 0x5af7,
560 0x6793, 0x62a4, 0x6dfd, 0x68ca, 0x7678, 0x734f, 0x7c16, 0x7921,
561 0x06de, 0x03e9, 0x0cb0, 0x0987, 0x1735, 0x1202, 0x1d5b, 0x186c,
562 0x2508, 0x203f, 0x2f66, 0x2a51, 0x34e3, 0x31d4, 0x3e8d, 0x3bba,
827 0x4445,
828 0x4172,
829 0x4E2B,
830 0x4B1C,
831 0x55AE,
832 0x5099,
833 0x5FC0,
834 0x5AF7,
835 0x6793,
836 0x62A4,
837 0x6DFD,
838 0x68CA,
839 0x7678,
840 0x734F,
841 0x7C16,
842 0x7921,
843 0x06DE,
844 0x03E9,
845 0x0CB0,
846 0x0987,
847 0x1735,
848 0x1202,
849 0x1D5B,
850 0x186C,
851 0x2508,
852 0x203F,
853 0x2F66,
854 0x2A51,
855 0x34E3,
856 0x31D4,
857 0x3E8D,
858 0x3BBA,
563859)
564860
565861FORMAT_INFO_RECT_MICRO_LEFT = (
566 0x1faB2, 0x1e597, 0x1dBdd, 0x1c4f8, 0x1B86c, 0x1a749, 0x19903, 0x18626, 0x17f0e, 0x1602B,
567 0x15e61, 0x14144, 0x13dd0, 0x122f5, 0x11cBf, 0x1039a, 0x0f1ca, 0x0eeef, 0x0d0a5, 0x0cf80,
568 0x0B314, 0x0ac31, 0x0927B, 0x08d5e, 0x07476, 0x06B53, 0x05519, 0x04a3c, 0x036a8, 0x0298d,
569 0x017c7, 0x008e2, 0x3f367, 0x3ec42, 0x3d208, 0x3cd2d, 0x3B1B9, 0x3ae9c, 0x390d6, 0x38ff3,
570 0x376dB, 0x369fe, 0x357B4, 0x34891, 0x33405, 0x32B20, 0x3156a, 0x30a4f, 0x2f81f, 0x2e73a,
571 0x2d970, 0x2c655, 0x2Bac1, 0x2a5e4, 0x29Bae, 0x2848B, 0x27da3, 0x26286, 0x25ccc, 0x243e9,
572 0x23f7d, 0x22058, 0x21e12, 0x20137
862 0x1FAB2,
863 0x1E597,
864 0x1DBDD,
865 0x1C4F8,
866 0x1B86C,
867 0x1A749,
868 0x19903,
869 0x18626,
870 0x17F0E,
871 0x1602B,
872 0x15E61,
873 0x14144,
874 0x13DD0,
875 0x122F5,
876 0x11CBF,
877 0x1039A,
878 0x0F1CA,
879 0x0EEEF,
880 0x0D0A5,
881 0x0CF80,
882 0x0B314,
883 0x0AC31,
884 0x0927B,
885 0x08D5E,
886 0x07476,
887 0x06B53,
888 0x05519,
889 0x04A3C,
890 0x036A8,
891 0x0298D,
892 0x017C7,
893 0x008E2,
894 0x3F367,
895 0x3EC42,
896 0x3D208,
897 0x3CD2D,
898 0x3B1B9,
899 0x3AE9C,
900 0x390D6,
901 0x38FF3,
902 0x376DB,
903 0x369FE,
904 0x357B4,
905 0x34891,
906 0x33405,
907 0x32B20,
908 0x3156A,
909 0x30A4F,
910 0x2F81F,
911 0x2E73A,
912 0x2D970,
913 0x2C655,
914 0x2BAC1,
915 0x2A5E4,
916 0x29BAE,
917 0x2848B,
918 0x27DA3,
919 0x26286,
920 0x25CCC,
921 0x243E9,
922 0x23F7D,
923 0x22058,
924 0x21E12,
925 0x20137,
573926)
574927
575928FORMAT_INFO_RECT_MICRO_RIGHT = (
576 0x20a7b, 0x2155e, 0x22b14, 0x23431, 0x248a5, 0x25780, 0x269ca, 0x276ef, 0x28fc7, 0x290e2,
577 0x2aea8, 0x2b18d, 0x2cd19, 0x2d23c, 0x2ec76, 0x2f353, 0x30103, 0x31e26, 0x3206c, 0x33f49,
578 0x343dd, 0x35cf8, 0x362b2, 0x37d97, 0x384bf, 0x39b9a, 0x3a5d0, 0x3baf5, 0x3c661, 0x3d944,
579 0x3e70e, 0x3f82b, 0x003ae, 0x01c8b, 0x022c1, 0x03de4, 0x04170, 0x05e55, 0x0601f, 0x07f3a,
580 0x08612, 0x09937, 0x0a77d, 0x0b858, 0x0c4cc, 0x0dbe9, 0x0e5a3, 0x0fa86, 0x108d6, 0x117f3,
581 0x129b9, 0x1369c, 0x14a08, 0x1552d, 0x16b67, 0x17442, 0x18d6a, 0x1924f, 0x1ac05, 0x1b320,
582 0x1cfb4, 0x1d091, 0x1eedb, 0x1f1fe
929 0x20A7B,
930 0x2155E,
931 0x22B14,
932 0x23431,
933 0x248A5,
934 0x25780,
935 0x269CA,
936 0x276EF,
937 0x28FC7,
938 0x290E2,
939 0x2AEA8,
940 0x2B18D,
941 0x2CD19,
942 0x2D23C,
943 0x2EC76,
944 0x2F353,
945 0x30103,
946 0x31E26,
947 0x3206C,
948 0x33F49,
949 0x343DD,
950 0x35CF8,
951 0x362B2,
952 0x37D97,
953 0x384BF,
954 0x39B9A,
955 0x3A5D0,
956 0x3BAF5,
957 0x3C661,
958 0x3D944,
959 0x3E70E,
960 0x3F82B,
961 0x003AE,
962 0x01C8B,
963 0x022C1,
964 0x03DE4,
965 0x04170,
966 0x05E55,
967 0x0601F,
968 0x07F3A,
969 0x08612,
970 0x09937,
971 0x0A77D,
972 0x0B858,
973 0x0C4CC,
974 0x0DBE9,
975 0x0E5A3,
976 0x0FA86,
977 0x108D6,
978 0x117F3,
979 0x129B9,
980 0x1369C,
981 0x14A08,
982 0x1552D,
983 0x16B67,
984 0x17442,
985 0x18D6A,
986 0x1924F,
987 0x1AC05,
988 0x1B320,
989 0x1CFB4,
990 0x1D091,
991 0x1EEDB,
992 0x1F1FE,
583993)
584994
585995
……@@ -587,11 +997,40 @@ FORMAT_INFO_RECT_MICRO_RIGHT = (
587997# Table D.1 — Version information bit stream for each version (page 82)
588998VERSION_INFO = (
589999 # Version 7, 8, 9 .. 40
590 0x07c94, 0x085bc, 0x09a99, 0x0a4d3, 0x0bbf6, 0x0c762, 0x0d847, 0x0e60d,
591 0x0f928, 0x10b78, 0x1145d, 0x12a17, 0x13532, 0x149a6, 0x15683, 0x168c9,
592 0x177ec, 0x18ec4, 0x191e1, 0x1afab, 0x1b08e, 0x1cc1a, 0x1d33f, 0x1ed75,
593 0x1f250, 0x209d5, 0x216f0, 0x228ba, 0x2379f, 0x24b0b, 0x2542e, 0x26a64,
594 0x27541, 0x28c69,
1000 0x07C94,
1001 0x085BC,
1002 0x09A99,
1003 0x0A4D3,
1004 0x0BBF6,
1005 0x0C762,
1006 0x0D847,
1007 0x0E60D,
1008 0x0F928,
1009 0x10B78,
1010 0x1145D,
1011 0x12A17,
1012 0x13532,
1013 0x149A6,
1014 0x15683,
1015 0x168C9,
1016 0x177EC,
1017 0x18EC4,
1018 0x191E1,
1019 0x1AFAB,
1020 0x1B08E,
1021 0x1CC1A,
1022 0x1D33F,
1023 0x1ED75,
1024 0x1F250,
1025 0x209D5,
1026 0x216F0,
1027 0x228BA,
1028 0x2379F,
1029 0x24B0B,
1030 0x2542E,
1031 0x26A64,
1032 0x27541,
1033 0x28C69,
5951034)
5961035
5971036
……@@ -664,13 +1103,188 @@ GEN_POLY = {
6641103 15: (8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105),
6651104 16: (120, 104, 107, 109, 102, 161, 76, 3, 91, 191, 147, 169, 182, 194, 225, 120),
6661105 17: (43, 139, 206, 78, 43, 239, 123, 206, 214, 147, 24, 99, 150, 39, 243, 163, 136),
667 18: (215, 234, 158, 94, 184, 97, 118, 170, 79, 187, 152, 148, 252, 179, 5, 98, 96, 153),
668 20: (17, 60, 79, 50, 61, 163, 26, 187, 202, 180, 221, 225, 83, 239, 156, 164, 212, 212, 188, 190),
669 22: (210, 171, 247, 242, 93, 230, 14, 109, 221, 53, 200, 74, 8, 172, 98, 80, 219, 134, 160, 105, 165, 231),
670 24: (229, 121, 135, 48, 211, 117, 251, 126, 159, 180, 169, 152, 192, 226, 228, 218, 111, 0, 117, 232, 87, 96, 227, 21), # noqa: E501
671 26: (173, 125, 158, 2, 103, 182, 118, 17, 145, 201, 111, 28, 165, 53, 161, 21, 245, 142, 13, 102, 48, 227, 153, 145, 218, 70), # noqa: E501
672 28: (168, 223, 200, 104, 224, 234, 108, 180, 110, 190, 195, 147, 205, 27, 232, 201, 21, 43, 245, 87, 42, 195, 212, 119, 242, 37, 9, 123), # noqa: E501
673 30: (41, 173, 145, 152, 216, 31, 179, 182, 50, 48, 110, 86, 239, 96, 222, 125, 42, 173, 226, 193, 224, 130, 156, 37, 251, 216, 238, 40, 192, 180) # noqa: E501
1106 18: (
1107 215,
1108 234,
1109 158,
1110 94,
1111 184,
1112 97,
1113 118,
1114 170,
1115 79,
1116 187,
1117 152,
1118 148,
1119 252,
1120 179,
1121 5,
1122 98,
1123 96,
1124 153,
1125 ),
1126 20: (
1127 17,
1128 60,
1129 79,
1130 50,
1131 61,
1132 163,
1133 26,
1134 187,
1135 202,
1136 180,
1137 221,
1138 225,
1139 83,
1140 239,
1141 156,
1142 164,
1143 212,
1144 212,
1145 188,
1146 190,
1147 ),
1148 22: (
1149 210,
1150 171,
1151 247,
1152 242,
1153 93,
1154 230,
1155 14,
1156 109,
1157 221,
1158 53,
1159 200,
1160 74,
1161 8,
1162 172,
1163 98,
1164 80,
1165 219,
1166 134,
1167 160,
1168 105,
1169 165,
1170 231,
1171 ),
1172 24: (
1173 229,
1174 121,
1175 135,
1176 48,
1177 211,
1178 117,
1179 251,
1180 126,
1181 159,
1182 180,
1183 169,
1184 152,
1185 192,
1186 226,
1187 228,
1188 218,
1189 111,
1190 0,
1191 117,
1192 232,
1193 87,
1194 96,
1195 227,
1196 21,
1197 ),
1198 26: (
1199 173,
1200 125,
1201 158,
1202 2,
1203 103,
1204 182,
1205 118,
1206 17,
1207 145,
1208 201,
1209 111,
1210 28,
1211 165,
1212 53,
1213 161,
1214 21,
1215 245,
1216 142,
1217 13,
1218 102,
1219 48,
1220 227,
1221 153,
1222 145,
1223 218,
1224 70,
1225 ),
1226 28: (
1227 168,
1228 223,
1229 200,
1230 104,
1231 224,
1232 234,
1233 108,
1234 180,
1235 110,
1236 190,
1237 195,
1238 147,
1239 205,
1240 27,
1241 232,
1242 201,
1243 21,
1244 43,
1245 245,
1246 87,
1247 42,
1248 195,
1249 212,
1250 119,
1251 242,
1252 37,
1253 9,
1254 123,
1255 ),
1256 30: (
1257 41,
1258 173,
1259 145,
1260 152,
1261 216,
1262 31,
1263 179,
1264 182,
1265 50,
1266 48,
1267 110,
1268 86,
1269 239,
1270 96,
1271 222,
1272 125,
1273 42,
1274 173,
1275 226,
1276 193,
1277 224,
1278 130,
1279 156,
1280 37,
1281 251,
1282 216,
1283 238,
1284 40,
1285 192,
1286 180,
1287 ),
6741288}
6751289
6761290# Precomputed Galios Log tables
……@@ -679,45 +1293,524 @@ GEN_POLY = {
6791293
6801294# GF(256) log
6811295GALIOS_LOG = (
682 0, 0, 1, 25, 2, 50, 26, 198, 3, 223, 51, 238, 27, 104, 199, 75, 4, 100,
683 224, 14, 52, 141, 239, 129, 28, 193, 105, 248, 200, 8, 76, 113, 5, 138,
684 101, 47, 225, 36, 15, 33, 53, 147, 142, 218, 240, 18, 130, 69, 29, 181,
685 194, 125, 106, 39, 249, 185, 201, 154, 9, 120, 77, 228, 114, 166, 6, 191,
686 139, 98, 102, 221, 48, 253, 226, 152, 37, 179, 16, 145, 34, 136, 54, 208,
687 148, 206, 143, 150, 219, 189, 241, 210, 19, 92, 131, 56, 70, 64, 30, 66,
688 182, 163, 195, 72, 126, 110, 107, 58, 40, 84, 250, 133, 186, 61, 202, 94,
689 155, 159, 10, 21, 121, 43, 78, 212, 229, 172, 115, 243, 167, 87, 7, 112,
690 192, 247, 140, 128, 99, 13, 103, 74, 222, 237, 49, 197, 254, 24, 227, 165,
691 153, 119, 38, 184, 180, 124, 17, 68, 146, 217, 35, 32, 137, 46, 55, 63,
692 209, 91, 149, 188, 207, 205, 144, 135, 151, 178, 220, 252, 190, 97, 242,
693 86, 211, 171, 20, 42, 93, 158, 132, 60, 57, 83, 71, 109, 65, 162, 31, 45,
694 67, 216, 183, 123, 164, 118, 196, 23, 73, 236, 127, 12, 111, 246, 108,
695 161, 59, 82, 41, 157, 85, 170, 251, 96, 134, 177, 187, 204, 62, 90, 203,
696 89, 95, 176, 156, 169, 160, 81, 11, 245, 22, 235, 122, 117, 44, 215, 79,
697 174, 213, 233, 230, 231, 173, 232, 116, 214, 244, 234, 168, 80, 88, 175
1296 0,
1297 0,
1298 1,
1299 25,
1300 2,
1301 50,
1302 26,
1303 198,
1304 3,
1305 223,
1306 51,
1307 238,
1308 27,
1309 104,
1310 199,
1311 75,
1312 4,
1313 100,
1314 224,
1315 14,
1316 52,
1317 141,
1318 239,
1319 129,
1320 28,
1321 193,
1322 105,
1323 248,
1324 200,
1325 8,
1326 76,
1327 113,
1328 5,
1329 138,
1330 101,
1331 47,
1332 225,
1333 36,
1334 15,
1335 33,
1336 53,
1337 147,
1338 142,
1339 218,
1340 240,
1341 18,
1342 130,
1343 69,
1344 29,
1345 181,
1346 194,
1347 125,
1348 106,
1349 39,
1350 249,
1351 185,
1352 201,
1353 154,
1354 9,
1355 120,
1356 77,
1357 228,
1358 114,
1359 166,
1360 6,
1361 191,
1362 139,
1363 98,
1364 102,
1365 221,
1366 48,
1367 253,
1368 226,
1369 152,
1370 37,
1371 179,
1372 16,
1373 145,
1374 34,
1375 136,
1376 54,
1377 208,
1378 148,
1379 206,
1380 143,
1381 150,
1382 219,
1383 189,
1384 241,
1385 210,
1386 19,
1387 92,
1388 131,
1389 56,
1390 70,
1391 64,
1392 30,
1393 66,
1394 182,
1395 163,
1396 195,
1397 72,
1398 126,
1399 110,
1400 107,
1401 58,
1402 40,
1403 84,
1404 250,
1405 133,
1406 186,
1407 61,
1408 202,
1409 94,
1410 155,
1411 159,
1412 10,
1413 21,
1414 121,
1415 43,
1416 78,
1417 212,
1418 229,
1419 172,
1420 115,
1421 243,
1422 167,
1423 87,
1424 7,
1425 112,
1426 192,
1427 247,
1428 140,
1429 128,
1430 99,
1431 13,
1432 103,
1433 74,
1434 222,
1435 237,
1436 49,
1437 197,
1438 254,
1439 24,
1440 227,
1441 165,
1442 153,
1443 119,
1444 38,
1445 184,
1446 180,
1447 124,
1448 17,
1449 68,
1450 146,
1451 217,
1452 35,
1453 32,
1454 137,
1455 46,
1456 55,
1457 63,
1458 209,
1459 91,
1460 149,
1461 188,
1462 207,
1463 205,
1464 144,
1465 135,
1466 151,
1467 178,
1468 220,
1469 252,
1470 190,
1471 97,
1472 242,
1473 86,
1474 211,
1475 171,
1476 20,
1477 42,
1478 93,
1479 158,
1480 132,
1481 60,
1482 57,
1483 83,
1484 71,
1485 109,
1486 65,
1487 162,
1488 31,
1489 45,
1490 67,
1491 216,
1492 183,
1493 123,
1494 164,
1495 118,
1496 196,
1497 23,
1498 73,
1499 236,
1500 127,
1501 12,
1502 111,
1503 246,
1504 108,
1505 161,
1506 59,
1507 82,
1508 41,
1509 157,
1510 85,
1511 170,
1512 251,
1513 96,
1514 134,
1515 177,
1516 187,
1517 204,
1518 62,
1519 90,
1520 203,
1521 89,
1522 95,
1523 176,
1524 156,
1525 169,
1526 160,
1527 81,
1528 11,
1529 245,
1530 22,
1531 235,
1532 122,
1533 117,
1534 44,
1535 215,
1536 79,
1537 174,
1538 213,
1539 233,
1540 230,
1541 231,
1542 173,
1543 232,
1544 116,
1545 214,
1546 244,
1547 234,
1548 168,
1549 80,
1550 88,
1551 175,
6981552)
6991553
7001554# GF(256) antilog
7011555# Inverse of the logarithm table. Maps integer logarithms to members
7021556# of the field.
703GALIOS_EXP = ([
704 1, 2, 4, 8, 16, 32, 64, 128, 29, 58, 116, 232, 205, 135, 19, 38, 76, 152,
705 45, 90, 180, 117, 234, 201, 143, 3, 6, 12, 24, 48, 96, 192, 157, 39, 78,
706 156, 37, 74, 148, 53, 106, 212, 181, 119, 238, 193, 159, 35, 70, 140, 5,
707 10, 20, 40, 80, 160, 93, 186, 105, 210, 185, 111, 222, 161, 95, 190, 97,
708 194, 153, 47, 94, 188, 101, 202, 137, 15, 30, 60, 120, 240, 253, 231, 211,
709 187, 107, 214, 177, 127, 254, 225, 223, 163, 91, 182, 113, 226, 217, 175,
710 67, 134, 17, 34, 68, 136, 13, 26, 52, 104, 208, 189, 103, 206, 129, 31,
711 62, 124, 248, 237, 199, 147, 59, 118, 236, 197, 151, 51, 102, 204, 133, 23,
712 46, 92, 184, 109, 218, 169, 79, 158, 33, 66, 132, 21, 42, 84, 168, 77, 154,
713 41, 82, 164, 85, 170, 73, 146, 57, 114, 228, 213, 183, 115, 230, 209, 191,
714 99, 198, 145, 63, 126, 252, 229, 215, 179, 123, 246, 241, 255, 227, 219,
715 171, 75, 150, 49, 98, 196, 149, 55, 110, 220, 165, 87, 174, 65, 130, 25,
716 50, 100, 200, 141, 7, 14, 28, 56, 112, 224, 221, 167, 83, 166, 81, 162, 89,
717 178, 121, 242, 249, 239, 195, 155, 43, 86, 172, 69, 138, 9, 18, 36, 72,
718 144, 61, 122, 244, 245, 247, 243, 251, 235, 203, 139, 11, 22, 44, 88, 176,
719 125, 250, 233, 207, 131, 27, 54, 108, 216, 173, 71, 142] * 2
720)
1557GALIOS_EXP = [
1558 1,
1559 2,
1560 4,
1561 8,
1562 16,
1563 32,
1564 64,
1565 128,
1566 29,
1567 58,
1568 116,
1569 232,
1570 205,
1571 135,
1572 19,
1573 38,
1574 76,
1575 152,
1576 45,
1577 90,
1578 180,
1579 117,
1580 234,
1581 201,
1582 143,
1583 3,
1584 6,
1585 12,
1586 24,
1587 48,
1588 96,
1589 192,
1590 157,
1591 39,
1592 78,
1593 156,
1594 37,
1595 74,
1596 148,
1597 53,
1598 106,
1599 212,
1600 181,
1601 119,
1602 238,
1603 193,
1604 159,
1605 35,
1606 70,
1607 140,
1608 5,
1609 10,
1610 20,
1611 40,
1612 80,
1613 160,
1614 93,
1615 186,
1616 105,
1617 210,
1618 185,
1619 111,
1620 222,
1621 161,
1622 95,
1623 190,
1624 97,
1625 194,
1626 153,
1627 47,
1628 94,
1629 188,
1630 101,
1631 202,
1632 137,
1633 15,
1634 30,
1635 60,
1636 120,
1637 240,
1638 253,
1639 231,
1640 211,
1641 187,
1642 107,
1643 214,
1644 177,
1645 127,
1646 254,
1647 225,
1648 223,
1649 163,
1650 91,
1651 182,
1652 113,
1653 226,
1654 217,
1655 175,
1656 67,
1657 134,
1658 17,
1659 34,
1660 68,
1661 136,
1662 13,
1663 26,
1664 52,
1665 104,
1666 208,
1667 189,
1668 103,
1669 206,
1670 129,
1671 31,
1672 62,
1673 124,
1674 248,
1675 237,
1676 199,
1677 147,
1678 59,
1679 118,
1680 236,
1681 197,
1682 151,
1683 51,
1684 102,
1685 204,
1686 133,
1687 23,
1688 46,
1689 92,
1690 184,
1691 109,
1692 218,
1693 169,
1694 79,
1695 158,
1696 33,
1697 66,
1698 132,
1699 21,
1700 42,
1701 84,
1702 168,
1703 77,
1704 154,
1705 41,
1706 82,
1707 164,
1708 85,
1709 170,
1710 73,
1711 146,
1712 57,
1713 114,
1714 228,
1715 213,
1716 183,
1717 115,
1718 230,
1719 209,
1720 191,
1721 99,
1722 198,
1723 145,
1724 63,
1725 126,
1726 252,
1727 229,
1728 215,
1729 179,
1730 123,
1731 246,
1732 241,
1733 255,
1734 227,
1735 219,
1736 171,
1737 75,
1738 150,
1739 49,
1740 98,
1741 196,
1742 149,
1743 55,
1744 110,
1745 220,
1746 165,
1747 87,
1748 174,
1749 65,
1750 130,
1751 25,
1752 50,
1753 100,
1754 200,
1755 141,
1756 7,
1757 14,
1758 28,
1759 56,
1760 112,
1761 224,
1762 221,
1763 167,
1764 83,
1765 166,
1766 81,
1767 162,
1768 89,
1769 178,
1770 121,
1771 242,
1772 249,
1773 239,
1774 195,
1775 155,
1776 43,
1777 86,
1778 172,
1779 69,
1780 138,
1781 9,
1782 18,
1783 36,
1784 72,
1785 144,
1786 61,
1787 122,
1788 244,
1789 245,
1790 247,
1791 243,
1792 251,
1793 235,
1794 203,
1795 139,
1796 11,
1797 22,
1798 44,
1799 88,
1800 176,
1801 125,
1802 250,
1803 233,
1804 207,
1805 131,
1806 27,
1807 54,
1808 108,
1809 216,
1810 173,
1811 71,
1812 142,
1813] * 2
7211814
7221815
7231816# Constants for module types
plugin.video.jacktook/lib/vendor/segno/encoder.py +241 -121
……@@ -11,20 +11,22 @@ DOES NOT belong to the public API.
1111
1212"QR Code" and "Micro QR Code" are registered trademarks of DENSO WAVE INCORPORATED.
1313"""
14from operator import itemgetter, gt, lt, xor
15from functools import partial, reduce
16from itertools import islice, chain, product
17import re
18import math
14
1915import codecs
16import math
17import re
18import sys
2019from collections import namedtuple
20from functools import partial, reduce
21from itertools import chain, islice, product, zip_longest
22from operator import gt, itemgetter, lt, xor
23
2124from . import consts
22from itertools import zip_longest
23import sys
25
2426_MAX_PENALTY_SCORE = sys.maxsize
2527del sys
2628
27__all__ = ('encode', 'encode_sequence', 'DataOverflowError')
29__all__ = ("DataOverflowError", "encode", "encode_sequence")
2830
2931
3032class DataOverflowError(ValueError):
……@@ -39,33 +41,47 @@ class DataOverflowError(ValueError):
3941 """
4042
4143
42Code = namedtuple('Code', 'matrix version error mask segments')
44Code = namedtuple("Code", "matrix version error mask segments")
4345
4446
45def encode(content, error=None, version=None, mode=None, mask=None,
46 encoding=None, eci=False, micro=None, boost_error=True):
47def encode(
48 content,
49 error=None,
50 version=None,
51 mode=None,
52 mask=None,
53 encoding=None,
54 eci=False,
55 micro=None,
56 boost_error=True,
57):
4758 version = normalize_version(version)
4859 if not micro and micro is not None and version in consts.MICRO_VERSIONS:
49 raise ValueError(f'A Micro QR Code version ("{get_version_name(version)}") '
50 'is provided but parameter "micro" is False')
60 raise ValueError(
61 f'A Micro QR Code version ("{get_version_name(version)}") '
62 'is provided but parameter "micro" is False'
63 )
5164 if micro and version is not None and version not in consts.MICRO_VERSIONS:
5265 raise ValueError(f'Illegal Micro QR Code version "{get_version_name(version)}"')
5366 error = normalize_errorlevel(error, accept_none=True)
5467 mode = normalize_mode(mode)
55 if mode is not None and version is not None \
56 and not is_mode_supported(mode, version):
57 raise ValueError(f'Mode "{get_mode_name(mode)}" is not available in version "{get_version_name(version)}"')
68 if mode is not None and version is not None and not is_mode_supported(mode, version):
69 raise ValueError(
70 f'Mode "{get_mode_name(mode)}" is not available in version "{get_version_name(version)}"'
71 )
5872 if error == consts.ERROR_LEVEL_H and (micro or version in consts.MICRO_VERSIONS):
5973 raise ValueError('Error correction level "H" is not available for Micro QR Codes')
6074 if eci and (micro or version in consts.MICRO_VERSIONS):
61 raise ValueError('The ECI mode is not available for Micro QR Codes')
75 raise ValueError("The ECI mode is not available for Micro QR Codes")
6276 segments = prepare_data(content, mode, encoding)
6377 guessed_version = find_version(segments, error, eci=eci, micro=micro)
6478 if version is None:
6579 version = guessed_version
6680 elif guessed_version > version:
67 raise DataOverflowError(f'The provided data does not fit into version "{get_version_name(version)}"'
68 f'Proposal: version {get_version_name(guessed_version)}')
81 raise DataOverflowError(
82 f'The provided data does not fit into version "{get_version_name(version)}"'
83 f"Proposal: version {get_version_name(guessed_version)}"
84 )
6985 if error is None and version != consts.VERSION_M1:
7086 error = consts.ERROR_LEVEL_L
7187 is_micro = version < 1
……@@ -73,8 +89,17 @@ def encode(content, error=None, version=None, mode=None, mask=None,
7389 return _encode(segments, error, version, mask, eci, boost_error)
7490
7591
76def encode_sequence(content, error=None, version=None, mode=None, mask=None,
77 encoding=None, eci=False, boost_error=True, symbol_count=None):
92def encode_sequence(
93 content,
94 error=None,
95 version=None,
96 mode=None,
97 mask=None,
98 encoding=None,
99 eci=False,
100 boost_error=True,
101 symbol_count=None,
102):
78103 def one_item_segments(chunk, mode):
79104 segs = Segments()
80105 segs.add_segment(make_segment(chunk, mode=mode, encoding=encoding))
……@@ -82,10 +107,11 @@ def encode_sequence(content, error=None, version=None, mode=None, mask=None,
82107
83108 def divide_into_chunks(data, num):
84109 k, m = divmod(len(data), num)
85 return [data[i * k + min(i, m):(i + 1) * k + min(i + 1, m)] for i in range(num)]
110 return [data[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(num)]
86111
87 def calc_qrcode_bit_length(char_count, ver_range, mode, encoding=None,
88 is_eci=False, is_sa=False):
112 def calc_qrcode_bit_length(
113 char_count, ver_range, mode, encoding=None, is_eci=False, is_sa=False
114 ):
89115 overhead = 4 # Mode indicator for QR Codes, only
90116 # Number of bits in character count indicator
91117 overhead += consts.CHAR_COUNT_INDICATOR_LENGTH[mode][ver_range]
……@@ -112,8 +138,9 @@ def encode_sequence(content, error=None, version=None, mode=None, mask=None,
112138 def number_of_symbols_by_version(content, version, error, mode):
113139 length = len(content)
114140 ver_range = version_range(version)
115 bit_length = calc_qrcode_bit_length(length, ver_range, mode, encoding,
116 is_eci=eci, is_sa=True)
141 bit_length = calc_qrcode_bit_length(
142 length, ver_range, mode, encoding, is_eci=eci, is_sa=True
143 )
117144 capacity = consts.SYMBOL_CAPACITY[version][error]
118145 # Initial result does not contain the overhead of SA mode for all QR Codes
119146 cnt = int(math.ceil(bit_length / capacity))
……@@ -124,12 +151,14 @@ def encode_sequence(content, error=None, version=None, mode=None, mask=None,
124151 version = normalize_version(version)
125152 if version is not None:
126153 if version < 1:
127 raise ValueError('This function does not accept Micro QR Code versions. '
128 f'Provided: "{get_version_name(version)}"')
154 raise ValueError(
155 "This function does not accept Micro QR Code versions. "
156 f'Provided: "{get_version_name(version)}"'
157 )
129158 elif symbol_count is None:
130 raise ValueError('Please provide either a QR Code version or the symbol count')
159 raise ValueError("Please provide either a QR Code version or the symbol count")
131160 if symbol_count is not None and not 1 <= symbol_count <= 16:
132 raise ValueError('The symbol count must be in range 1 .. 16')
161 raise ValueError("The symbol count must be in range 1 .. 16")
133162 error = normalize_errorlevel(error, accept_none=True)
134163 if error is None:
135164 error = consts.ERROR_LEVEL_L
……@@ -147,31 +176,49 @@ def encode_sequence(content, error=None, version=None, mode=None, mask=None,
147176 pass
148177 if guessed_version and guessed_version <= (version or guessed_version):
149178 # Return iterable of size 1
150 return [_encode(segments, error=error, version=(version or guessed_version),
151 mask=mask, eci=eci, boost_error=boost_error)]
179 return [
180 _encode(
181 segments,
182 error=error,
183 version=(version or guessed_version),
184 mask=mask,
185 eci=eci,
186 boost_error=boost_error,
187 )
188 ]
152189 if len(segments.modes) > 1:
153 raise ValueError('This function cannot handle more than one mode (yet). Sorry.')
190 raise ValueError("This function cannot handle more than one mode (yet). Sorry.")
154191 mode = segments.modes[0] # CHANGE iff more than one mode is supported!
155192 # Creating one QR code failed or max_no is not None
156193 if mode == consts.MODE_NUMERIC:
157194 content = str(content)
158195 if symbol_count is not None and len(content) < symbol_count:
159 raise ValueError(f'The content is not long enough to be divided into {symbol_count} symbols')
196 raise ValueError(
197 f"The content is not long enough to be divided into {symbol_count} symbols"
198 )
160199 sa_parity_data = calc_structured_append_parity(content)
161200 num_symbols = symbol_count or 16
162201 if version is not None:
163202 num_symbols = number_of_symbols_by_version(content, version, error, mode)
164203 if num_symbols > 16:
165 raise DataOverflowError(f'The data does not fit into Structured Append version {version}')
204 raise DataOverflowError(f"The data does not fit into Structured Append version {version}")
166205 chunks = divide_into_chunks(content, num_symbols)
167206 if symbol_count is not None:
168207 segments = one_item_segments(max(chunks, key=len), mode)
169208 version = find_version(segments, error, eci=eci, micro=False, is_sa=True)
170 sa_info = partial(_StructuredAppendInfo, total=len(chunks) - 1,
171 parity=sa_parity_data)
172 return [_encode(one_item_segments(chunk, mode), error=error, version=version,
173 mask=mask, eci=eci, boost_error=boost_error,
174 sa_info=sa_info(i)) for i, chunk in enumerate(chunks)]
209 sa_info = partial(_StructuredAppendInfo, total=len(chunks) - 1, parity=sa_parity_data)
210 return [
211 _encode(
212 one_item_segments(chunk, mode),
213 error=error,
214 version=version,
215 mask=mask,
216 eci=eci,
217 boost_error=boost_error,
218 sa_info=sa_info(i),
219 )
220 for i, chunk in enumerate(chunks)
221 ]
175222
176223
177224def _encode(segments, error, version, mask, eci, boost_error, sa_info=None):
……@@ -224,14 +271,18 @@ def _encode(segments, error, version, mask, eci, boost_error, sa_info=None):
224271
225272def boost_error_level(version, error, segments, eci, is_sa=False):
226273 if error not in (consts.ERROR_LEVEL_H, None) and len(segments) == 1:
227 levels = [consts.ERROR_LEVEL_L, consts.ERROR_LEVEL_M,
228 consts.ERROR_LEVEL_Q, consts.ERROR_LEVEL_H]
274 levels = [
275 consts.ERROR_LEVEL_L,
276 consts.ERROR_LEVEL_M,
277 consts.ERROR_LEVEL_Q,
278 consts.ERROR_LEVEL_H,
279 ]
229280 if version < 1:
230281 levels.pop() # H isn't support by Micro QR Codes
231282 if version < consts.VERSION_M4:
232283 levels.pop() # Error level Q isn't supported by M2 and M3
233284 data_length = segments.bit_length_with_overhead(version, eci, is_sa=is_sa)
234 for error_level in levels[levels.index(error) + 1:]:
285 for error_level in levels[levels.index(error) + 1 :]:
235286 if consts.SYMBOL_CAPACITY[version][error_level] >= data_length:
236287 error = error_level
237288 else:
……@@ -243,8 +294,7 @@ def write_segment(buff, segment, ver, ver_range, eci=False):
243294 mode = segment.mode
244295 append_bits = buff.append_bits
245296 # Write ECI header if requested
246 if eci and mode == consts.MODE_BYTE \
247 and segment.encoding != consts.DEFAULT_BYTE_ENCODING:
297 if eci and mode == consts.MODE_BYTE and segment.encoding != consts.DEFAULT_BYTE_ENCODING:
248298 append_bits(consts.MODE_ECI, 4)
249299 append_bits(get_eci_assignment_number(segment.encoding), 8)
250300 if ver is None: # QR Code
……@@ -255,8 +305,7 @@ def write_segment(buff, segment, ver, ver_range, eci=False):
255305 elif ver > consts.VERSION_M1: # Micro QR Code (M1 has no mode indicator)
256306 append_bits(consts.MODE_TO_MICRO_MODE_MAPPING[mode], ver + 3)
257307 # Character count indicator
258 append_bits(segment.char_count,
259 consts.CHAR_COUNT_INDICATOR_LENGTH[mode][ver_range])
308 append_bits(segment.char_count, consts.CHAR_COUNT_INDICATOR_LENGTH[mode][ver_range])
260309 buff.extend(segment.bits)
261310
262311
……@@ -280,20 +329,26 @@ def write_pad_codewords(buff, version, capacity, length):
280329
281330
282331# Finder pattern (includes separator around each side!)
283_FINDER_PATTERN = ((0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0),
284 (0x0, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x0),
285 (0x0, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0),
286 (0x0, 0x1, 0x0, 0x1, 0x1, 0x1, 0x0, 0x1, 0x0),
287 (0x0, 0x1, 0x0, 0x1, 0x1, 0x1, 0x0, 0x1, 0x0),
288 (0x0, 0x1, 0x0, 0x1, 0x1, 0x1, 0x0, 0x1, 0x0),
289 (0x0, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0),
290 (0x0, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x0),
291 (0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0))
332_FINDER_PATTERN = (
333 (0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0),
334 (0x0, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x0),
335 (0x0, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0),
336 (0x0, 0x1, 0x0, 0x1, 0x1, 0x1, 0x0, 0x1, 0x0),
337 (0x0, 0x1, 0x0, 0x1, 0x1, 0x1, 0x0, 0x1, 0x0),
338 (0x0, 0x1, 0x0, 0x1, 0x1, 0x1, 0x0, 0x1, 0x0),
339 (0x0, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0),
340 (0x0, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x1, 0x0),
341 (0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0),
342)
292343
293344
294345def add_finder_patterns(matrix, width, height):
295346 is_square = width == height
296 corners = ((0, 0), (0, len(matrix) - 8), (-8, 0)) # Upper left, upper right, bottom left
347 corners = (
348 (0, 0),
349 (0, len(matrix) - 8),
350 (-8, 0),
351 ) # Upper left, upper right, bottom left
297352 if is_square and width < 21:
298353 corners = ((0, 0),)
299354 finder_range = range(8)
……@@ -301,7 +356,7 @@ def add_finder_patterns(matrix, width, height):
301356 offset = 1 if i == 0 else 0
302357 sepoffset = 0 if j != 0 else 1
303358 for r in finder_range:
304 matrix[i + r][j:j + 8] = _FINDER_PATTERN[offset + r][sepoffset:sepoffset + 8]
359 matrix[i + r][j : j + 8] = _FINDER_PATTERN[offset + r][sepoffset : sepoffset + 8]
305360
306361
307362def add_timing_pattern(matrix, is_micro):
……@@ -316,14 +371,38 @@ def add_timing_pattern(matrix, is_micro):
316371
317372def add_alignment_patterns(matrix, width, height):
318373 is_square = width == height
319 version = (width - 17) // 4 # QR Codes: version * 4 + 17 == width / height of the matrix w/o border
374 version = (
375 width - 17
376 ) // 4 # QR Codes: version * 4 + 17 == width / height of the matrix w/o border
320377 if is_square and version < 2: # QR Codes version < 2 don't have alignment patterns
321378 return
322 pattern = (0x1, 0x1, 0x1, 0x1, 0x1,
323 0x1, 0x0, 0x0, 0x0, 0x1,
324 0x1, 0x0, 0x1, 0x0, 0x1,
325 0x1, 0x0, 0x0, 0x0, 0x1,
326 0x1, 0x1, 0x1, 0x1, 0x1)
379 pattern = (
380 0x1,
381 0x1,
382 0x1,
383 0x1,
384 0x1,
385 0x1,
386 0x0,
387 0x0,
388 0x0,
389 0x1,
390 0x1,
391 0x0,
392 0x1,
393 0x0,
394 0x1,
395 0x1,
396 0x0,
397 0x0,
398 0x0,
399 0x1,
400 0x1,
401 0x1,
402 0x1,
403 0x1,
404 0x1,
405 )
327406 positions = consts.ALIGNMENT_POS[version - 2]
328407 alignment_range = range(5)
329408 min_pos = positions[0]
……@@ -335,7 +414,7 @@ def add_alignment_patterns(matrix, width, height):
335414 # The x and y values represent the center of the alignment pattern
336415 i, j = x - 2, y - 2
337416 for r in alignment_range:
338 matrix[i + r][j:j + 5] = pattern[r * 5:r * 5 + 5]
417 matrix[i + r][j : j + 5] = pattern[r * 5 : r * 5 + 5]
339418
340419
341420def add_codewords(matrix, codewords, version):
……@@ -362,8 +441,10 @@ def add_codewords(matrix, codewords, version):
362441 row[j] = codewords[idx]
363442 idx += 1
364443 if idx != len(codewords): # pragma: no cover
365 raise ValueError('Internal error: Adding codewords to matrix failed. '
366 f'Added {idx} of {len(codewords)} codewords')
444 raise ValueError(
445 "Internal error: Adding codewords to matrix failed. "
446 f"Added {idx} of {len(codewords)} codewords"
447 )
367448
368449
369450def make_final_message(version, error, buff):
……@@ -377,11 +458,25 @@ def make_final_message(version, error, buff):
377458 cw_four = to_binary(data_blocks[0].pop(-1) >> 4, 4)
378459 res = Buffer()
379460 # Write codewords
380 res.extend(chain(*map(to_binary, (x for x in chain.from_iterable(zip_longest(*data_blocks)) if x is not None))))
461 res.extend(
462 chain(
463 *map(
464 to_binary,
465 (x for x in chain.from_iterable(zip_longest(*data_blocks)) if x is not None),
466 )
467 )
468 )
381469 if cw_four is not None:
382470 res.extend(cw_four)
383471 # Write error codewords
384 res.extend(chain(*map(to_binary, (x for x in chain.from_iterable(zip_longest(*error_blocks)) if x is not None))))
472 res.extend(
473 chain(
474 *map(
475 to_binary,
476 (x for x in chain.from_iterable(zip_longest(*error_blocks)) if x is not None),
477 )
478 )
479 )
385480 remainder = 0
386481 if version in (2, 3, 4, 5, 6):
387482 remainder = 7
……@@ -389,7 +484,7 @@ def make_final_message(version, error, buff):
389484 remainder = 3
390485 elif version in (21, 22, 23, 24, 25, 26, 27):
391486 remainder = 4
392 res.extend(b'\0' * remainder)
487 res.extend(b"\0" * remainder)
393488 return res
394489
395490
……@@ -444,8 +539,7 @@ def find_and_apply_best_mask(matrix, width, height, proposed_mask=None):
444539 mask_patterns = get_data_mask_functions(is_micro)
445540 # If the user supplied a mask pattern, the evaluation step is skipped
446541 if proposed_mask is not None:
447 apply_mask(matrix, mask_patterns[proposed_mask], width, height,
448 is_encoding_region)
542 apply_mask(matrix, mask_patterns[proposed_mask], width, height, is_encoding_region)
449543 return proposed_mask, matrix
450544
451545 best_matrix = None
……@@ -483,9 +577,11 @@ def mask_scores(matrix, width, height):
483577 idx = seq.find(n3_pattern)
484578 while idx != -1:
485579 offset = idx + 7
486 if idx in (0, qr_size - 7) \
487 or not any(seq[max(idx - 4, 0):min(idx, qr_size)]) \
488 or not any(seq[max(offset, 0):min(offset + 4, qr_size)]):
580 if (
581 idx in (0, qr_size - 7)
582 or not any(seq[max(idx - 4, 0) : min(idx, qr_size)])
583 or not any(seq[max(offset, 0) : min(offset + 4, qr_size)])
584 ):
489585 count += 40 # N3 = 40
490586 else:
491587 offset = idx + 4
……@@ -543,7 +639,7 @@ def mask_scores(matrix, width, height):
543639 if n1_col_counter >= 5:
544640 score_n1 += n1_col_counter - 2
545641 # N4
546 percent = float(dark_module_counter) / (qr_size ** 2)
642 percent = float(dark_module_counter) / (qr_size**2)
547643 score_n4 = 10 * int(abs(percent * 100 - 50) / 5) # N4 = 10
548644 return score_n1, score_n2, score_n3, score_n4
549645
……@@ -654,7 +750,7 @@ def data_to_bytes(data, encoding):
654750 data = data.encode(encoding)
655751 except UnicodeError:
656752 # Use UTF-8
657 encoding = 'utf-8'
753 encoding = "utf-8"
658754 data = data.encode(encoding)
659755 return data, len(data), encoding
660756
……@@ -668,25 +764,31 @@ def make_segment(data, mode, encoding=None):
668764 if segment_mode is not None:
669765 # Check if user provided mode is applicable for the given segment_data
670766 if segment_mode < guessed_mode:
671 raise ValueError(f'The provided mode "{get_mode_name(segment_mode)}" '
672 f'is not applicable for {segment_data!r}. '
673 f'Proposal: {get_mode_name(guessed_mode)}')
767 raise ValueError(
768 f'The provided mode "{get_mode_name(segment_mode)}" '
769 f"is not applicable for {segment_data!r}. "
770 f"Proposal: {get_mode_name(guessed_mode)}"
771 )
674772 else:
675773 segment_mode = guessed_mode
676774 if segment_mode != consts.MODE_BYTE:
677775 segment_encoding = None
678 char_count = segment_length if segment_mode not in (consts.MODE_KANJI, consts.MODE_HANZI) else segment_length // 2
776 char_count = (
777 segment_length
778 if segment_mode not in (consts.MODE_KANJI, consts.MODE_HANZI)
779 else segment_length // 2
780 )
679781 buff = Buffer()
680782 append_bits = buff.append_bits
681783 if segment_mode == consts.MODE_NUMERIC:
682784 for i in range(0, segment_length, 3):
683 chunk = segment_data[i:i + 3]
785 chunk = segment_data[i : i + 3]
684786 append_bits(int(chunk), len(chunk) * 3 + 1)
685787 elif segment_mode == consts.MODE_ALPHANUMERIC:
686788 # ISO/IEC 18004:2015(E) -- 7.4.4 Alphanumeric mode (page 26)
687789 to_byte = consts.ALPHANUMERIC_CHARS.find
688790 for i in range(0, segment_length, 2):
689 chunk = segment_data[i:i + 2]
791 chunk = segment_data[i : i + 2]
690792 if len(chunk) > 1:
691793 append_bits(to_byte(chunk[0]) * 45 + to_byte(chunk[1]), 11)
692794 else:
……@@ -700,32 +802,32 @@ def make_segment(data, mode, encoding=None):
700802 # Note: len(segment.data)! segment.data_length = len(segment.data) / 2!!
701803 for i in range(0, segment_length, 2):
702804 code = (segment_data[i] << 8) | segment_data[i + 1]
703 if 0xa1a1 <= code <= 0xaafe:
805 if 0xA1A1 <= code <= 0xAAFE:
704806 # For characters with GB2312 values from A1A1HEX to AAFEHEX:
705807 # a) Subtract A1A1HEX from GB2312 value;
706 diff = code - 0xa1a1
707 elif 0xb0a1 <= code <= 0xfafe:
808 diff = code - 0xA1A1
809 elif 0xB0A1 <= code <= 0xFAFE:
708810 # For characters with GB2312 values from B0A1HEX to FAFEHEX:
709811 # a) Subtract A6A1HEX from GB2312 value;
710 diff = code - 0xa6a1
812 diff = code - 0xA6A1
711813 else: # pragma: no cover
712 raise ValueError(f'Invalid Hanzi bytes: {code}')
713 append_bits(((diff >> 8) * 0x60) + (diff & 0xff), 13)
814 raise ValueError(f"Invalid Hanzi bytes: {code}")
815 append_bits(((diff >> 8) * 0x60) + (diff & 0xFF), 13)
714816 else:
715817 # ISO/IEC 18004:2015(E) -- 7.4.6 Kanji mode (page 29)
716818 for i in range(0, segment_length, 2):
717819 code = (segment_data[i] << 8) | segment_data[i + 1]
718 if 0x8140 <= code <= 0x9ffc:
820 if 0x8140 <= code <= 0x9FFC:
719821 # 1. a) For characters with Shift JIS values from 8140HEX to 9FFCHEX:
720822 # Subtract 8140HEX from Shift JIS value;
721823 diff = code - 0x8140
722 elif 0xe040 <= code <= 0xebbf:
824 elif 0xE040 <= code <= 0xEBBF:
723825 # 2. a) For characters with Shift JIS values from E040HEX to EBBFHEX:
724826 # Subtract C140HEX from Shift JIS value;
725 diff = code - 0xc140
827 diff = code - 0xC140
726828 else: # pragma: no cover
727 raise ValueError(f'Invalid Kanji bytes: {code}')
728 append_bits(((diff >> 8) * 0xc0) + (diff & 0xff), 13)
829 raise ValueError(f"Invalid Kanji bytes: {code}")
830 append_bits(((diff >> 8) * 0xC0) + (diff & 0xFF), 13)
729831 return _Segment(buff.getbits(), char_count, segment_mode, segment_encoding)
730832
731833
……@@ -775,8 +877,10 @@ def normalize_version(version):
775877 except (KeyError, AttributeError):
776878 error = True
777879 if error or (not 0 < version < 41 and version not in consts.MICRO_VERSIONS):
778 raise ValueError(f'Unsupported version "{version}". '
779 f'Supported: {", ".join(sorted(consts.MICRO_VERSION_MAPPING.keys()))} and 1 .. 40')
880 raise ValueError(
881 f'Unsupported version "{version}". '
882 f"Supported: {', '.join(sorted(consts.MICRO_VERSION_MAPPING.keys()))} and 1 .. 40"
883 )
780884 return version
781885
782886
……@@ -786,8 +890,10 @@ def normalize_mode(mode):
786890 try:
787891 return consts.MODE_MAPPING[mode.lower()]
788892 except (KeyError, AttributeError):
789 raise ValueError(f'Illegal mode "{mode}". '
790 f'Supported values: {", ".join(sorted(consts.MODE_MAPPING.keys()))}')
893 raise ValueError(
894 f'Illegal mode "{mode}". '
895 f"Supported values: {', '.join(sorted(consts.MODE_MAPPING.keys()))}"
896 )
791897
792898
793899def normalize_mask(mask, is_micro):
……@@ -796,11 +902,15 @@ def normalize_mask(mask, is_micro):
796902 try:
797903 mask = int(mask)
798904 except ValueError:
799 raise ValueError(f'Invalid data mask "{mask}". '
800 'Must be an integer or a string which represents an integer value.')
905 raise ValueError(
906 f'Invalid data mask "{mask}". '
907 "Must be an integer or a string which represents an integer value."
908 )
801909 if is_micro:
802910 if not 0 <= mask < 4:
803 raise ValueError(f'Invalid data mask "{mask}" for Micro QR Code. Must be in range 0 .. 3')
911 raise ValueError(
912 f'Invalid data mask "{mask}" for Micro QR Code. Must be in range 0 .. 3'
913 )
804914 else:
805915 if not 0 <= mask < 8:
806916 raise ValueError(f'Invalid data mask "{mask}". Must be in range 0 .. 7')
……@@ -810,7 +920,7 @@ def normalize_mask(mask, is_micro):
810920def normalize_errorlevel(error, accept_none=False):
811921 if error is None:
812922 if not accept_none:
813 raise ValueError('The error level must be provided')
923 raise ValueError("The error level must be provided")
814924 return error
815925 try:
816926 return consts.ERROR_MAPPING[error.upper()]
……@@ -843,7 +953,7 @@ def get_version_name(version_const):
843953 raise ValueError(f'Unknown version constant "{version_const}"')
844954
845955
846_ALPHANUMERIC_PATTERN = re.compile(br'^[' + re.escape(consts.ALPHANUMERIC_CHARS) + br']+\Z')
956_ALPHANUMERIC_PATTERN = re.compile(rb"^[" + re.escape(consts.ALPHANUMERIC_CHARS) + rb"]+\Z")
847957
848958
849959def is_alphanumeric(data):
……@@ -857,7 +967,7 @@ def is_kanji(data):
857967 data_iter = iter(data)
858968 for i in range(0, data_len, 2):
859969 code = (next(data_iter) << 8) | next(data_iter)
860 if not (0x8140 <= code <= 0x9ffc or 0xe040 <= code <= 0xebbf):
970 if not (0x8140 <= code <= 0x9FFC or 0xE040 <= code <= 0xEBBF):
861971 return False
862972 return True
863973
……@@ -885,16 +995,18 @@ def find_version(segments, error, eci, micro, is_sa=False):
885995 if error is None and version != consts.VERSION_M1:
886996 error = consts.ERROR_LEVEL_L
887997 try:
888 if consts.SYMBOL_CAPACITY[version][error] >= segments.bit_length_with_overhead(version, eci, is_sa):
998 if consts.SYMBOL_CAPACITY[version][error] >= segments.bit_length_with_overhead(
999 version, eci, is_sa
1000 ):
8891001 return version
8901002 except KeyError:
8911003 pass
892 help_txt = ''
1004 help_txt = ""
8931005 if micro is None:
894 help_txt = '(Micro) '
1006 help_txt = "(Micro) "
8951007 elif micro:
896 help_txt = 'Micro '
897 raise DataOverflowError(f'Data too large. No {help_txt}QR Code can handle the provided data')
1008 help_txt = "Micro "
1009 raise DataOverflowError(f"Data too large. No {help_txt}QR Code can handle the provided data")
8981010
8991011
9001012def calc_matrix_size(ver):
……@@ -905,12 +1017,12 @@ def calc_structured_append_parity(content):
9051017 if not isinstance(content, str):
9061018 content = str(content)
9071019 try:
908 data = content.encode('iso-8859-1')
1020 data = content.encode("iso-8859-1")
9091021 except UnicodeError:
9101022 try:
911 data = content.encode('shift-jis')
1023 data = content.encode("shift-jis")
9121024 except (LookupError, UnicodeError):
913 data = content.encode('utf-8')
1025 data = content.encode("utf-8")
9141026 return reduce(xor, data)
9151027
9161028
……@@ -979,7 +1091,7 @@ def get_data_mask_functions(is_micro):
9791091
9801092
9811093class Segments:
982 __slots__ = ('bit_length', 'modes', 'segments')
1094 __slots__ = ("bit_length", "modes", "segments")
9831095
9841096 def __init__(self):
9851097 self.segments = []
……@@ -995,9 +1107,12 @@ class Segments:
9951107 prev_seg = self.segments[-1]
9961108 if prev_seg.mode == segment.mode and prev_seg.encoding == segment.encoding:
9971109 # Merge segment with previous segment
998 segment = _Segment(prev_seg.bits + segment.bits,
999 prev_seg.char_count + segment.char_count,
1000 segment.mode, segment.encoding)
1110 segment = _Segment(
1111 prev_seg.bits + segment.bits,
1112 prev_seg.char_count + segment.char_count,
1113 segment.mode,
1114 segment.encoding,
1115 )
10011116 self.bit_length -= len(prev_seg.bits)
10021117 del self.segments[-1]
10031118 del self.modes[-1]
……@@ -1018,9 +1133,12 @@ class Segments:
10181133 overhead = 0
10191134 # ECI overhead
10201135 if eci:
1021 no_eci_indicators = sum(1 for segment in self.segments
1022 if segment.mode == consts.MODE_BYTE
1023 and segment.encoding != consts.DEFAULT_BYTE_ENCODING)
1136 no_eci_indicators = sum(
1137 1
1138 for segment in self.segments
1139 if segment.mode == consts.MODE_BYTE
1140 and segment.encoding != consts.DEFAULT_BYTE_ENCODING
1141 )
10241142 overhead += no_eci_indicators * 4 # ECI indicator
10251143 overhead += no_eci_indicators * 8 # ECI assignment no
10261144 if is_sa:
……@@ -1051,7 +1169,7 @@ class _Segment(tuple):
10511169
10521170
10531171class Buffer:
1054 __slots__ = ['_data']
1172 __slots__ = ["_data"]
10551173
10561174 def __init__(self, iterable=()):
10571175 self._data = bytearray(iterable)
……@@ -1070,7 +1188,9 @@ class Buffer:
10701188 Returns an iterable of integers interpreting the content of `seq`
10711189 as sequence of binary numbers of length 8.
10721190 """
1073 return (int(''.join(map(str, g)), 2) for g in zip_longest(*[iter(self._data)] * 8, fillvalue=0))
1191 return (
1192 int("".join(map(str, g)), 2) for g in zip_longest(*[iter(self._data)] * 8, fillvalue=0)
1193 )
10741194
10751195 def __len__(self):
10761196 return len(self._data)
plugin.video.jacktook/lib/vendor/segno/utils.py +50 -19
……@@ -10,12 +10,21 @@ Utility functions useful for writers or QR Code objects.
1010
1111DOES NOT belong to the public API.
1212"""
13
1314from itertools import chain, repeat
15
1416from . import consts
1517
16__all__ = ('get_default_border_size', 'get_border', 'get_symbol_size',
17 'check_valid_scale', 'check_valid_border', 'matrix_to_lines',
18 'matrix_iter', 'matrix_iter_verbose')
18__all__ = (
19 "check_valid_border",
20 "check_valid_scale",
21 "get_border",
22 "get_default_border_size",
23 "get_symbol_size",
24 "matrix_iter",
25 "matrix_iter_verbose",
26 "matrix_to_lines",
27)
1928
2029
2130def get_default_border_size(matrix_size):
……@@ -140,10 +149,15 @@ def matrix_iter(matrix, matrix_size, scale=1, border=None):
140149 border = get_border(matrix_size, border)
141150 width, height = matrix_size
142151 border_row = [0x0] * width
143 width_range, height_range = range(-border, width + border), range(-border, height + border)
152 width_range, height_range = (
153 range(-border, width + border),
154 range(-border, height + border),
155 )
144156 for i in height_range:
145157 r = matrix[i] if 0 <= i < height else border_row
146 row = tuple(chain.from_iterable(repeat(r[j] if 0 <= j < width else 0x0, scale) for j in width_range))
158 row = tuple(
159 chain.from_iterable(repeat(r[j] if 0 <= j < width else 0x0, scale) for j in width_range)
160 )
147161 for s in repeat(None, scale):
148162 yield row
149163
……@@ -169,6 +183,7 @@ def matrix_iter_verbose(matrix, matrix_size, scale=1, border=None):
169183 :raises: :py:exc:`ValueError` if an illegal scale or border value is provided
170184 """
171185 from segno import encoder
186
172187 check_valid_border(border)
173188 scale = int(scale)
174189 check_valid_scale(scale)
……@@ -188,37 +203,53 @@ def matrix_iter_verbose(matrix, matrix_size, scale=1, border=None):
188203 # Alignment pattern
189204 alignment_val = alignment_matrix[i][j]
190205 if alignment_val != 0x2:
191 return (consts.TYPE_ALIGNMENT_PATTERN_LIGHT, consts.TYPE_ALIGNMENT_PATTERN_DARK)[alignment_val]
192 if is_square and width > 41: # QR Codes < version 7 do not carry any version information
193 if (i < 6 and width - 12 < j < width - 8) \
194 or (height - 12 < i < height - 8 and j < 6):
206 return (
207 consts.TYPE_ALIGNMENT_PATTERN_LIGHT,
208 consts.TYPE_ALIGNMENT_PATTERN_DARK,
209 )[alignment_val]
210 if (
211 is_square and width > 41
212 ): # QR Codes < version 7 do not carry any version information
213 if (i < 6 and width - 12 < j < width - 8) or (
214 height - 12 < i < height - 8 and j < 6
215 ):
195216 return (consts.TYPE_VERSION_LIGHT, consts.TYPE_VERSION_DARK)[val]
196217 # Dark module
197218 if i == height - 8 and j == 8:
198219 return consts.TYPE_DARKMODULE
199220 # Timing - IMPORTANT: Check alignment (see above) in advance!
200 if (not is_micro and ((i == 6 and 7 < j < width - 8) or (j == 6 and 7 < i < height - 8))) \
201 or (is_micro and ((i == 0 and j > 7) or (j == 0 and i > 7))):
221 if (
222 not is_micro and ((i == 6 and 7 < j < width - 8) or (j == 6 and 7 < i < height - 8))
223 ) or (is_micro and ((i == 0 and j > 7) or (j == 0 and i > 7))):
202224 return (consts.TYPE_TIMING_LIGHT, consts.TYPE_TIMING_DARK)[val]
203225 # Format - IMPORTANT: Check timing (see above) in advance!
204 if (i == 8 and (j < 9 or (not is_micro and j > width - 10))) \
205 or (j == 8 and (i < 8 or (not is_micro and i > height - 9))):
226 if (i == 8 and (j < 9 or (not is_micro and j > width - 10))) or (
227 j == 8 and (i < 8 or (not is_micro and i > height - 9))
228 ):
206229 return (consts.TYPE_FORMAT_LIGHT, consts.TYPE_FORMAT_DARK)[val]
207230 # Finder pattern
208231 # top left top right
209 if (i < 7 and (j < 7 or (not is_micro and j > width - 8))) \
210 or (not is_micro and i > height - 8 and j < 7): # bottom left
211 return (consts.TYPE_FINDER_PATTERN_LIGHT, consts.TYPE_FINDER_PATTERN_DARK)[val]
232 if (i < 7 and (j < 7 or (not is_micro and j > width - 8))) or (
233 not is_micro and i > height - 8 and j < 7
234 ): # bottom left
235 return (
236 consts.TYPE_FINDER_PATTERN_LIGHT,
237 consts.TYPE_FINDER_PATTERN_DARK,
238 )[val]
212239 # Separator
213240 # top left top right
214 if (i < 8 and (j < 8 or (not is_micro and j > width - 9))) \
215 or (not is_micro and (i > height - 9 and j < 8)): # bottom left
241 if (i < 8 and (j < 8 or (not is_micro and j > width - 9))) or (
242 not is_micro and (i > height - 9 and j < 8)
243 ): # bottom left
216244 return consts.TYPE_SEPARATOR
217245 return (consts.TYPE_DATA_LIGHT, consts.TYPE_DATA_DARK)[val]
218246 else:
219247 return consts.TYPE_QUIET_ZONE
220248
221 width_range, height_range = range(-border, width + border), range(-border, height + border)
249 width_range, height_range = (
250 range(-border, width + border),
251 range(-border, height + border),
252 )
222253 for i in height_range:
223254 row = tuple(chain.from_iterable(repeat(get_bit(i, j), scale) for j in width_range))
224255 for s in repeat(None, scale):
plugin.video.jacktook/lib/vendor/segno/writers.py +734 -490
……@@ -5,33 +5,52 @@
55# License: BSD License
66#
77# type: ignore
8import base64
9import codecs
10import functools
11import gzip
812import io
913import re
14import time
1015import zlib
11import codecs
12import base64
13import gzip
14from xml.sax.saxutils import quoteattr, escape
15from struct import pack
16from itertools import chain, repeat
17import functools
18from functools import partial
19from functools import reduce
20from operator import itemgetter
21from contextlib import contextmanager
2216from collections import defaultdict
23import time
24from . import consts
25from .utils import matrix_to_lines, get_symbol_size, get_border, check_valid_scale, check_valid_border, matrix_iter, matrix_iter_verbose
26from itertools import zip_longest
17from contextlib import contextmanager
18from functools import partial, reduce
19from itertools import chain, repeat, zip_longest
20from operator import itemgetter
21from struct import pack
2722from urllib.parse import quote
23from xml.sax.saxutils import escape, quoteattr
2824
29__all__ = ('writable', 'write_svg', 'write_png', 'write_eps', 'write_pdf',
30 'write_txt', 'write_pbm', 'write_pam', 'write_ppm', 'write_xpm',
31 'write_xbm', 'write_tex', 'write_terminal')
25from . import consts
26from .utils import (
27 check_valid_border,
28 check_valid_scale,
29 get_border,
30 get_symbol_size,
31 matrix_iter,
32 matrix_iter_verbose,
33 matrix_to_lines,
34)
35
36__all__ = (
37 "writable",
38 "write_eps",
39 "write_pam",
40 "write_pbm",
41 "write_pdf",
42 "write_png",
43 "write_ppm",
44 "write_svg",
45 "write_terminal",
46 "write_tex",
47 "write_txt",
48 "write_xbm",
49 "write_xpm",
50)
3251
3352# Standard creator name
34CREATOR = 'Segno <https://pypi.org/project/segno/>'
53CREATOR = "Segno <https://pypi.org/project/segno/>"
3554
3655
3756@contextmanager
……@@ -55,21 +74,53 @@ def writable(file_or_path, mode, encoding=None):
5574def colorful(dark, light):
5675 def decorate(f):
5776 @functools.wraps(f)
58 def wrapper(matrix, matrix_size, out, dark=dark, light=light, finder_dark=False, finder_light=False,
59 data_dark=False, data_light=False, version_dark=False, version_light=False,
60 format_dark=False, format_light=False, alignment_dark=False, alignment_light=False,
61 timing_dark=False, timing_light=False, separator=False, dark_module=False,
62 quiet_zone=False, **kw):
63 cm = _make_colormap(*matrix_size, dark=dark, light=light, finder_dark=finder_dark,
64 finder_light=finder_light, data_dark=data_dark,
65 data_light=data_light, version_dark=version_dark,
66 version_light=version_light, format_dark=format_dark,
67 format_light=format_light, alignment_dark=alignment_dark,
68 alignment_light=alignment_light, timing_dark=timing_dark,
69 timing_light=timing_light, separator=separator,
70 dark_module=dark_module, quiet_zone=quiet_zone)
77 def wrapper(
78 matrix,
79 matrix_size,
80 out,
81 dark=dark,
82 light=light,
83 finder_dark=False,
84 finder_light=False,
85 data_dark=False,
86 data_light=False,
87 version_dark=False,
88 version_light=False,
89 format_dark=False,
90 format_light=False,
91 alignment_dark=False,
92 alignment_light=False,
93 timing_dark=False,
94 timing_light=False,
95 separator=False,
96 dark_module=False,
97 quiet_zone=False,
98 **kw,
99 ):
100 cm = _make_colormap(
101 *matrix_size,
102 dark=dark,
103 light=light,
104 finder_dark=finder_dark,
105 finder_light=finder_light,
106 data_dark=data_dark,
107 data_light=data_light,
108 version_dark=version_dark,
109 version_light=version_light,
110 format_dark=format_dark,
111 format_light=format_light,
112 alignment_dark=alignment_dark,
113 alignment_light=alignment_light,
114 timing_dark=timing_dark,
115 timing_light=timing_light,
116 separator=separator,
117 dark_module=dark_module,
118 quiet_zone=quiet_zone,
119 )
71120 return f(matrix, matrix_size, out, cm, **kw)
121
72122 return wrapper
123
73124 return decorate
74125
75126
……@@ -81,16 +132,37 @@ def _valid_width_height_and_border(matrix_size, scale, border):
81132 return width, height, border
82133
83134
84@colorful(dark='#000', light=None)
85def write_svg(matrix, matrix_size, out, colormap, scale=1, border=None, xmldecl=True,
86 svgns=True, title=None, desc=None, svgid=None, svgclass='segno',
87 lineclass='qrline', omitsize=False, unit=None, encoding='utf-8',
88 svgversion=None, nl=True, draw_transparent=False):
135@colorful(dark="#000", light=None)
136def write_svg(
137 matrix,
138 matrix_size,
139 out,
140 colormap,
141 scale=1,
142 border=None,
143 xmldecl=True,
144 svgns=True,
145 title=None,
146 desc=None,
147 svgid=None,
148 svgclass="segno",
149 lineclass="qrline",
150 omitsize=False,
151 unit=None,
152 encoding="utf-8",
153 svgversion=None,
154 nl=True,
155 draw_transparent=False,
156):
89157 def svg_color(clr):
90 return _color_to_webcolor(clr, allow_css3_colors=allow_css3_colors) if clr is not None else None
158 return (
159 _color_to_webcolor(clr, allow_css3_colors=allow_css3_colors)
160 if clr is not None
161 else None
162 )
91163
92164 def matrix_to_lines_verbose():
93 j = -.5 # stroke width / 2
165 j = -0.5 # stroke width / 2
94166 invalid_color = -1
95167 for row in matrix_iter_verbose(matrix, matrix_size, scale=1, border=border):
96168 last_color = invalid_color
……@@ -105,21 +177,24 @@ def write_svg(matrix, matrix_size, out, colormap, scale=1, border=None, xmldecl=
105177 yield last_color, (x1, x2, j)
106178
107179 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
108 unit = unit or ''
180 unit = unit or ""
109181 if unit and omitsize:
110 raise ValueError(f'The unit "{unit}" has no effect if the size '
111 '(width and height) is omitted.')
182 raise ValueError(
183 f'The unit "{unit}" has no effect if the size (width and height) is omitted.'
184 )
112185 omit_encoding = encoding is None
113186 if omit_encoding:
114 encoding = 'utf-8'
187 encoding = "utf-8"
115188 allow_css3_colors = svgversion is not None and svgversion >= 2.0
116189 is_multicolor = len(set(colormap.values())) > 2
117 need_background = not is_multicolor and colormap[consts.TYPE_QUIET_ZONE] is not None and not draw_transparent
190 need_background = (
191 not is_multicolor and colormap[consts.TYPE_QUIET_ZONE] is not None and not draw_transparent
192 )
118193 need_svg_group = scale != 1 and (need_background or is_multicolor)
119194 if is_multicolor:
120195 miter = matrix_to_lines_verbose()
121196 else:
122 x, y = border, border + .5
197 x, y = border, border + 0.5
123198 dark = colormap[consts.TYPE_DATA_DARK]
124199 miter = ((dark, (x1, x2, y1)) for (x1, y1), (x2, y2) in matrix_to_lines(matrix, x, y))
125200 xy = defaultdict(lambda: (0, 0))
……@@ -136,9 +211,11 @@ def write_svg(matrix, matrix_size, out, colormap, scale=1, border=None, xmldecl=
136211 except KeyError:
137212 pass
138213 paths = {}
139 scale_info = f' transform="scale({scale})"' if scale != 1 else ''
140 p = '<path{}{}'.format(scale_info if not need_svg_group else '',
141 '' if not lineclass else f' class={quoteattr(lineclass)}')
214 scale_info = f' transform="scale({scale})"' if scale != 1 else ""
215 p = "<path{}{}".format(
216 scale_info if not need_svg_group else "",
217 "" if not lineclass else f" class={quoteattr(lineclass)}",
218 )
142219 for color, coord in coordinates.items():
143220 path = p
144221 clr = svg_color(color)
……@@ -146,98 +223,148 @@ def write_svg(matrix, matrix_size, out, colormap, scale=1, border=None, xmldecl=
146223 opacity = None
147224 if isinstance(clr, tuple):
148225 clr, opacity = clr
149 path += f' stroke={quoteattr(clr)}'
226 path += f" stroke={quoteattr(clr)}"
150227 if opacity is not None:
151 path += f' stroke-opacity={quoteattr(str(opacity))}'
228 path += f" stroke-opacity={quoteattr(str(opacity))}"
152229 path += ' d="'
153 path += ''.join('{moveto}{x} {y}h{l}'.format(moveto=('m' if i > 0 else 'M'),
154 x=x, l=length,
155 y=(int(y) if int(y) == y else y))
156 for i, (x, y, length) in enumerate(coord))
230 path += "".join(
231 "{moveto}{x} {y}h{l}".format(
232 moveto=("m" if i > 0 else "M"),
233 x=x,
234 l=length,
235 y=(int(y) if int(y) == y else y),
236 )
237 for i, (x, y, length) in enumerate(coord)
238 )
157239 path += '"/>'
158240 paths[color] = path
159241 if need_background:
160242 k = colormap[consts.TYPE_QUIET_ZONE]
161 paths[k] = re.sub(r'\sclass="[^"]+"', '',
162 paths[k].replace('stroke', 'fill')
163 .replace('"/>', f'v{height // scale}h-{width // scale}z"/>'))
164 svg = ''
243 paths[k] = re.sub(
244 r'\sclass="[^"]+"',
245 "",
246 paths[k]
247 .replace("stroke", "fill")
248 .replace('"/>', f'v{height // scale}h-{width // scale}z"/>'),
249 )
250 svg = ""
165251 if xmldecl:
166252 svg += '<?xml version="1.0"'
167253 if not omit_encoding:
168 svg += f' encoding={quoteattr(encoding)}'
169 svg += '?>\n'
170 svg += '<svg'
254 svg += f" encoding={quoteattr(encoding)}"
255 svg += "?>\n"
256 svg += "<svg"
171257 if svgns:
172258 svg += ' xmlns="http://www.w3.org/2000/svg"'
173259 if svgversion is not None and svgversion < 2.0:
174 svg += f' version={quoteattr(str(svgversion))}'
260 svg += f" version={quoteattr(str(svgversion))}"
175261 if not omitsize:
176262 svg += f' width="{width}{unit}" height="{height}{unit}"'
177263 if omitsize or unit:
178264 svg += f' viewBox="0 0 {width} {height}"'
179265 if svgid:
180 svg += f' id={quoteattr(svgid)}'
266 svg += f" id={quoteattr(svgid)}"
181267 if svgclass:
182 svg += f' class={quoteattr(svgclass)}'
183 svg += '>'
268 svg += f" class={quoteattr(svgclass)}"
269 svg += ">"
184270 if title is not None:
185 svg += f'<title>{escape(title)}</title>'
271 svg += f"<title>{escape(title)}</title>"
186272 if desc is not None:
187 svg += f'<desc>{escape(desc)}</desc>'
273 svg += f"<desc>{escape(desc)}</desc>"
188274 if need_svg_group:
189 svg += f'<g{scale_info}>'
190 svg += ''.join(sorted(paths.values(), key=len))
275 svg += f"<g{scale_info}>"
276 svg += "".join(sorted(paths.values(), key=len))
191277 if need_svg_group:
192 svg += '</g>'
193 svg += '</svg>'
278 svg += "</g>"
279 svg += "</svg>"
194280 if nl:
195 svg += '\n'
196 with writable(out, 'wt', encoding=encoding) as f:
281 svg += "\n"
282 with writable(out, "wt", encoding=encoding) as f:
197283 f.write(svg)
198284
199285
200_replace_quotes = partial(re.compile(br'(=)"([^"]+)"').sub, br"\1'\2'")
201
202
203def as_svg_data_uri(matrix, matrix_size, scale=1, border=None,
204 xmldecl=False, svgns=True, title=None,
205 desc=None, svgid=None, svgclass='segno',
206 lineclass='qrline', omitsize=False, unit='',
207 encoding='utf-8', svgversion=None, nl=False,
208 encode_minimal=False, omit_charset=False, **kw):
286_replace_quotes = partial(re.compile(rb'(=)"([^"]+)"').sub, rb"\1'\2'")
287
288
289def as_svg_data_uri(
290 matrix,
291 matrix_size,
292 scale=1,
293 border=None,
294 xmldecl=False,
295 svgns=True,
296 title=None,
297 desc=None,
298 svgid=None,
299 svgclass="segno",
300 lineclass="qrline",
301 omitsize=False,
302 unit="",
303 encoding="utf-8",
304 svgversion=None,
305 nl=False,
306 encode_minimal=False,
307 omit_charset=False,
308 **kw,
309):
209310 encode = partial(quote, safe=b"") if not encode_minimal else partial(quote, safe=b" :/='")
210311 buff = io.BytesIO()
211 write_svg(matrix, matrix_size, buff, scale=scale, border=border, xmldecl=xmldecl,
212 svgns=svgns, title=title, desc=desc, svgclass=svgclass,
213 lineclass=lineclass, omitsize=omitsize, encoding=encoding,
214 svgid=svgid, unit=unit, svgversion=svgversion, nl=nl, **kw)
215 return f'data:image/svg+xml{(";charset=" + encoding if not omit_charset else "")},' \
216 + encode(_replace_quotes(buff.getvalue()))
217
218
219def write_svg_debug(matrix, matrix_size, out, scale=15, border=None,
220 fallback_color='fuchsia', colormap=None,
221 add_legend=True): # pragma: no cover
312 write_svg(
313 matrix,
314 matrix_size,
315 buff,
316 scale=scale,
317 border=border,
318 xmldecl=xmldecl,
319 svgns=svgns,
320 title=title,
321 desc=desc,
322 svgclass=svgclass,
323 lineclass=lineclass,
324 omitsize=omitsize,
325 encoding=encoding,
326 svgid=svgid,
327 unit=unit,
328 svgversion=svgversion,
329 nl=nl,
330 **kw,
331 )
332 return f"data:image/svg+xml{(';charset=' + encoding if not omit_charset else '')}," + encode(
333 _replace_quotes(buff.getvalue())
334 )
335
336
337def write_svg_debug(
338 matrix,
339 matrix_size,
340 out,
341 scale=15,
342 border=None,
343 fallback_color="fuchsia",
344 colormap=None,
345 add_legend=True,
346): # pragma: no cover
222347 clr_mapping = {
223 0x0: '#fff',
224 0x1: '#000',
225 0x2: 'red',
226 0x3: 'orange',
227 0x4: 'gold',
228 0x5: 'green',
348 0x0: "#fff",
349 0x1: "#000",
350 0x2: "red",
351 0x3: "orange",
352 0x4: "gold",
353 0x5: "green",
229354 }
230355 if colormap is not None:
231356 clr_mapping.update(colormap)
232357 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
233358 matrix_width, matrix_height = matrix_size
234 with writable(out, 'wt', encoding='utf-8') as f:
359 with writable(out, "wt", encoding="utf-8") as f:
235360 legend = []
236361 write = f.write
237362 write('<?xml version="1.0" encoding="utf-8"?>\n')
238363 write(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}">')
239 write('<style type="text/css"><![CDATA[ text { font-size: 1px; '
240 'font-family: Helvetica, Arial, sans; } ]]></style>')
364 write(
365 '<style type="text/css"><![CDATA[ text { font-size: 1px; '
366 "font-family: Helvetica, Arial, sans; } ]]></style>"
367 )
241368 write(f'<g transform="scale({scale})">')
242369 for i in range(matrix_height):
243370 y = i + border
……@@ -249,17 +376,17 @@ def write_svg_debug(matrix, matrix_size, out, scale=15, border=None,
249376 fill = clr_mapping.get(bit, fallback_color)
250377 write(f'<rect x="{x}" y="{y}" width="1" height="1" fill="{fill}"/>')
251378 for x, y, val in legend:
252 write(f'<text x="{x + .2}" y="{y + .9}">{val}</text>')
253 write('</g></svg>\n')
379 write(f'<text x="{x + 0.2}" y="{y + 0.9}">{val}</text>')
380 write("</g></svg>\n")
254381
255382
256def write_eps(matrix, matrix_size, out, scale=1, border=None, dark='#000', light=None):
383def write_eps(matrix, matrix_size, out, scale=1, border=None, dark="#000", light=None):
257384 import textwrap
258385
259386 def write_line(writemeth, content):
260387 for line in textwrap.wrap(content, 254):
261388 writemeth(line)
262 writemeth('\n')
389 writemeth("\n")
263390
264391 def rgb_to_floats(clr):
265392 def to_float(c):
……@@ -274,45 +401,53 @@ def write_eps(matrix, matrix_size, out, scale=1, border=None, dark='#000', light
274401 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
275402 stroke_color_is_black = _color_is_black(dark)
276403 stroke_color = dark if stroke_color_is_black else rgb_to_floats(dark)
277 with writable(out, 'wt') as f:
404 with writable(out, "wt") as f:
278405 writeline = partial(write_line, f.write)
279 writeline('%!PS-Adobe-3.0 EPSF-3.0')
280 writeline(f'%%Creator: {CREATOR}')
281 writeline(f'%%CreationDate: {time.strftime("%Y-%m-%d %H:%M:%S")}')
282 writeline('%%DocumentData: Clean7Bit')
283 writeline(f'%%BoundingBox: 0 0 {width} {height}')
284 writeline('/m { rmoveto } bind def')
285 writeline('/l { rlineto } bind def')
406 writeline("%!PS-Adobe-3.0 EPSF-3.0")
407 writeline(f"%%Creator: {CREATOR}")
408 writeline(f"%%CreationDate: {time.strftime('%Y-%m-%d %H:%M:%S')}")
409 writeline("%%DocumentData: Clean7Bit")
410 writeline(f"%%BoundingBox: 0 0 {width} {height}")
411 writeline("/m { rmoveto } bind def")
412 writeline("/l { rlineto } bind def")
286413 if light is not None:
287 writeline('{0:f} {1:f} {2:f} setrgbcolor clippath fill'.format(*rgb_to_floats(light))) # noqa UP030
414 writeline("{0:f} {1:f} {2:f} setrgbcolor clippath fill".format(*rgb_to_floats(light)))
288415 if stroke_color_is_black:
289 writeline('0 0 0 setrgbcolor')
416 writeline("0 0 0 setrgbcolor")
290417 if not stroke_color_is_black:
291 writeline('{0:f} {1:f} {2:f} setrgbcolor'.format(*stroke_color)) # noqa UP030
418 writeline("{0:f} {1:f} {2:f} setrgbcolor".format(*stroke_color)) # noqa UP030
292419 if scale != 1:
293 writeline(f'{scale} {scale} scale')
294 writeline('newpath')
295 y = get_symbol_size(matrix_size, scale=1, border=0)[1] + border - .5 # .5 = linewidth / 2
420 writeline(f"{scale} {scale} scale")
421 writeline("newpath")
422 y = get_symbol_size(matrix_size, scale=1, border=0)[1] + border - 0.5 # .5 = linewidth / 2
296423 line_iter = matrix_to_lines(matrix, border, y, incby=-1)
297424 (x1, y1), (x2, y2) = next(line_iter)
298 coord = [f'{x1} {y1} moveto {x2 - x1} 0 l']
425 coord = [f"{x1} {y1} moveto {x2 - x1} 0 l"]
299426 append_coord = coord.append
300427 x = x2
301428 for (x1, y1), (x2, y2) in line_iter:
302 append_coord(f' {x1 - x} {int(y1 - y)} m {x2 - x1} 0 l')
429 append_coord(f" {x1 - x} {int(y1 - y)} m {x2 - x1} 0 l")
303430 x, y = x2, y2
304 writeline(''.join(coord))
305 writeline('stroke')
306 writeline('%%EOF')
431 writeline("".join(coord))
432 writeline("stroke")
433 writeline("%%EOF")
307434
308435
309436def as_png_data_uri(matrix, matrix_size, scale=1, border=None, compresslevel=9, **kw):
310437 buff = io.BytesIO()
311 write_png(matrix, matrix_size, buff, scale=scale, border=border, compresslevel=compresslevel, **kw)
312 return f'data:image/png;base64,{base64.b64encode(buff.getvalue()).decode("ascii")}'
313
314
315@colorful(dark='#000', light='#fff')
438 write_png(
439 matrix,
440 matrix_size,
441 buff,
442 scale=scale,
443 border=border,
444 compresslevel=compresslevel,
445 **kw,
446 )
447 return f"data:image/png;base64,{base64.b64encode(buff.getvalue()).decode('ascii')}"
448
449
450@colorful(dark="#000", light="#fff")
316451def write_png(matrix, matrix_size, out, colormap, scale=1, border=None, compresslevel=9, dpi=None):
317452
318453 def png_color(clr):
……@@ -320,19 +455,25 @@ def write_png(matrix, matrix_size, out, colormap, scale=1, border=None, compress
320455
321456 def chunk(name, data):
322457 chunk_head = name + data
323 return pack(b'>I', len(data)) + chunk_head + pack(b'>I', zlib.crc32(chunk_head))
324
325 def scanline(row, filter_type=b'\0'):
326 return bytearray(chain(filter_type,
327 (reduce(lambda x, y: (x << png_bit_depth) + y, e)
328 for e in zip_longest(*[iter(row)] * (8 // png_bit_depth), fillvalue=0x0))))
458 return pack(b">I", len(data)) + chunk_head + pack(b">I", zlib.crc32(chunk_head))
459
460 def scanline(row, filter_type=b"\0"):
461 return bytearray(
462 chain(
463 filter_type,
464 (
465 reduce(lambda x, y: (x << png_bit_depth) + y, e)
466 for e in zip_longest(*[iter(row)] * (8 // png_bit_depth), fillvalue=0x0)
467 ),
468 )
469 )
329470
330471 scale = int(scale)
331472 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
332473 if dpi:
333474 dpi = int(dpi)
334475 if dpi < 0:
335 raise ValueError('DPI value must not be negative')
476 raise ValueError("DPI value must not be negative")
336477 dpi = int(dpi // 0.0254)
337478
338479 black = (0, 0, 0)
……@@ -346,7 +487,9 @@ def write_png(matrix, matrix_size, out, colormap, scale=1, border=None, compress
346487 is_transparent = transparent in palette
347488 number_of_colors = len(palette)
348489 # Check if greyscale mode is applicable
349 is_greyscale = number_of_colors == 2 and all(clr in (transparent, black, white) for clr in palette)
490 is_greyscale = number_of_colors == 2 and all(
491 clr in (transparent, black, white) for clr in palette
492 )
350493 png_color_type = 0 if is_greyscale else 3
351494 png_bit_depth = 1 # Assume a bit depth of 1 (may change if PLTE is used)
352495 png_trans_idx = None
……@@ -356,13 +499,22 @@ def write_png(matrix, matrix_size, out, colormap, scale=1, border=None, compress
356499 palette.sort(key=len, reverse=True) # RGBA colors first
357500 if is_transparent:
358501 png_trans_idx = 0
359 rgb_values = _NAME2RGB.values() if len(palette[1]) == 3 else ((*clr, 0) for clr in _NAME2RGB.values())
502 rgb_values = (
503 _NAME2RGB.values()
504 if len(palette[1]) == 3
505 else ((*clr, 0) for clr in _NAME2RGB.values())
506 )
360507 # Choose a random color which becomes transparent.
361508 transparent_color = next(clr for clr in rgb_values if clr not in palette)
362509 palette[0] = transparent_color
363510 # Replace the placeholder "transparent" with the actual RGB(A) value
364 clr_map.update({module_type: transparent_color
365 for module_type, clr in clr_map.items() if clr == transparent})
511 clr_map.update(
512 {
513 module_type: transparent_color
514 for module_type, clr in clr_map.items()
515 if clr == transparent
516 }
517 )
366518 elif is_transparent: # Greyscale and transparent
367519 if black in palette:
368520 # Since black is zero, it should be the first entry
……@@ -377,20 +529,19 @@ def write_png(matrix, matrix_size, out, colormap, scale=1, border=None, compress
377529 miter = iter(matrix)
378530 # The code to create the image requires that TYPE_QUIET_ZONE is available
379531 color_index = {qz_idx: palette.index(clr_map[qz_idx])}
380 color_index.update({0: color_index[qz_idx],
381 1: palette.index(clr_map[dark_idx])})
532 color_index.update({0: color_index[qz_idx], 1: palette.index(clr_map[dark_idx])})
382533 miter = ((color_index[b] for b in r) for r in miter)
383 horizontal_border = b''
384 vertical_border = b''
534 horizontal_border = b""
535 vertical_border = b""
385536 if border > 0:
386537 # Calculate horizontal and vertical border
387538 qz_value = color_index[qz_idx]
388539 horizontal_border = scanline(repeat(qz_value, width)) * border * scale
389540 vertical_border = [qz_value] * border * scale
390 same_as_above = b''
541 same_as_above = b""
391542 if scale > 1:
392543 # 2 == PNG Filter "Up" <https://www.w3.org/TR/PNG/#9-table91>
393 same_as_above = scanline(repeat(0x0, width), filter_type=b'\2') * (scale - 1)
544 same_as_above = scanline(repeat(0x0, width), filter_type=b"\2") * (scale - 1)
394545 miter = (chain(*(repeat(b, scale) for b in row)) for row in miter)
395546 idat = bytearray(horizontal_border)
396547 for row in miter:
……@@ -398,32 +549,50 @@ def write_png(matrix, matrix_size, out, colormap, scale=1, border=None, compress
398549 idat += scanline(chain(vertical_border, row, vertical_border))
399550 idat += same_as_above # This is b'' if no scaling factor was provided
400551 idat += horizontal_border
401 with writable(out, 'wb') as f:
552 with writable(out, "wb") as f:
402553 write = f.write
403 write(b'\211PNG\r\n\032\n') # Magic number
554 write(b"\211PNG\r\n\032\n") # Magic number
404555 # Header:
405556 # width, height, bitdepth, colortype, compression meth., filter, interlance
406 write(chunk(b'IHDR', pack(b'>2I5B', width, height, png_bit_depth, png_color_type, 0, 0, 0)))
557 write(
558 chunk(
559 b"IHDR",
560 pack(b">2I5B", width, height, png_bit_depth, png_color_type, 0, 0, 0),
561 )
562 )
407563 if dpi:
408 write(chunk(b'pHYs', pack(b'>LLB', dpi, dpi, 1)))
564 write(chunk(b"pHYs", pack(b">LLB", dpi, dpi, 1)))
409565 if not is_greyscale:
410 write(chunk(b'PLTE', b''.join(pack(b'>3B', *clr[:3]) for clr in palette)))
566 write(chunk(b"PLTE", b"".join(pack(b">3B", *clr[:3]) for clr in palette)))
411567 # <https://www.w3.org/TR/PNG/#11tRNS>
412568 if len(palette[0]) > 3: # Color with alpha channel is the first entry in the palette
413 write(chunk(b'tRNS', b''.join(pack(b'>B', clr[3]) for clr in palette if len(clr) > 3)))
569 write(
570 chunk(
571 b"tRNS",
572 b"".join(pack(b">B", clr[3]) for clr in palette if len(clr) > 3),
573 )
574 )
414575 elif is_transparent:
415 write(chunk(b'tRNS', pack(b'>B', png_trans_idx)))
576 write(chunk(b"tRNS", pack(b">B", png_trans_idx)))
416577 elif is_transparent:
417 write(chunk(b'tRNS', pack(b'>1H', png_trans_idx)))
418 write(chunk(b'IDAT', zlib.compress(idat, compresslevel)))
419 write(chunk(b'IEND', b''))
420
421
422def write_pdf(matrix, matrix_size, out, scale=1, border=None, dark='#000',
423 light=None, compresslevel=9):
578 write(chunk(b"tRNS", pack(b">1H", png_trans_idx)))
579 write(chunk(b"IDAT", zlib.compress(idat, compresslevel)))
580 write(chunk(b"IEND", b""))
581
582
583def write_pdf(
584 matrix,
585 matrix_size,
586 out,
587 scale=1,
588 border=None,
589 dark="#000",
590 light=None,
591 compresslevel=9,
592):
424593
425594 def write_string(writemeth, s):
426 writemeth(s.encode('ascii'))
595 writemeth(s.encode("ascii"))
427596
428597 def to_pdf_color(clr):
429598 def to_float(c):
……@@ -432,6 +601,7 @@ def write_pdf(matrix, matrix_size, out, scale=1, border=None, dark='#000',
432601 raise ValueError(f'Invalid color "{c}". Not in range 0 .. 1')
433602 return c
434603 return 1 / 255.0 * c if c != 1 else c
604
435605 return tuple([to_float(i) for i in _color_to_rgb(clr)])
436606
437607 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
……@@ -440,212 +610,243 @@ def write_pdf(matrix, matrix_size, out, scale=1, border=None, dark='#000',
440610 append_cmd = cmds.append
441611 if light is not None:
442612 # If the background color is defined, a rect is drawn in the background
443 append_cmd('{} {} {} rg'.format(*to_pdf_color(light)))
444 append_cmd(f'0 0 {width} {height} re')
445 append_cmd('f q')
613 append_cmd("{} {} {} rg".format(*to_pdf_color(light)))
614 append_cmd(f"0 0 {width} {height} re")
615 append_cmd("f q")
446616 # Set the stroke color only iff it is not black (default)
447617 if not _color_is_black(dark):
448 append_cmd('{} {} {} RG'.format(*to_pdf_color(dark)))
618 append_cmd("{} {} {} RG".format(*to_pdf_color(dark)))
449619 if scale > 1:
450 append_cmd(f'{scale} 0 0 {scale} 0 0 cm')
451 y = get_symbol_size(matrix_size, scale=1, border=0)[1] + border - .5
620 append_cmd(f"{scale} 0 0 {scale} 0 0 cm")
621 y = get_symbol_size(matrix_size, scale=1, border=0)[1] + border - 0.5
452622 # Set the origin in the upper left corner
453 append_cmd(f'1 0 0 1 {border} {y} cm')
623 append_cmd(f"1 0 0 1 {border} {y} cm")
454624 miter = matrix_to_lines(matrix, 0, 0, incby=-1)
455625 # PDF supports absolute coordinates, only
456 cmds.extend(f'{x1} {y1} m {x2} {y1} l' for (x1, y1), (x2, y2) in miter)
457 append_cmd('S')
458 graphic = zlib.compress((' '.join(cmds)).encode('ascii'), compresslevel)
459 with writable(out, 'wb') as f:
626 cmds.extend(f"{x1} {y1} m {x2} {y1} l" for (x1, y1), (x2, y2) in miter)
627 append_cmd("S")
628 graphic = zlib.compress((" ".join(cmds)).encode("ascii"), compresslevel)
629 with writable(out, "wb") as f:
460630 write = f.write
461631 writestr = partial(write_string, write)
462632 object_pos = []
463 write(b'%PDF-1.4\r%\xE2\xE3\xCF\xD3\r\n')
464 for obj in ('obj <</Type /Catalog /Pages 2 0 R>>\r\nendobj\r\n',
465 'obj <</Type /Pages /Kids [3 0 R] /Count 1>>\r\nendobj\r\n',
466 f'obj <</Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] /Contents 4 0 R>>\r\nendobj\r\n',
467 f'obj <</Length {len(graphic)} /Filter /FlateDecode>>\r\nstream\r\n'):
633 write(b"%PDF-1.4\r%\xe2\xe3\xcf\xd3\r\n")
634 for obj in (
635 "obj <</Type /Catalog /Pages 2 0 R>>\r\nendobj\r\n",
636 "obj <</Type /Pages /Kids [3 0 R] /Count 1>>\r\nendobj\r\n",
637 f"obj <</Type /Page /Parent 2 0 R /MediaBox [0 0 {width} {height}] /Contents 4 0 R>>\r\nendobj\r\n",
638 f"obj <</Length {len(graphic)} /Filter /FlateDecode>>\r\nstream\r\n",
639 ):
468640 object_pos.append(f.tell())
469 writestr(f'{len(object_pos)} 0 {obj}')
641 writestr(f"{len(object_pos)} 0 {obj}")
470642 write(graphic)
471 write(b'\r\nendstream\r\nendobj\r\n')
643 write(b"\r\nendstream\r\nendobj\r\n")
472644 object_pos.append(f.tell())
473 writestr(f'{len(object_pos)} 0 obj <</CreationDate(D:{creation_date})'
474 f'/Producer({CREATOR})/Creator({CREATOR})\r\n>>\r\nendobj\r\n')
645 writestr(
646 f"{len(object_pos)} 0 obj <</CreationDate(D:{creation_date})"
647 f"/Producer({CREATOR})/Creator({CREATOR})\r\n>>\r\nendobj\r\n"
648 )
475649 object_pos.append(f.tell())
476 writestr(f'xref\r\n0 {len(object_pos)}\r\n0000000000 65535 f\r\n')
650 writestr(f"xref\r\n0 {len(object_pos)}\r\n0000000000 65535 f\r\n")
477651 for pos in object_pos[:-1]:
478 writestr(f'{pos:010d} {0:05d} n\r\n')
479 writestr(f'trailer <</Size {len(object_pos)}/Root 1 0 R/Info 5 0 R>>\r\n')
652 writestr(f"{pos:010d} {0:05d} n\r\n")
653 writestr(f"trailer <</Size {len(object_pos)}/Root 1 0 R/Info 5 0 R>>\r\n")
480654 xref_location = object_pos[-1]
481 writestr(f'startxref\r\n{xref_location}\r\n%%EOF\r\n')
655 writestr(f"startxref\r\n{xref_location}\r\n%%EOF\r\n")
482656
483657
484def write_txt(matrix, matrix_size, out, border=None, dark='1', light='0'):
658def write_txt(matrix, matrix_size, out, border=None, dark="1", light="0"):
485659 row_iter = matrix_iter(matrix, matrix_size, scale=1, border=border)
486660 colours = (str(light), str(dark))
487 with writable(out, 'wt') as f:
661 with writable(out, "wt") as f:
488662 write = f.write
489663 for row in row_iter:
490 write(''.join(colours[i] for i in row))
491 write('\n')
664 write("".join(colours[i] for i in row))
665 write("\n")
492666
493667
494668def write_pbm(matrix, matrix_size, out, scale=1, border=None, plain=False):
495669 def pack_row(iterable):
496 return (reduce(lambda x, y: (x << 1) + y, e)
497 for e in zip_longest(*[iter(iterable)] * 8, fillvalue=0x0))
670 return (
671 reduce(lambda x, y: (x << 1) + y, e)
672 for e in zip_longest(*[iter(iterable)] * 8, fillvalue=0x0)
673 )
498674
499675 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
500676 row_iter = matrix_iter(matrix, matrix_size, scale, border)
501 with writable(out, 'wb') as f:
677 with writable(out, "wb") as f:
502678 write = f.write
503 write(f'{("P4" if not plain else "P1")}\n'
504 f'# Created by {CREATOR}\n'
505 f'{width} {height}\n'.encode('ascii'))
679 write(
680 f"{('P4' if not plain else 'P1')}\n# Created by {CREATOR}\n{width} {height}\n".encode(
681 "ascii"
682 )
683 )
506684 if not plain:
507685 for row in row_iter:
508686 write(bytearray(pack_row(row)))
509687 else:
510688 for row in row_iter:
511 write(b''.join(str(i).encode('ascii') for i in row))
512 write(b'\n')
689 write(b"".join(str(i).encode("ascii") for i in row))
690 write(b"\n")
513691
514692
515def write_pam(matrix, matrix_size, out, scale=1, border=None, dark='#000', light='#fff'):
693def write_pam(matrix, matrix_size, out, scale=1, border=None, dark="#000", light="#fff"):
516694 def invert_row_bits(row):
517695 return bytearray([b ^ 0x1 for b in row])
518696
519697 def row_to_color_values(row, colours):
520 return b''.join(colours[b] for b in row)
698 return b"".join(colours[b] for b in row)
521699
522700 if not dark:
523701 raise ValueError(f'Invalid stroke color "{dark}"')
524702 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
525703 row_iter = matrix_iter(matrix, matrix_size, scale, border)
526 depth, maxval, tuple_type = 1, 1, 'BLACKANDWHITE'
704 depth, maxval, tuple_type = 1, 1, "BLACKANDWHITE"
527705 transparency = False
528706 stroke_color = _color_to_rgb_or_rgba(dark, alpha_float=False)
529707 bg_color = _color_to_rgb_or_rgba(light, alpha_float=False) if light is not None else None
530708 colored_stroke = not (_color_is_black(stroke_color) or _color_is_white(stroke_color))
531709 if bg_color is None:
532 tuple_type = 'GRAYSCALE_ALPHA' if not colored_stroke else 'RGB_ALPHA'
710 tuple_type = "GRAYSCALE_ALPHA" if not colored_stroke else "RGB_ALPHA"
533711 transparency = True
534712 bg_color = _invert_color(stroke_color[:3])
535713 bg_color += (0,)
536714 if len(stroke_color) != 4:
537715 stroke_color += (255,)
538716 elif colored_stroke or not (_color_is_black(bg_color) or _color_is_white(bg_color)):
539 tuple_type = 'RGB'
540 is_rgb = tuple_type.startswith('RGB')
717 tuple_type = "RGB"
718 is_rgb = tuple_type.startswith("RGB")
541719 colours = None
542720 if not is_rgb and transparency:
543721 depth = 2
544 colours = (b'\x01\x00', b'\x00\x01')
722 colours = (b"\x01\x00", b"\x00\x01")
545723 elif is_rgb:
546724 maxval = max(chain(stroke_color, bg_color))
547725 depth = 3 if not transparency else 4
548 fmt = f'>{depth}B'.encode('ascii')
726 fmt = f">{depth}B".encode("ascii")
549727 colours = (pack(fmt, *bg_color), pack(fmt, *stroke_color))
550 row_filter = invert_row_bits if colours is None else partial(row_to_color_values, colours=colours)
551 with writable(out, 'wb') as f:
728 row_filter = (
729 invert_row_bits if colours is None else partial(row_to_color_values, colours=colours)
730 )
731 with writable(out, "wb") as f:
552732 write = f.write
553 write('P7\n'
554 f'# Created by {CREATOR}\n'
555 f'WIDTH {width}\n'
556 f'HEIGHT {height}\n'
557 f'DEPTH {depth}\n'
558 f'MAXVAL {maxval}\n'
559 f'TUPLTYPE {tuple_type}\n'
560 'ENDHDR\n'.encode('ascii'))
733 write(
734 "P7\n"
735 f"# Created by {CREATOR}\n"
736 f"WIDTH {width}\n"
737 f"HEIGHT {height}\n"
738 f"DEPTH {depth}\n"
739 f"MAXVAL {maxval}\n"
740 f"TUPLTYPE {tuple_type}\n"
741 "ENDHDR\n".encode("ascii")
742 )
561743 for row in row_iter:
562744 write(row_filter(row))
563745
564746
565@colorful(dark='#000', light='#fff')
747@colorful(dark="#000", light="#fff")
566748def write_ppm(matrix, matrix_size, out, colormap, scale=1, border=None):
567749 scale = int(scale)
568750 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
569751 if None in colormap.values():
570 raise ValueError('Transparency is not supported')
752 raise ValueError("Transparency is not supported")
571753 for mt, clr in colormap.items():
572754 colormap[mt] = _color_to_rgb(clr)
573755 row_iter = matrix_iter_verbose(matrix, matrix_size, scale, border)
574 with writable(out, 'wb') as f:
756 with writable(out, "wb") as f:
575757 write = f.write
576 write(f'P6 # Created by {CREATOR}\n{width} {height} 255\n'.encode('ascii'))
758 write(f"P6 # Created by {CREATOR}\n{width} {height} 255\n".encode("ascii"))
577759 for row in row_iter:
578 write(b''.join(pack(b'>3B', *colormap[mt]) for mt in row))
579
580
581def write_xpm(matrix, matrix_size, out, scale=1, border=None, dark='#000',
582 light='#fff', name='img'):
760 write(b"".join(pack(b">3B", *colormap[mt]) for mt in row))
761
762
763def write_xpm(
764 matrix,
765 matrix_size,
766 out,
767 scale=1,
768 border=None,
769 dark="#000",
770 light="#fff",
771 name="img",
772):
583773 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
584774 row_iter = matrix_iter(matrix, matrix_size, scale, border)
585 stroke_color = color_to_rgb_hex(dark) if dark is not None else 'None'
586 bg_color = color_to_rgb_hex(light) if light is not None else 'None'
587 with writable(out, 'wt') as f:
775 stroke_color = color_to_rgb_hex(dark) if dark is not None else "None"
776 bg_color = color_to_rgb_hex(light) if light is not None else "None"
777 with writable(out, "wt") as f:
588778 write = f.write
589 write('/* XPM */\n'
590 f'static char *{name}[] = {{\n'
591 f'"{width} {height} 2 1",\n'
592 f'" c {bg_color}",\n'
593 f'"X c {stroke_color}",\n')
779 write(
780 "/* XPM */\n"
781 f"static char *{name}[] = {{\n"
782 f'"{width} {height} 2 1",\n'
783 f'" c {bg_color}",\n'
784 f'"X c {stroke_color}",\n'
785 )
594786 for i, row in enumerate(row_iter):
595 write(''.join(chain(['"'], (" " if not b else "X" for b in row),
596 [f'"{("," if i < height - 1 else "")}\n'])))
597 write('};\n')
598
599
600def write_xbm(matrix, matrix_size, out, scale=1, border=None, name='img'):
787 write(
788 "".join(
789 chain(
790 ['"'],
791 (" " if not b else "X" for b in row),
792 [f'"{("," if i < height - 1 else "")}\n'],
793 )
794 )
795 )
796 write("};\n")
797
798
799def write_xbm(matrix, matrix_size, out, scale=1, border=None, name="img"):
601800 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
602801 row_iter = matrix_iter(matrix, matrix_size, scale, border)
603 with writable(out, 'wt') as f:
802 with writable(out, "wt") as f:
604803 write = f.write
605 write(f'#define {name}_width {width}\n'
606 f'#define {name}_height {height}\n'
607 f'static unsigned char {name}_bits[] = {{\n')
804 write(
805 f"#define {name}_width {width}\n"
806 f"#define {name}_height {height}\n"
807 f"static unsigned char {name}_bits[] = {{\n"
808 )
608809 for i, row in enumerate(row_iter, start=1):
609810 iter_ = zip_longest(*[iter(row)] * 8, fillvalue=0x0)
610811 # Reverse bits since XBM uses little endian
611 bits = [f'0x{reduce(lambda x, y: (x << 1) + y, bits[::-1]):02x}' for bits in iter_]
612 write(' ')
613 write(', '.join(bits))
614 write(',\n' if i < height else '\n')
615 write('};\n')
812 bits = [f"0x{reduce(lambda x, y: (x << 1) + y, bits[::-1]):02x}" for bits in iter_]
813 write(" ")
814 write(", ".join(bits))
815 write(",\n" if i < height else "\n")
816 write("};\n")
616817
617818
618def write_tex(matrix, matrix_size, out, scale=1, border=None, dark='black', unit='pt', url=None):
819def write_tex(matrix, matrix_size, out, scale=1, border=None, dark="black", unit="pt", url=None):
619820 def point(x, y):
620 return f'\\pgfqpoint{{{x}{unit}}}{{{y}{unit}}}'
821 return f"\\pgfqpoint{{{x}{unit}}}{{{y}{unit}}}"
621822
622823 check_valid_scale(scale)
623824 check_valid_border(border)
624825 border = get_border(matrix_size, border)
625 end_marker = ''
626 with writable(out, 'wt') as f:
826 end_marker = ""
827 with writable(out, "wt") as f:
627828 write = f.write
628 write(f'% Creator: {CREATOR}\n')
629 write(f'% Date: {time.strftime("%Y-%m-%dT%H:%M:%S")}\n')
829 write(f"% Creator: {CREATOR}\n")
830 write(f"% Date: {time.strftime('%Y-%m-%dT%H:%M:%S')}\n")
630831 if url:
631 write(f'\\href{{{url}}}{{')
632 end_marker = '}'
633 write('\\begin{pgfpicture}\n')
634 write(f' \\pgfsetlinewidth{{{scale}{unit}}}\n')
635 if dark and dark != 'black':
636 write(f' \\color{{{dark}}}\n')
832 write(f"\\href{{{url}}}{{")
833 end_marker = "}"
834 write("\\begin{pgfpicture}\n")
835 write(f" \\pgfsetlinewidth{{{scale}{unit}}}\n")
836 if dark and dark != "black":
837 write(f" \\color{{{dark}}}\n")
637838 x, y = border, -border
638839 for (x1, y1), (x2, y2) in matrix_to_lines(matrix, x, y, incby=-1):
639 write(f' \\pgfpathmoveto{{{point(x1 * scale, y1 * scale)}}}\n')
640 write(f' \\pgfpathlineto{{{point(x2 * scale, y2 * scale)}}}\n')
641 write(' \\pgfusepath{stroke}\n')
642 write(f'\\end{{pgfpicture}}{end_marker}\n')
840 write(f" \\pgfpathmoveto{{{point(x1 * scale, y1 * scale)}}}\n")
841 write(f" \\pgfpathlineto{{{point(x2 * scale, y2 * scale)}}}\n")
842 write(" \\pgfusepath{stroke}\n")
843 write(f"\\end{{pgfpicture}}{end_marker}\n")
643844
644845
645846def write_terminal(matrix, matrix_size, out, border=None):
646 with writable(out, 'wt') as f:
847 with writable(out, "wt") as f:
647848 write = f.write
648 colours = [f'\033[{i}m' for i in (7, 49)]
849 colours = [f"\033[{i}m" for i in (7, 49)]
649850 for row in matrix_iter(matrix, matrix_size, scale=1, border=border):
650851 prev_bit = -1
651852 cnt = 0
……@@ -655,29 +856,29 @@ def write_terminal(matrix, matrix_size, out, border=None):
655856 else:
656857 if cnt:
657858 write(colours[prev_bit])
658 write(' ' * cnt)
659 write('\033[0m') # reset color
859 write(" " * cnt)
860 write("\033[0m") # reset color
660861 prev_bit = bit
661862 cnt = 1
662863 if cnt:
663864 write(colours[prev_bit])
664 write(' ' * cnt)
665 write('\033[0m') # reset color
666 write('\n')
865 write(" " * cnt)
866 write("\033[0m") # reset color
867 write("\n")
667868
668869
669870def write_terminal_win(matrix, matrix_size, border=None): # pragma: no cover
670 import sys
671 import struct
672871 import ctypes
872 import struct
873 import sys
874
673875 write = sys.stdout.write
674876 std_out = ctypes.windll.kernel32.GetStdHandle(-11)
675877 csbi = ctypes.create_string_buffer(22)
676878 res = ctypes.windll.kernel32.GetConsoleScreenBufferInfo(std_out, csbi)
677879 if not res:
678 raise OSError('Cannot find information about the console. '
679 'Not running on the command line?')
680 default_color = struct.unpack(b'hhhhHhhhhhh', csbi.raw)[4]
880 raise OSError("Cannot find information about the console. Not running on the command line?")
881 default_color = struct.unpack(b"hhhhHhhhhhh", csbi.raw)[4]
681882 set_color = partial(ctypes.windll.kernel32.SetConsoleTextAttribute, std_out)
682883 colours = (240, default_color)
683884 for row in matrix_iter(matrix, matrix_size, scale=1, border=border):
……@@ -689,28 +890,29 @@ def write_terminal_win(matrix, matrix_size, border=None): # pragma: no cover
689890 else:
690891 if cnt:
691892 set_color(colours[prev_bit])
692 write(' ' * cnt)
893 write(" " * cnt)
693894 prev_bit = bit
694895 cnt = 1
695896 if cnt:
696897 set_color(colours[prev_bit])
697 write(' ' * cnt)
898 write(" " * cnt)
698899 set_color(default_color) # reset color
699 write('\n')
900 write("\n")
700901
701902
702903def write_terminal_compact(matrix, matrix_size, out, border=None):
703 blocks = {(1, 1): ' ',
704 (0, 1): '\u2580', # Upper half block
705 (1, 0): '\u2584', # Lower half block
706 (0, 0): '\u2588', # Full block
707 }
904 blocks = {
905 (1, 1): " ",
906 (0, 1): "\u2580", # Upper half block
907 (1, 0): "\u2584", # Lower half block
908 (0, 0): "\u2588", # Full block
909 }
708910 it = [matrix_iter(matrix, matrix_size, scale=1, border=border)] * 2
709 with writable(out, 'wt') as f:
911 with writable(out, "wt") as f:
710912 write = f.write
711913 for top_row, bottom_row in zip_longest(*it, fillvalue=repeat(1)):
712 write(''.join(blocks[pair] for pair in zip(top_row, bottom_row)))
713 write('\n')
914 write("".join(blocks[pair] for pair in zip(top_row, bottom_row)))
915 write("\n")
714916
715917
716918def _color_to_rgb_or_rgba(color, alpha_float=True):
……@@ -722,29 +924,29 @@ def _color_to_rgb_or_rgba(color, alpha_float=True):
722924
723925def _color_to_webcolor(color, allow_css3_colors=True, optimize=True):
724926 if _color_is_black(color):
725 return '#000'
927 return "#000"
726928 elif _color_is_white(color):
727 return '#fff'
929 return "#fff"
728930 clr = _color_to_rgb_or_rgba(color)
729931 alpha_channel = None
730932 if len(clr) == 4:
731933 if allow_css3_colors:
732 return 'rgba({0},{1},{2},{3})'.format(*clr) # noqa UP030
934 return "rgba({0},{1},{2},{3})".format(*clr) # noqa UP030
733935 alpha_channel = clr[3]
734936 clr = clr[:3]
735 hx = '#{0:02x}{1:02x}{2:02x}'.format(*clr) # noqa UP030
937 hx = "#{0:02x}{1:02x}{2:02x}".format(*clr) # noqa UP030
736938 if optimize:
737 if hx == '#d2b48c':
738 hx = 'tan' # shorter
739 elif hx == '#ff0000':
740 hx = 'red' # shorter
939 if hx == "#d2b48c":
940 hx = "tan" # shorter
941 elif hx == "#ff0000":
942 hx = "red" # shorter
741943 elif hx[1] == hx[2] and hx[3] == hx[4] and hx[5] == hx[6]:
742 hx = f'#{hx[1]}{hx[3]}{hx[5]}'
944 hx = f"#{hx[1]}{hx[3]}{hx[5]}"
743945 return hx if alpha_channel is None else (hx, alpha_channel)
744946
745947
746948def color_to_rgb_hex(color):
747 return '#{0:02x}{1:02x}{2:02x}'.format(*_color_to_rgb(color)) # noqa UP030
949 return "#{0:02x}{1:02x}{2:02x}".format(*_color_to_rgb(color)) # noqa UP030
748950
749951
750952def _color_is_black(color):
……@@ -752,8 +954,14 @@ def _color_is_black(color):
752954 color = color.lower()
753955 except AttributeError:
754956 pass
755 return color in ('#000', '#000000', 'black', (0, 0, 0), (0, 0, 0, 255),
756 (0, 0, 0, 1.0))
957 return color in (
958 "#000",
959 "#000000",
960 "black",
961 (0, 0, 0),
962 (0, 0, 0, 255),
963 (0, 0, 0, 1.0),
964 )
757965
758966
759967def _color_is_white(color):
……@@ -761,15 +969,22 @@ def _color_is_white(color):
761969 color = color.lower()
762970 except AttributeError:
763971 pass
764 return color in ('#fff', '#ffffff', 'white', (255, 255, 255),
765 (255, 255, 255, 255), (255, 255, 255, 1.0))
972 return color in (
973 "#fff",
974 "#ffffff",
975 "white",
976 (255, 255, 255),
977 (255, 255, 255, 255),
978 (255, 255, 255, 1.0),
979 )
766980
767981
768982def _color_to_rgb(color):
769983 rgb = _color_to_rgb_or_rgba(color)
770984 if len(rgb) != 3:
771 raise ValueError(f'The alpha channel {rgb[3]} in color "{color}" cannot be '
772 'converted to RGB')
985 raise ValueError(
986 f'The alpha channel {rgb[3]} in color "{color}" cannot be converted to RGB'
987 )
773988 return rgb
774989
775990
……@@ -803,33 +1018,35 @@ def _color_to_rgba(color, alpha_float=True):
8031018 else:
8041019 return clr + alpha_channel
8051020 except ValueError:
806 raise ValueError(f'Unsupported color "{color}". Neither a known web '
807 'color name nor a color in hexadecimal format.')
1021 raise ValueError(
1022 f'Unsupported color "{color}". Neither a known web '
1023 "color name nor a color in hexadecimal format."
1024 )
8081025
8091026
8101027def _hex_to_rgb_or_rgba(color, alpha_float=True):
811 if color[0] == '#':
1028 if color[0] == "#":
8121029 color = color[1:]
8131030 if 2 < len(color) < 5:
8141031 # Expand RGB -> RRGGBB and RGBA -> RRGGBBAA
815 color = ''.join([color[i] * 2 for i in range(len(color))])
1032 color = "".join([color[i] * 2 for i in range(len(color))])
8161033 color_len = len(color)
8171034 if color_len not in (6, 8):
818 raise ValueError(f'Input #{color} is not in #RRGGBB nor in #RRGGBBAA format')
819 res = tuple([int(color[i:i + 2], 16) for i in range(0, color_len, 2)])
1035 raise ValueError(f"Input #{color} is not in #RRGGBB nor in #RRGGBBAA format")
1036 res = tuple([int(color[i : i + 2], 16) for i in range(0, color_len, 2)])
8201037 if alpha_float and color_len == 8:
8211038 res = res[:3] + (_alpha_value(res[3], alpha_float),)
8221039 return res
8231040
8241041
825_ALPHA_COMMONS = {255: 1.0, 128: .5, 64: .25, 32: .125, 16: .625, 0: 0.0}
1042_ALPHA_COMMONS = {255: 1.0, 128: 0.5, 64: 0.25, 32: 0.125, 16: 0.625, 0: 0.0}
8261043
8271044
8281045def _alpha_value(color, alpha_float):
8291046 if alpha_float:
8301047 if not isinstance(color, float):
8311048 if 0 <= color <= 255:
832 return _ALPHA_COMMONS.get(color, float('%.02f' % (color / 255.0)))
1049 return _ALPHA_COMMONS.get(color, float("%.02f" % (color / 255.0)))
8331050 else:
8341051 if 0 <= color <= 1.0:
8351052 return color
……@@ -840,7 +1057,7 @@ def _alpha_value(color, alpha_float):
8401057 else:
8411058 if 0 <= color <= 1.0:
8421059 return color * 255.0
843 raise ValueError(f'Invalid alpha channel value: {color}')
1060 raise ValueError(f"Invalid alpha channel value: {color}")
8441061
8451062
8461063def _invert_color(rgb_or_rgba):
……@@ -849,177 +1066,202 @@ def _invert_color(rgb_or_rgba):
8491066
8501067# <https://www.w3.org/TR/css-color-3/#svg-color>
8511068_NAME2RGB = {
852 'aliceblue': (240, 248, 255),
853 'antiquewhite': (250, 235, 215),
854 'aqua': (0, 255, 255),
855 'aquamarine': (127, 255, 212),
856 'azure': (240, 255, 255),
857 'beige': (245, 245, 220),
858 'bisque': (255, 228, 196),
859 'black': (0, 0, 0),
860 'blanchedalmond': (255, 235, 205),
861 'blue': (0, 0, 255),
862 'blueviolet': (138, 43, 226),
863 'brown': (165, 42, 42),
864 'burlywood': (222, 184, 135),
865 'cadetblue': (95, 158, 160),
866 'chartreuse': (127, 255, 0),
867 'chocolate': (210, 105, 30),
868 'coral': (255, 127, 80),
869 'cornflowerblue': (100, 149, 237),
870 'cornsilk': (255, 248, 220),
871 'crimson': (220, 20, 60),
872 'cyan': (0, 255, 255),
873 'darkblue': (0, 0, 139),
874 'darkcyan': (0, 139, 139),
875 'darkgoldenrod': (184, 134, 11),
876 'darkgray': (169, 169, 169),
877 'darkgreen': (0, 100, 0),
878 'darkgrey': (169, 169, 169),
879 'darkkhaki': (189, 183, 107),
880 'darkmagenta': (139, 0, 139),
881 'darkolivegreen': (85, 107, 47),
882 'darkorange': (255, 140, 0),
883 'darkorchid': (153, 50, 204),
884 'darkred': (139, 0, 0),
885 'darksalmon': (233, 150, 122),
886 'darkseagreen': (143, 188, 143),
887 'darkslateblue': (72, 61, 139),
888 'darkslategray': (47, 79, 79),
889 'darkslategrey': (47, 79, 79),
890 'darkturquoise': (0, 206, 209),
891 'darkviolet': (148, 0, 211),
892 'deeppink': (255, 20, 147),
893 'deepskyblue': (0, 191, 255),
894 'dimgray': (105, 105, 105),
895 'dimgrey': (105, 105, 105),
896 'dodgerblue': (30, 144, 255),
897 'firebrick': (178, 34, 34),
898 'floralwhite': (255, 250, 240),
899 'forestgreen': (34, 139, 34),
900 'fuchsia': (255, 0, 255),
901 'gainsboro': (220, 220, 220),
902 'ghostwhite': (248, 248, 255),
903 'gold': (255, 215, 0),
904 'goldenrod': (218, 165, 32),
905 'gray': (128, 128, 128),
906 'green': (0, 128, 0),
907 'greenyellow': (173, 255, 47),
908 'grey': (128, 128, 128),
909 'honeydew': (240, 255, 240),
910 'hotpink': (255, 105, 180),
911 'indianred': (205, 92, 92),
912 'indigo': (75, 0, 130),
913 'ivory': (255, 255, 240),
914 'khaki': (240, 230, 140),
915 'lavender': (230, 230, 250),
916 'lavenderblush': (255, 240, 245),
917 'lawngreen': (124, 252, 0),
918 'lemonchiffon': (255, 250, 205),
919 'lightblue': (173, 216, 230),
920 'lightcoral': (240, 128, 128),
921 'lightcyan': (224, 255, 255),
922 'lightgoldenrodyellow': (250, 250, 210),
923 'lightgray': (211, 211, 211),
924 'lightgreen': (144, 238, 144),
925 'lightgrey': (211, 211, 211),
926 'lightpink': (255, 182, 193),
927 'lightsalmon': (255, 160, 122),
928 'lightseagreen': (32, 178, 170),
929 'lightskyblue': (135, 206, 250),
930 'lightslategray': (119, 136, 153),
931 'lightslategrey': (119, 136, 153),
932 'lightsteelblue': (176, 196, 222),
933 'lightyellow': (255, 255, 224),
934 'lime': (0, 255, 0),
935 'limegreen': (50, 205, 50),
936 'linen': (250, 240, 230),
937 'magenta': (255, 0, 255),
938 'maroon': (128, 0, 0),
939 'mediumaquamarine': (102, 205, 170),
940 'mediumblue': (0, 0, 205),
941 'mediumorchid': (186, 85, 211),
942 'mediumpurple': (147, 112, 219),
943 'mediumseagreen': (60, 179, 113),
944 'mediumslateblue': (123, 104, 238),
945 'mediumspringgreen': (0, 250, 154),
946 'mediumturquoise': (72, 209, 204),
947 'mediumvioletred': (199, 21, 133),
948 'midnightblue': (25, 25, 112),
949 'mintcream': (245, 255, 250),
950 'mistyrose': (255, 228, 225),
951 'moccasin': (255, 228, 181),
952 'navajowhite': (255, 222, 173),
953 'navy': (0, 0, 128),
954 'oldlace': (253, 245, 230),
955 'olive': (128, 128, 0),
956 'olivedrab': (107, 142, 35),
957 'orange': (255, 165, 0),
958 'orangered': (255, 69, 0),
959 'orchid': (218, 112, 214),
960 'palegoldenrod': (238, 232, 170),
961 'palegreen': (152, 251, 152),
962 'paleturquoise': (175, 238, 238),
963 'palevioletred': (219, 112, 147),
964 'papayawhip': (255, 239, 213),
965 'peachpuff': (255, 218, 185),
966 'peru': (205, 133, 63),
967 'pink': (255, 192, 203),
968 'plum': (221, 160, 221),
969 'powderblue': (176, 224, 230),
970 'purple': (128, 0, 128),
971 'red': (255, 0, 0),
972 'rosybrown': (188, 143, 143),
973 'royalblue': (65, 105, 225),
974 'saddlebrown': (139, 69, 19),
975 'salmon': (250, 128, 114),
976 'sandybrown': (244, 164, 96),
977 'seagreen': (46, 139, 87),
978 'seashell': (255, 245, 238),
979 'sienna': (160, 82, 45),
980 'silver': (192, 192, 192),
981 'skyblue': (135, 206, 235),
982 'slateblue': (106, 90, 205),
983 'slategray': (112, 128, 144),
984 'slategrey': (112, 128, 144),
985 'snow': (255, 250, 250),
986 'springgreen': (0, 255, 127),
987 'steelblue': (70, 130, 180),
988 'tan': (210, 180, 140),
989 'teal': (0, 128, 128),
990 'thistle': (216, 191, 216),
991 'tomato': (255, 99, 71),
992 'turquoise': (64, 224, 208),
993 'violet': (238, 130, 238),
994 'wheat': (245, 222, 179),
995 'white': (255, 255, 255),
996 'whitesmoke': (245, 245, 245),
997 'yellow': (255, 255, 0),
998 'yellowgreen': (154, 205, 50),
1069 "aliceblue": (240, 248, 255),
1070 "antiquewhite": (250, 235, 215),
1071 "aqua": (0, 255, 255),
1072 "aquamarine": (127, 255, 212),
1073 "azure": (240, 255, 255),
1074 "beige": (245, 245, 220),
1075 "bisque": (255, 228, 196),
1076 "black": (0, 0, 0),
1077 "blanchedalmond": (255, 235, 205),
1078 "blue": (0, 0, 255),
1079 "blueviolet": (138, 43, 226),
1080 "brown": (165, 42, 42),
1081 "burlywood": (222, 184, 135),
1082 "cadetblue": (95, 158, 160),
1083 "chartreuse": (127, 255, 0),
1084 "chocolate": (210, 105, 30),
1085 "coral": (255, 127, 80),
1086 "cornflowerblue": (100, 149, 237),
1087 "cornsilk": (255, 248, 220),
1088 "crimson": (220, 20, 60),
1089 "cyan": (0, 255, 255),
1090 "darkblue": (0, 0, 139),
1091 "darkcyan": (0, 139, 139),
1092 "darkgoldenrod": (184, 134, 11),
1093 "darkgray": (169, 169, 169),
1094 "darkgreen": (0, 100, 0),
1095 "darkgrey": (169, 169, 169),
1096 "darkkhaki": (189, 183, 107),
1097 "darkmagenta": (139, 0, 139),
1098 "darkolivegreen": (85, 107, 47),
1099 "darkorange": (255, 140, 0),
1100 "darkorchid": (153, 50, 204),
1101 "darkred": (139, 0, 0),
1102 "darksalmon": (233, 150, 122),
1103 "darkseagreen": (143, 188, 143),
1104 "darkslateblue": (72, 61, 139),
1105 "darkslategray": (47, 79, 79),
1106 "darkslategrey": (47, 79, 79),
1107 "darkturquoise": (0, 206, 209),
1108 "darkviolet": (148, 0, 211),
1109 "deeppink": (255, 20, 147),
1110 "deepskyblue": (0, 191, 255),
1111 "dimgray": (105, 105, 105),
1112 "dimgrey": (105, 105, 105),
1113 "dodgerblue": (30, 144, 255),
1114 "firebrick": (178, 34, 34),
1115 "floralwhite": (255, 250, 240),
1116 "forestgreen": (34, 139, 34),
1117 "fuchsia": (255, 0, 255),
1118 "gainsboro": (220, 220, 220),
1119 "ghostwhite": (248, 248, 255),
1120 "gold": (255, 215, 0),
1121 "goldenrod": (218, 165, 32),
1122 "gray": (128, 128, 128),
1123 "green": (0, 128, 0),
1124 "greenyellow": (173, 255, 47),
1125 "grey": (128, 128, 128),
1126 "honeydew": (240, 255, 240),
1127 "hotpink": (255, 105, 180),
1128 "indianred": (205, 92, 92),
1129 "indigo": (75, 0, 130),
1130 "ivory": (255, 255, 240),
1131 "khaki": (240, 230, 140),
1132 "lavender": (230, 230, 250),
1133 "lavenderblush": (255, 240, 245),
1134 "lawngreen": (124, 252, 0),
1135 "lemonchiffon": (255, 250, 205),
1136 "lightblue": (173, 216, 230),
1137 "lightcoral": (240, 128, 128),
1138 "lightcyan": (224, 255, 255),
1139 "lightgoldenrodyellow": (250, 250, 210),
1140 "lightgray": (211, 211, 211),
1141 "lightgreen": (144, 238, 144),
1142 "lightgrey": (211, 211, 211),
1143 "lightpink": (255, 182, 193),
1144 "lightsalmon": (255, 160, 122),
1145 "lightseagreen": (32, 178, 170),
1146 "lightskyblue": (135, 206, 250),
1147 "lightslategray": (119, 136, 153),
1148 "lightslategrey": (119, 136, 153),
1149 "lightsteelblue": (176, 196, 222),
1150 "lightyellow": (255, 255, 224),
1151 "lime": (0, 255, 0),
1152 "limegreen": (50, 205, 50),
1153 "linen": (250, 240, 230),
1154 "magenta": (255, 0, 255),
1155 "maroon": (128, 0, 0),
1156 "mediumaquamarine": (102, 205, 170),
1157 "mediumblue": (0, 0, 205),
1158 "mediumorchid": (186, 85, 211),
1159 "mediumpurple": (147, 112, 219),
1160 "mediumseagreen": (60, 179, 113),
1161 "mediumslateblue": (123, 104, 238),
1162 "mediumspringgreen": (0, 250, 154),
1163 "mediumturquoise": (72, 209, 204),
1164 "mediumvioletred": (199, 21, 133),
1165 "midnightblue": (25, 25, 112),
1166 "mintcream": (245, 255, 250),
1167 "mistyrose": (255, 228, 225),
1168 "moccasin": (255, 228, 181),
1169 "navajowhite": (255, 222, 173),
1170 "navy": (0, 0, 128),
1171 "oldlace": (253, 245, 230),
1172 "olive": (128, 128, 0),
1173 "olivedrab": (107, 142, 35),
1174 "orange": (255, 165, 0),
1175 "orangered": (255, 69, 0),
1176 "orchid": (218, 112, 214),
1177 "palegoldenrod": (238, 232, 170),
1178 "palegreen": (152, 251, 152),
1179 "paleturquoise": (175, 238, 238),
1180 "palevioletred": (219, 112, 147),
1181 "papayawhip": (255, 239, 213),
1182 "peachpuff": (255, 218, 185),
1183 "peru": (205, 133, 63),
1184 "pink": (255, 192, 203),
1185 "plum": (221, 160, 221),
1186 "powderblue": (176, 224, 230),
1187 "purple": (128, 0, 128),
1188 "red": (255, 0, 0),
1189 "rosybrown": (188, 143, 143),
1190 "royalblue": (65, 105, 225),
1191 "saddlebrown": (139, 69, 19),
1192 "salmon": (250, 128, 114),
1193 "sandybrown": (244, 164, 96),
1194 "seagreen": (46, 139, 87),
1195 "seashell": (255, 245, 238),
1196 "sienna": (160, 82, 45),
1197 "silver": (192, 192, 192),
1198 "skyblue": (135, 206, 235),
1199 "slateblue": (106, 90, 205),
1200 "slategray": (112, 128, 144),
1201 "slategrey": (112, 128, 144),
1202 "snow": (255, 250, 250),
1203 "springgreen": (0, 255, 127),
1204 "steelblue": (70, 130, 180),
1205 "tan": (210, 180, 140),
1206 "teal": (0, 128, 128),
1207 "thistle": (216, 191, 216),
1208 "tomato": (255, 99, 71),
1209 "turquoise": (64, 224, 208),
1210 "violet": (238, 130, 238),
1211 "wheat": (245, 222, 179),
1212 "white": (255, 255, 255),
1213 "whitesmoke": (245, 245, 245),
1214 "yellow": (255, 255, 0),
1215 "yellowgreen": (154, 205, 50),
9991216}
10001217
10011218
1002def _make_colormap(matrix_width, matrix_height, dark, light,
1003 finder_dark=False, finder_light=False,
1004 data_dark=False, data_light=False,
1005 version_dark=False, version_light=False,
1006 format_dark=False, format_light=False,
1007 alignment_dark=False, alignment_light=False,
1008 timing_dark=False, timing_light=False,
1009 separator=False, dark_module=False,
1010 quiet_zone=False):
1219def _make_colormap(
1220 matrix_width,
1221 matrix_height,
1222 dark,
1223 light,
1224 finder_dark=False,
1225 finder_light=False,
1226 data_dark=False,
1227 data_light=False,
1228 version_dark=False,
1229 version_light=False,
1230 format_dark=False,
1231 format_light=False,
1232 alignment_dark=False,
1233 alignment_light=False,
1234 timing_dark=False,
1235 timing_light=False,
1236 separator=False,
1237 dark_module=False,
1238 quiet_zone=False,
1239):
10111240 unsupported = ()
10121241 is_square = matrix_width == matrix_height
10131242 if not is_square: # rMQR
1014 unsupported = [consts.TYPE_DARKMODULE, consts.TYPE_VERSION_DARK, consts.TYPE_VERSION_LIGHT]
1243 unsupported = [
1244 consts.TYPE_DARKMODULE,
1245 consts.TYPE_VERSION_DARK,
1246 consts.TYPE_VERSION_LIGHT,
1247 ]
10151248 if matrix_width < 43: # rMQR R11x27, R13x27, …
1016 unsupported.extend((consts.TYPE_ALIGNMENT_PATTERN_DARK, consts.TYPE_ALIGNMENT_PATTERN_LIGHT))
1249 unsupported.extend(
1250 (
1251 consts.TYPE_ALIGNMENT_PATTERN_DARK,
1252 consts.TYPE_ALIGNMENT_PATTERN_LIGHT,
1253 )
1254 )
10171255 elif matrix_width < 45: # QR Code version 7
10181256 unsupported = [consts.TYPE_VERSION_DARK, consts.TYPE_VERSION_LIGHT]
10191257 if matrix_width < 21: # Lesser than QR Code version 1 => Micro QR code
1020 unsupported.extend([consts.TYPE_DARKMODULE,
1021 consts.TYPE_ALIGNMENT_PATTERN_DARK,
1022 consts.TYPE_ALIGNMENT_PATTERN_LIGHT])
1258 unsupported.extend(
1259 [
1260 consts.TYPE_DARKMODULE,
1261 consts.TYPE_ALIGNMENT_PATTERN_DARK,
1262 consts.TYPE_ALIGNMENT_PATTERN_LIGHT,
1263 ]
1264 )
10231265 mt2color = {
10241266 consts.TYPE_FINDER_PATTERN_DARK: finder_dark if finder_dark is not False else dark,
10251267 consts.TYPE_FINDER_PATTERN_LIGHT: finder_light if finder_light is not False else light,
……@@ -1028,7 +1270,9 @@ def _make_colormap(matrix_width, matrix_height, dark, light,
10281270 consts.TYPE_VERSION_DARK: version_dark if version_dark is not False else dark,
10291271 consts.TYPE_VERSION_LIGHT: version_light if version_light is not False else light,
10301272 consts.TYPE_ALIGNMENT_PATTERN_DARK: alignment_dark if alignment_dark is not False else dark,
1031 consts.TYPE_ALIGNMENT_PATTERN_LIGHT: alignment_light if alignment_light is not False else light,
1273 consts.TYPE_ALIGNMENT_PATTERN_LIGHT: alignment_light
1274 if alignment_light is not False
1275 else light,
10321276 consts.TYPE_TIMING_DARK: timing_dark if timing_dark is not False else dark,
10331277 consts.TYPE_TIMING_LIGHT: timing_light if timing_light is not False else light,
10341278 consts.TYPE_FORMAT_DARK: format_dark if format_dark is not False else dark,
……@@ -1041,18 +1285,18 @@ def _make_colormap(matrix_width, matrix_height, dark, light,
10411285
10421286
10431287_VALID_SERIALIZERS = {
1044 'svg': write_svg,
1045 'png': write_png,
1046 'eps': write_eps,
1047 'txt': write_txt,
1048 'pdf': write_pdf,
1049 'ans': write_terminal,
1050 'pbm': write_pbm,
1051 'pam': write_pam,
1052 'ppm': write_ppm,
1053 'tex': write_tex,
1054 'xbm': write_xbm,
1055 'xpm': write_xpm,
1288 "svg": write_svg,
1289 "png": write_png,
1290 "eps": write_eps,
1291 "txt": write_txt,
1292 "pdf": write_pdf,
1293 "ans": write_terminal,
1294 "pbm": write_pbm,
1295 "pam": write_pam,
1296 "ppm": write_ppm,
1297 "tex": write_tex,
1298 "xbm": write_xbm,
1299 "xpm": write_xpm,
10561300}
10571301
10581302
……@@ -1064,16 +1308,16 @@ def save(matrix, matrix_size, out, kind=None, **kw):
10641308 is_stream = True
10651309 except AttributeError:
10661310 fname = out
1067 ext = fname[fname.rfind('.') + 1:].lower()
1311 ext = fname[fname.rfind(".") + 1 :].lower()
10681312 else:
10691313 ext = kind.lower()
1070 is_svgz = not is_stream and ext == 'svgz'
1314 is_svgz = not is_stream and ext == "svgz"
10711315 try:
1072 serializer = _VALID_SERIALIZERS[ext if not is_svgz else 'svg']
1316 serializer = _VALID_SERIALIZERS[ext if not is_svgz else "svg"]
10731317 except KeyError:
10741318 raise ValueError(f'Unknown file extension ".{ext}"')
10751319 if is_svgz:
1076 with gzip.open(out, 'wb', compresslevel=kw.pop('compresslevel', 9)) as f:
1320 with gzip.open(out, "wb", compresslevel=kw.pop("compresslevel", 9)) as f:
10771321 serializer(matrix, matrix_size, f, **kw)
10781322 else:
10791323 serializer(matrix, matrix_size, out, **kw)
plugin.video.jacktook/lib/vendor/torf/__init__.py +1 -1
……@@ -18,7 +18,7 @@
1818Create and parse torrent files and magnet URIs
1919"""
2020
21__version__ = '4.2.0'
21__version__ = "4.2.0"
2222
2323from ._errors import *
2424from ._magnet import Magnet
plugin.video.jacktook/lib/vendor/torf/_errors.py +68 -37
……@@ -17,6 +17,7 @@ import os
1717
1818class TorfError(Exception):
1919 """Base exception for all exceptions raised by torf"""
20
2021 def __init__(self, msg, *posargs, **kwargs):
2122 super().__init__(msg)
2223 self.posargs = posargs
……@@ -25,9 +26,10 @@ class TorfError(Exception):
2526
2627class URLError(TorfError):
2728 """Invalid URL"""
29
2830 def __init__(self, url):
2931 self._url = url
30 super().__init__(f'{url}: Invalid URL', url)
32 super().__init__(f"{url}: Invalid URL", url)
3133
3234 @property
3335 def url(self):
……@@ -37,16 +39,20 @@ class URLError(TorfError):
3739
3840class PieceSizeError(TorfError):
3941 """Invalid piece size"""
42
4043 def __init__(self, size, min=None, max=None):
4144 self._size = size
4245 self._min = min
4346 self._max = max
4447 if min is not None and max is not None:
45 super().__init__(f'Piece size must be between {min} and {max}: {size}',
46 size, min=min, max=max)
48 super().__init__(
49 f"Piece size must be between {min} and {max}: {size}",
50 size,
51 min=min,
52 max=max,
53 )
4754 else:
48 super().__init__(f'Piece size must be divisible by 16 KiB: {size}',
49 size)
55 super().__init__(f"Piece size must be divisible by 16 KiB: {size}", size)
5056
5157 @property
5258 def size(self):
……@@ -66,18 +72,20 @@ class PieceSizeError(TorfError):
6672
6773class MetainfoError(TorfError):
6874 """Invalid torrent metainfo"""
75
6976 def __init__(self, msg):
70 super().__init__(f'Invalid metainfo: {msg}', msg)
77 super().__init__(f"Invalid metainfo: {msg}", msg)
7178
7279
7380class BdecodeError(TorfError):
7481 """Failed to decode bencoded byte sequence"""
82
7583 def __init__(self, filepath=None):
7684 self._filepath = filepath
7785 if filepath is None:
78 super().__init__('Invalid metainfo format')
86 super().__init__("Invalid metainfo format")
7987 else:
80 super().__init__(f'{filepath}: Invalid torrent file format', filepath)
88 super().__init__(f"{filepath}: Invalid torrent file format", filepath)
8189
8290 @property
8391 def filepath(self):
……@@ -87,13 +95,14 @@ class BdecodeError(TorfError):
8795
8896class MagnetError(TorfError):
8997 """Invalid magnet URI or value"""
98
9099 def __init__(self, uri, reason=None):
91100 self._uri = uri
92101 self._reason = reason
93102 if reason is not None:
94 super().__init__(f'{uri}: {reason}', uri, reason=reason)
103 super().__init__(f"{uri}: {reason}", uri, reason=reason)
95104 else:
96 super().__init__(f'{uri}: Invalid magnet URI', uri)
105 super().__init__(f"{uri}: Invalid magnet URI", uri)
97106
98107 @property
99108 def uri(self):
……@@ -108,9 +117,10 @@ class MagnetError(TorfError):
108117
109118class PathError(TorfError):
110119 """General invalid or unexpected path"""
120
111121 def __init__(self, path, msg):
112122 self._path = path
113 super().__init__(f'{path}: {msg}', path, msg)
123 super().__init__(f"{path}: {msg}", path, msg)
114124
115125 @property
116126 def path(self):
……@@ -120,10 +130,11 @@ class PathError(TorfError):
120130
121131class CommonPathError(TorfError):
122132 """Files don't share parent directory"""
133
123134 def __init__(self, filepaths):
124135 self._filepaths = filepaths
125 filepaths_str = ', '.join(str(fp) for fp in filepaths)
126 super().__init__(f'No common parent path: {filepaths_str}', filepaths)
136 filepaths_str = ", ".join(str(fp) for fp in filepaths)
137 super().__init__(f"No common parent path: {filepaths_str}", filepaths)
127138
128139 @property
129140 def filepaths(self):
……@@ -133,9 +144,10 @@ class CommonPathError(TorfError):
133144
134145class VerifyIsDirectoryError(TorfError):
135146 """Expected file but found directory"""
147
136148 def __init__(self, path):
137149 self._path = path
138 super().__init__(f'{path}: Is a directory', path)
150 super().__init__(f"{path}: Is a directory", path)
139151
140152 @property
141153 def path(self):
……@@ -145,9 +157,10 @@ class VerifyIsDirectoryError(TorfError):
145157
146158class VerifyNotDirectoryError(TorfError):
147159 """Expected (link to) directory, but found something else"""
160
148161 def __init__(self, path):
149162 self._path = path
150 super().__init__(f'{path}: Not a directory', path)
163 super().__init__(f"{path}: Not a directory", path)
151164
152165 @property
153166 def path(self):
……@@ -157,18 +170,29 @@ class VerifyNotDirectoryError(TorfError):
157170
158171class VerifyFileSizeError(TorfError):
159172 """Unexpected file size"""
173
160174 def __init__(self, filepath, actual_size, expected_size):
161175 self._filepath = filepath
162176 self._actual_size = actual_size
163177 self._expected_size = expected_size
164178 if actual_size > expected_size:
165 super().__init__(f'{filepath}: Too big: {actual_size} instead of {expected_size} bytes',
166 filepath, actual_size=actual_size, expected_size=expected_size)
179 super().__init__(
180 f"{filepath}: Too big: {actual_size} instead of {expected_size} bytes",
181 filepath,
182 actual_size=actual_size,
183 expected_size=expected_size,
184 )
167185 elif actual_size < expected_size:
168 super().__init__(f'{filepath}: Too small: {actual_size} instead of {expected_size} bytes',
169 filepath, actual_size=actual_size, expected_size=expected_size)
186 super().__init__(
187 f"{filepath}: Too small: {actual_size} instead of {expected_size} bytes",
188 filepath,
189 actual_size=actual_size,
190 expected_size=expected_size,
191 )
170192 else:
171 raise RuntimeError(f'Unjustified: actual_size={actual_size} == expected_size={expected_size}')
193 raise RuntimeError(
194 f"Unjustified: actual_size={actual_size} == expected_size={expected_size}"
195 )
172196
173197 @property
174198 def filepath(self):
……@@ -188,14 +212,15 @@ class VerifyFileSizeError(TorfError):
188212
189213class VerifyContentError(TorfError):
190214 """On-disk data does not match hashes in metainfo"""
215
191216 def __init__(self, filepath, piece_index, piece_size, file_sizes):
192217 self._filepath = filepath
193218 self._piece_index = piece_index
194219 self._piece_size = piece_size
195 msg = f'Corruption in piece {piece_index+1}'
220 msg = f"Corruption in piece {piece_index + 1}"
196221
197222 if len(file_sizes) < 1:
198 raise RuntimeError('file_sizes argument is empty: {file_sizes!r}')
223 raise RuntimeError("file_sizes argument is empty: {file_sizes!r}")
199224 elif len(file_sizes) == 1:
200225 corrupt_files = (file_sizes[0][0],)
201226 else:
……@@ -208,7 +233,7 @@ class VerifyContentError(TorfError):
208233
209234 # Find the files that are covered by the corrupt piece
210235 cur_pos = 0
211 for filepath,filesize in file_sizes:
236 for filepath, filesize in file_sizes:
212237 # `file` is possibly corrupt if:
213238 # 1. The corrupt piece STARTS between the beginning and the end
214239 # of the file in the stream.
……@@ -218,17 +243,20 @@ class VerifyContentError(TorfError):
218243 # and end of the corrupt piece (i.e. file fits in one piece).
219244 file_i_beg = cur_pos
220245 file_i_end = file_i_beg + filesize
221 if (file_i_beg <= err_i_beg < file_i_end or
222 file_i_beg < err_i_end <= file_i_end or
223 (file_i_beg >= err_i_beg and file_i_end < err_i_end)):
246 if (
247 file_i_beg <= err_i_beg < file_i_end
248 or file_i_beg < err_i_end <= file_i_end
249 or (file_i_beg >= err_i_beg and file_i_end < err_i_end)
250 ):
224251 corrupt_files.append(filepath)
225252 cur_pos += filesize
226253
227254 if len(corrupt_files) == 1:
228 msg += f' in {corrupt_files[0]}'
255 msg += f" in {corrupt_files[0]}"
229256 else:
230 msg += (', at least one of these files is corrupt: ' +
231 ', '.join(str(f) for f in corrupt_files))
257 msg += ", at least one of these files is corrupt: " + ", ".join(
258 str(f) for f in corrupt_files
259 )
232260
233261 self._files = tuple(corrupt_files)
234262 super().__init__(msg, filepath, piece_index, piece_size, file_sizes)
……@@ -256,14 +284,15 @@ class VerifyContentError(TorfError):
256284
257285class ReadError(TorfError):
258286 """Unreadable file or stream"""
287
259288 def __init__(self, errno, path=None):
260289 self._errno = errno
261290 self._path = path
262 msg = os.strerror(errno) if errno else 'Unable to read'
291 msg = os.strerror(errno) if errno else "Unable to read"
263292 if path is None:
264 super().__init__(f'{msg}', errno)
293 super().__init__(f"{msg}", errno)
265294 else:
266 super().__init__(f'{path}: {msg}', errno, path)
295 super().__init__(f"{path}: {msg}", errno, path)
267296
268297 @property
269298 def path(self):
……@@ -286,14 +315,15 @@ class MemoryError(TorfError, MemoryError):
286315
287316class WriteError(TorfError):
288317 """Unwritable file or stream"""
318
289319 def __init__(self, errno, path=None):
290320 self._errno = errno
291321 self._path = path
292 msg = os.strerror(errno) if errno else 'Unable to write'
322 msg = os.strerror(errno) if errno else "Unable to write"
293323 if path is None:
294 super().__init__(f'{msg}', path)
324 super().__init__(f"{msg}", path)
295325 else:
296 super().__init__(f'{path}: {msg}', errno, path)
326 super().__init__(f"{path}: {msg}", errno, path)
297327
298328 @property
299329 def path(self):
……@@ -308,10 +338,11 @@ class WriteError(TorfError):
308338
309339class ConnectionError(TorfError):
310340 """Unwritable file or stream"""
311 def __init__(self, url, msg='Failed'):
341
342 def __init__(self, url, msg="Failed"):
312343 self._url = url
313344 self._msg = str(msg)
314 super().__init__(f'{url}: {msg}', url, msg)
345 super().__init__(f"{url}: {msg}", url, msg)
315346
316347 @property
317348 def url(self):
plugin.video.jacktook/lib/vendor/torf/_generate.py +97 -56
……@@ -25,15 +25,17 @@ from ._stream import TorrentFileStream
2525
2626QUEUE_CLOSED = object()
2727
28_debug = logging.getLogger('torf').debug
28_debug = logging.getLogger("torf").debug
29
2930
3031def _thread_name():
3132 return threading.current_thread().name
3233
34
3335def _pretty_bytes(b):
3436 if isinstance(b, (bytes, bytearray)) and len(b) > 8:
3537 # return b[:8].hex() + '...' + b[-8:].hex()
36 return b[:8] + b'...' + b[-8:]
38 return b[:8] + b"..." + b[-8:]
3739 else:
3840 return b
3941
……@@ -76,12 +78,12 @@ class Worker:
7678 self._thread.start()
7779 except RuntimeError as e:
7880 if fail_ok:
79 _debug(f'{self.name}: Failed to start thread: {e!r} - but that\'s ok')
81 _debug(f"{self.name}: Failed to start thread: {e!r} - but that's ok")
8082 else:
81 _debug(f'{self.name}: Failed to start thread: {e!r}')
83 _debug(f"{self.name}: Failed to start thread: {e!r}")
8284 raise
8385 else:
84 _debug(f'{self.name}: Started')
86 _debug(f"{self.name}: Started")
8587
8688 def join(self, *args, **kwargs):
8789 if self.is_running:
……@@ -102,7 +104,7 @@ class Reader(Worker):
102104 self._piece_queue = queue.Queue(maxsize=queue_size)
103105 self._stop = False
104106 self._memory_error_timestamp = -1
105 super().__init__(name='reader', worker=self._push_pieces)
107 super().__init__(name="reader", worker=self._push_pieces)
106108
107109 def _push_pieces(self):
108110 stream = TorrentFileStream(self._torrent)
……@@ -111,10 +113,14 @@ class Reader(Worker):
111113 for piece_index, (piece, filepath, exceptions) in enumerate(iter_pieces):
112114 # _debug(f'{_thread_name()}: Read #{piece_index}')
113115 if self._stop:
114 _debug(f'{_thread_name()}: Stopped reading')
116 _debug(f"{_thread_name()}: Stopped reading")
115117 break
116118 elif exceptions:
117 self._push_piece(piece_index=piece_index, filepath=filepath, exceptions=exceptions)
119 self._push_piece(
120 piece_index=piece_index,
121 filepath=filepath,
122 exceptions=exceptions,
123 )
118124 elif piece:
119125 self._push_piece(piece_index=piece_index, filepath=filepath, piece=piece)
120126 else:
……@@ -125,12 +131,12 @@ class Reader(Worker):
125131 # _debug(f'{_thread_name()}: {self._piece_queue.qsize()} pieces queued')
126132
127133 except BaseException as e:
128 _debug(f'{_thread_name()}: Exception while reading: {e!r}')
134 _debug(f"{_thread_name()}: Exception while reading: {e!r}")
129135 raise
130136
131137 finally:
132138 self._piece_queue.put(QUEUE_CLOSED)
133 _debug(f'{_thread_name()}: Piece queue is now exhausted')
139 _debug(f"{_thread_name()}: Piece queue is now exhausted")
134140 stream.close()
135141
136142 def _push_piece(self, *, piece_index, filepath, piece=None, exceptions=()):
……@@ -145,7 +151,7 @@ class Reader(Worker):
145151 old_maxsize = self._piece_queue.maxsize
146152 new_maxsize = max(1, int(old_maxsize * 0.9))
147153 if new_maxsize != old_maxsize:
148 _debug(f'{_thread_name()}: Reducing piece_queue.maxsize to {new_maxsize}')
154 _debug(f"{_thread_name()}: Reducing piece_queue.maxsize to {new_maxsize}")
149155 self._piece_queue.maxsize = new_maxsize
150156 self._memory_error_timestamp = now
151157 else:
……@@ -154,7 +160,7 @@ class Reader(Worker):
154160 def stop(self):
155161 """Stop reading and close the piece queue"""
156162 if not self._stop:
157 _debug(f'{_thread_name()}: {type(self).__name__}: Setting stop flag')
163 _debug(f"{_thread_name()}: {type(self).__name__}: Setting stop flag")
158164 self._stop = True
159165
160166 @property
……@@ -180,7 +186,7 @@ class HasherPool:
180186
181187 # Janitor takes care of closing the hash queue, removing idle hashers, etc
182188 self._janitor = Worker(
183 name='janitor',
189 name="janitor",
184190 worker=self._janitor_thread,
185191 start=False,
186192 )
……@@ -188,7 +194,7 @@ class HasherPool:
188194 # Hashers read from piece_queue and push to hash_queue
189195 self._hashers = [
190196 Worker(
191 name='hasher1',
197 name="hasher1",
192198 # One hasher is vital an may not die from boredom
193199 worker=lambda: self._hasher_thread(is_vital=True),
194200 start=False,
……@@ -197,7 +203,7 @@ class HasherPool:
197203 for i in range(2, hasher_threads + 1):
198204 self._hashers.append(
199205 Worker(
200 name=f'hasher{i}',
206 name=f"hasher{i}",
201207 # All other hashers should die if they are bored
202208 worker=lambda: self._hasher_thread(is_vital=False),
203209 start=False,
……@@ -223,13 +229,13 @@ class HasherPool:
223229 task = piece_queue.get(timeout=0.5)
224230 except queue.Empty:
225231 if not is_vital:
226 _debug(f'{_thread_name()}: I am bored, byeee!')
232 _debug(f"{_thread_name()}: I am bored, byeee!")
227233 break
228234 else:
229 _debug(f'{_thread_name()}: I am bored, but needed.')
235 _debug(f"{_thread_name()}: I am bored, but needed.")
230236 else:
231237 if task is QUEUE_CLOSED:
232 _debug(f'{_thread_name()}: piece_queue is closed')
238 _debug(f"{_thread_name()}: piece_queue is closed")
233239 # Repeat QUEUE_CLOSED to the next sibling. This ensures
234240 # there is always one more QUEUE_CLOSED queued than running
235241 # threads. Otherwise, one thread might consume multiple
……@@ -257,11 +263,11 @@ class HasherPool:
257263
258264 def _janitor_thread(self):
259265 while True:
260 _debug(f'{_thread_name()}: Waiting for finalize event')
266 _debug(f"{_thread_name()}: Waiting for finalize event")
261267 finalization_initiated = self._finalize_event.wait(timeout=1.0)
262268 if finalization_initiated:
263269 self._wait_for_hashers()
264 _debug(f'{_thread_name()}: Closing hash queue')
270 _debug(f"{_thread_name()}: Closing hash queue")
265271 self._hash_queue.put(QUEUE_CLOSED)
266272 break
267273
……@@ -269,28 +275,28 @@ class HasherPool:
269275 # Remove terminated idle hashers
270276 for hasher in tuple(self._hashers):
271277 if not hasher.is_running:
272 _debug(f'{_thread_name()}: Pruning {hasher.name}')
278 _debug(f"{_thread_name()}: Pruning {hasher.name}")
273279 self._hashers.remove(hasher)
274280
275 _debug(f'{_thread_name()}: Terminating')
281 _debug(f"{_thread_name()}: Terminating")
276282
277283 def _wait_for_hashers(self):
278284 while True:
279285 # _debug(f'{_thread_name()}: Hashers running: {[h.name for h in self._hashers if h.is_running]}')
280286 if all(not h.is_running for h in self._hashers):
281 _debug(f'{_thread_name()}: All hashers terminated')
287 _debug(f"{_thread_name()}: All hashers terminated")
282288 break
283289
284290 def join(self):
285291 """Block until all threads have terminated"""
286292 for hasher in self._hashers:
287 _debug(f'{_thread_name()}: Joining {hasher.name}')
293 _debug(f"{_thread_name()}: Joining {hasher.name}")
288294 hasher.join()
289 _debug(f'{_thread_name()}: Joined all hashers')
295 _debug(f"{_thread_name()}: Joined all hashers")
290296
291 _debug(f'{_thread_name()}: Joining {self._janitor.name}')
297 _debug(f"{_thread_name()}: Joining {self._janitor.name}")
292298 self._janitor.join()
293 _debug(f'{_thread_name()}: Joined {self._janitor.name}')
299 _debug(f"{_thread_name()}: Joined {self._janitor.name}")
294300
295301 @property
296302 def hash_queue(self):
……@@ -337,7 +343,7 @@ class Collector:
337343 self._collect(*task)
338344
339345 except BaseException as e:
340 _debug(f'{_thread_name()}: Exception while dequeueing piece hashes: {e!r}')
346 _debug(f"{_thread_name()}: Exception while dequeueing piece hashes: {e!r}")
341347 self._cancel()
342348 raise
343349
……@@ -365,8 +371,12 @@ class Collector:
365371 if self._callback:
366372 # _debug(f'{_thread_name()}: Collector callback: {self._callback}')
367373 maybe_cancel = self._callback(
368 piece_index, len(self._pieces_seen), self._pieces_total,
369 filepath, piece_hash, exceptions,
374 piece_index,
375 len(self._pieces_seen),
376 self._pieces_total,
377 filepath,
378 piece_hash,
379 exceptions,
370380 )
371381 # _debug(f'{_thread_name()}: Collector callback return value: {maybe_cancel}')
372382 if maybe_cancel is not None:
……@@ -380,11 +390,11 @@ class Collector:
380390 self._reader.stop()
381391
382392 def _finalize(self):
383 _debug(f'{_thread_name()}: Joining {self._reader}')
393 _debug(f"{_thread_name()}: Joining {self._reader}")
384394 self._reader.join()
385 _debug(f'{_thread_name()}: Joining {self._hashers}')
395 _debug(f"{_thread_name()}: Joining {self._hashers}")
386396 self._hashers.join()
387 _debug(f'{_thread_name()}: hash_queue has {self._hashers.hash_queue.qsize()} items left')
397 _debug(f"{_thread_name()}: hash_queue has {self._hashers.hash_queue.qsize()} items left")
388398
389399 @property
390400 def hashes(self):
……@@ -397,6 +407,7 @@ class _IntervaledCallback:
397407 Callable that calls `callback`, but only if at least `interval` seconds
398408 elapsed since the previous call
399409 """
410
400411 def __init__(self, callback, interval=0):
401412 self._callback = callback
402413 self._interval = interval
……@@ -422,22 +433,33 @@ class _TranslatingCallback:
422433 )
423434
424435 def __call__(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
425 force = self._force_callback(piece_index, pieces_done, pieces_total,
426 filepath, piece_hash, exceptions)
427 return self._intervaled_callback(piece_index, pieces_done, pieces_total,
428 filepath, piece_hash, exceptions,
429 force=force)
436 force = self._force_callback(
437 piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions
438 )
439 return self._intervaled_callback(
440 piece_index,
441 pieces_done,
442 pieces_total,
443 filepath,
444 piece_hash,
445 exceptions,
446 force=force,
447 )
430448
431 def _force_callback(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
449 def _force_callback(
450 self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions
451 ):
432452 # Figure out if we must ignore the interval for this call. This method
433453 # is called for every hashed piece and should be as efficient as
434454 # possible.
435 raise NotImplementedError('You must implement this method!')
455 raise NotImplementedError("You must implement this method!")
436456
437 def _call_callback(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
457 def _call_callback(
458 self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions
459 ):
438460 # Translate arguments for the actual callback. This method is only
439461 # called at intervals (e.g. once per second).
440 raise NotImplementedError('You must implement this method!')
462 raise NotImplementedError("You must implement this method!")
441463
442464
443465class GenerateCallback(_TranslatingCallback):
……@@ -446,10 +468,14 @@ class GenerateCallback(_TranslatingCallback):
446468 :meth:`~.Torrent.generate`
447469 """
448470
449 def _force_callback(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
471 def _force_callback(
472 self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions
473 ):
450474 return exceptions or pieces_done >= pieces_total
451475
452 def _call_callback(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
476 def _call_callback(
477 self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions
478 ):
453479 if exceptions:
454480 # Torrent creation errors are always fatal and must be raised
455481 raise exceptions[0]
……@@ -463,6 +489,7 @@ class VerifyCallback(_TranslatingCallback):
463489 Translate arguments from :class:`Collector` to what's specified by
464490 :meth:`~.Torrent.verify`
465491 """
492
466493 def __init__(self, *args, path, **kwargs):
467494 super().__init__(*args, **kwargs)
468495
……@@ -482,34 +509,48 @@ class VerifyCallback(_TranslatingCallback):
482509 for file in self._torrent.files
483510 )
484511
485 def _force_callback(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
512 def _force_callback(
513 self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions
514 ):
486515 return (
487516 # Always report exceptions
488517 exceptions
489518 # Always report completion
490519 or pieces_done >= pieces_total
491520 # Always report hash mismatch
492 or piece_hash is not None and piece_hash != self._exp_hashes[piece_index]
521 or (piece_hash is not None and piece_hash != self._exp_hashes[piece_index])
493522 )
494523
495 def _call_callback(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
524 def _call_callback(
525 self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions
526 ):
496527 if (
497528 # Don't add verification error if there are other errors
498529 not exceptions
499530 # Piece hash was calculated and doesn't match
500 and piece_hash is not None and piece_hash != self._exp_hashes[piece_index]
531 and piece_hash is not None
532 and piece_hash != self._exp_hashes[piece_index]
501533 ):
502 exceptions = (errors.VerifyContentError(
503 filepath, piece_index, self._torrent.piece_size, self._exp_file_sizes,
504 ),)
534 exceptions = (
535 errors.VerifyContentError(
536 filepath,
537 piece_index,
538 self._torrent.piece_size,
539 self._exp_file_sizes,
540 ),
541 )
505542
506543 if self._callback:
507544 # Callback can raise exception or handle it otherwise
508545 def call_callback(fpath, exception):
509546 return self._callback(
510 self._torrent, fpath,
511 pieces_done, pieces_total, piece_index,
512 piece_hash, exception,
547 self._torrent,
548 fpath,
549 pieces_done,
550 pieces_total,
551 piece_index,
552 piece_hash,
553 exception,
513554 )
514555
515556 if exceptions:
……@@ -530,10 +571,10 @@ class VerifyCallback(_TranslatingCallback):
530571
531572 @staticmethod
532573 def _get_path_from_exception(exception):
533 for attr in ('filepath', 'path'):
574 for attr in ("filepath", "path"):
534575 try:
535576 return getattr(exception, attr)
536577 except AttributeError:
537578 pass
538579
539 raise RuntimeError(f'Failed to get path from {exception!r}')
580 raise RuntimeError(f"Failed to get path from {exception!r}")
plugin.video.jacktook/lib/vendor/torf/_magnet.py +84 -64
……@@ -24,7 +24,7 @@ from . import _errors as error
2424from . import _utils as utils
2525
2626
27class Magnet():
27class Magnet:
2828 """
2929 BTIH Magnet URI
3030
……@@ -49,10 +49,22 @@ class Magnet():
4949 | http://shareaza.sourceforge.net/mediawiki/index.php/Magnet_URI_scheme
5050 """
5151
52 _INFOHASH_REGEX = re.compile(r'^[0-9a-f]{40}|[a-z2-7]{32}$', flags=re.IGNORECASE)
53 _XT_REGEX = re.compile(r'^urn:btih:([0-9a-f]{40}|[a-z2-7]{32})$', flags=re.IGNORECASE)
54
55 def __init__(self, xt, *, dn=None, xl=None, tr=None, xs=None, as_=None, ws=None, kt=None, **kwargs):
52 _INFOHASH_REGEX = re.compile(r"^[0-9a-f]{40}|[a-z2-7]{32}$", flags=re.IGNORECASE)
53 _XT_REGEX = re.compile(r"^urn:btih:([0-9a-f]{40}|[a-z2-7]{32})$", flags=re.IGNORECASE)
54
55 def __init__(
56 self,
57 xt,
58 *,
59 dn=None,
60 xl=None,
61 tr=None,
62 xs=None,
63 as_=None,
64 ws=None,
65 kt=None,
66 **kwargs,
67 ):
5668 self._tr = utils.MonitoredList(type=utils.URL)
5769 self._ws = utils.MonitoredList(type=utils.URL)
5870 self.xt = xt
……@@ -66,12 +78,12 @@ class Magnet():
6678
6779 self._x = defaultdict(lambda: None)
6880 for key in tuple(kwargs):
69 if key.startswith('x_'):
81 if key.startswith("x_"):
7082 self._x[key[2:]] = kwargs.pop(key)
7183
7284 if kwargs:
7385 key, value = next(iter(kwargs.items()))
74 raise TypeError(f'Unrecognized argument: {key}={value!r}')
86 raise TypeError(f"Unrecognized argument: {key}={value!r}")
7587
7688 @property
7789 def dn(self):
……@@ -80,15 +92,14 @@ class Magnet():
8092
8193 @dn.setter
8294 def dn(self, value):
83 self._dn = str(value).replace('\n', ' ') if value is not None else None
95 self._dn = str(value).replace("\n", " ") if value is not None else None
8496
8597 @property
8698 def xt(self):
8799 """
88 eXact Topic: URN containing the info hash as base 16 or base 32
100 EXact Topic: URN containing the info hash as base 16 or base 32
89101
90102 Example:
91
92103 urn:btih:3bb9561e35b06175bb6d2c2330578dc83846cc5d
93104
94105 For convenience, this property may be set to the info hash without the
……@@ -96,7 +107,7 @@ class Magnet():
96107
97108 :raises MagnetError: if set to an invalid value
98109 """
99 return f'urn:btih:{self._infohash}'
110 return f"urn:btih:{self._infohash}"
100111
101112 @xt.setter
102113 def xt(self, value):
……@@ -107,7 +118,7 @@ class Magnet():
107118 match = self._XT_REGEX.match(value)
108119 if match:
109120 self._infohash = match.group(1)
110 if not hasattr(self, '_infohash'):
121 if not hasattr(self, "_infohash"):
111122 raise error.MagnetError(value, 'Invalid exact topic ("xt")')
112123
113124 @property
……@@ -126,12 +137,12 @@ class Magnet():
126137 if match:
127138 self._infohash = value
128139 else:
129 raise error.MagnetError(value, 'Invalid info hash')
140 raise error.MagnetError(value, "Invalid info hash")
130141
131142 @property
132143 def xl(self):
133144 """
134 eXact Length: Size in bytes or ``None``
145 EXact Length: Size in bytes or ``None``
135146
136147 :raises MagnetError: if set to an invalid value
137148 """
……@@ -146,7 +157,7 @@ class Magnet():
146157 raise error.MagnetError(value, 'Invalid exact length ("xl")')
147158 else:
148159 if value < 1:
149 raise error.MagnetError(value, 'Must be 1 or larger')
160 raise error.MagnetError(value, "Must be 1 or larger")
150161 else:
151162 self._xl = value
152163 else:
……@@ -173,7 +184,7 @@ class Magnet():
173184 @property
174185 def xs(self):
175186 """
176 eXact Source: Torrent file URL or ``None``
187 EXact Source: Torrent file URL or ``None``
177188
178189 :raises URLError: if the URL is invalid
179190 """
……@@ -248,6 +259,7 @@ class Magnet():
248259 """:class:`Torrent` instance"""
249260 # Prevent circular import issues
250261 from ._torrent import Torrent
262
251263 torrent = Torrent()
252264 torrent.name = self.dn
253265 if self.tr:
……@@ -255,15 +267,16 @@ class Magnet():
255267 if self.ws:
256268 torrent.webseeds = self.ws
257269 if self.xl:
258 torrent._metainfo['info']['length'] = self.xl
259 if hasattr(self, '_info'):
260 torrent.metainfo['info'] = self._info
270 torrent._metainfo["info"]["length"] = self.xl
271 if hasattr(self, "_info"):
272 torrent.metainfo["info"] = self._info
261273 elif len(self.infohash) == 40:
262274 torrent._infohash = self.infohash
263275 else:
264276 # Convert base 32 to base 16 (SHA1)
265 torrent._infohash = base64.b16encode(
266 base64.b32decode(self.infohash)).decode('utf-8').lower()
277 torrent._infohash = (
278 base64.b16encode(base64.b32decode(self.infohash)).decode("utf-8").lower()
279 )
267280 return torrent
268281
269282 def get_info(self, validate=True, timeout=60, callback=None):
……@@ -286,20 +299,23 @@ class Magnet():
286299 :return: ``True`` if the "info" section was successfully downloaded,
287300 ``False`` otherwise
288301 """
302
289303 def success():
290 return hasattr(self, '_info')
304 return hasattr(self, "_info")
291305
292306 torrent_urls = []
293 if self.xs: torrent_urls.append(self.xs)
294 if self.as_: torrent_urls.append(self.as_)
295 torrent_urls.extend((url.rstrip('/') + '.torrent' for url in self.ws))
307 if self.xs:
308 torrent_urls.append(self.xs)
309 if self.as_:
310 torrent_urls.append(self.as_)
311 torrent_urls.extend(url.rstrip("/") + ".torrent" for url in self.ws)
296312 # I couldn't find any documentation for the "/file?info_hash=..." GET request, but
297313 # it seems to work for HTTP trackers.
298314 # https://stackoverflow.com/a/1019588
299315 for url in self.tr:
300 if url.scheme in ('http', 'https'):
316 if url.scheme in ("http", "https"):
301317 infohash_enc = urllib.parse.quote_from_bytes(binascii.unhexlify(self.infohash))
302 torrent_urls.append(f'{url.scheme}://{url.netloc}/file?info_hash={infohash_enc}')
318 torrent_urls.append(f"{url.scheme}://{url.netloc}/file?info_hash={infohash_enc}")
303319
304320 start = time.monotonic()
305321 for url in torrent_urls:
……@@ -320,6 +336,7 @@ class Magnet():
320336 """Extract "info" section from `torrent_data` for :meth:`torrent`"""
321337 # Prevent circular import issues
322338 from ._torrent import Torrent
339
323340 stream = io.BytesIO(torrent_data)
324341 try:
325342 torrent = Torrent.read_stream(stream, validate=validate)
……@@ -328,11 +345,13 @@ class Magnet():
328345 callback(e)
329346 else:
330347 if validate and self.infohash != torrent.infohash:
331 raise error.MetainfoError(f'Mismatching info hashes: {self.infohash} != {torrent.infohash}')
332 elif torrent.metainfo['info']:
333 self._info = torrent.metainfo['info']
348 raise error.MetainfoError(
349 f"Mismatching info hashes: {self.infohash} != {torrent.infohash}"
350 )
351 elif torrent.metainfo["info"]:
352 self._info = torrent.metainfo["info"]
334353
335 _KNOWN_PARAMETERS = ('xt', 'dn', 'xl', 'tr', 'xs', 'as', 'ws', 'kt')
354 _KNOWN_PARAMETERS = ("xt", "dn", "xl", "tr", "xs", "as", "ws", "kt")
336355
337356 @classmethod
338357 def from_string(cls, uri):
……@@ -342,30 +361,32 @@ class Magnet():
342361 :raises URLError: if `uri` contains an invalid URL (e.g. :attr:`tr`)
343362 :raises MagnetError: if `uri` is not a valid magnet URI
344363 """
345 info = urllib.parse.urlparse(uri.strip(), scheme='magnet', allow_fragments=False)
346 if not info.scheme == 'magnet':
347 raise error.MagnetError(uri, 'Not a magnet URI')
364 info = urllib.parse.urlparse(uri.strip(), scheme="magnet", allow_fragments=False)
365 if not info.scheme == "magnet":
366 raise error.MagnetError(uri, "Not a magnet URI")
348367 else:
349368 query = urllib.parse.parse_qs(info.query)
350369
351370 # Check for unknown parameters
352371 for key in query:
353 if key not in cls._KNOWN_PARAMETERS and not key.startswith('x_'):
354 raise error.MagnetError(uri, f'{key}: Unknown parameter')
372 if key not in cls._KNOWN_PARAMETERS and not key.startswith("x_"):
373 raise error.MagnetError(uri, f"{key}: Unknown parameter")
355374
356 if 'xt' not in query:
375 if "xt" not in query:
357376 raise error.MagnetError(uri, 'Missing exact topic ("xt")')
358 elif len(query['xt']) > 1:
377 elif len(query["xt"]) > 1:
359378 raise error.MagnetError(uri, 'Multiple exact topics ("xt")')
360379 else:
361 self = cls(xt=query['xt'][0])
380 self = cls(xt=query["xt"][0])
362381
363382 # Parameters that accept only one value
364 for param,attr,name,parse in (('dn', 'dn', 'display name', lambda v: v),
365 ('xl', 'xl', 'exact length', lambda v: v),
366 ('xs', 'xs', 'exact source', lambda v: v),
367 ('as', 'as_', 'acceptable source', lambda v: v),
368 ('kt', 'kt', 'keyword topic', lambda v: v.split())):
383 for param, attr, name, parse in (
384 ("dn", "dn", "display name", lambda v: v),
385 ("xl", "xl", "exact length", lambda v: v),
386 ("xs", "xs", "exact source", lambda v: v),
387 ("as", "as_", "acceptable source", lambda v: v),
388 ("kt", "kt", "keyword topic", lambda v: v.split()),
389 ):
369390 if param in query:
370391 if len(query[param]) > 1:
371392 raise error.MagnetError(uri, f'Multiple {name}s ("{param}")')
……@@ -373,48 +394,47 @@ class Magnet():
373394 setattr(self, attr, parse(query[param][0]))
374395
375396 # Parameters that accept multiple values
376 for param,name in (('tr', 'tracker'),
377 ('ws', 'webseed')):
397 for param, name in (("tr", "tracker"), ("ws", "webseed")):
378398 if param in query:
379399 setattr(self, param, query[param])
380400
381401 return self
382402
383403 def __str__(self):
384 uri = [f'magnet:?xt={self.xt}']
404 uri = [f"magnet:?xt={self.xt}"]
385405
386 for key in ('dn', 'xl', 'xs', 'as_'):
387 value = getattr(self, f'{key}')
406 for key in ("dn", "xl", "xs", "as_"):
407 value = getattr(self, f"{key}")
388408 if value is not None:
389409 if isinstance(value, str):
390 uri.append(f'{key}={utils.urlquote(value)}')
410 uri.append(f"{key}={utils.urlquote(value)}")
391411 else:
392 uri.append(f'{key}={value}')
412 uri.append(f"{key}={value}")
393413
394414 if self.kt:
395 uri.append(f'kt={"+".join(utils.urlquote(k) for k in self.kt)}')
415 uri.append(f"kt={'+'.join(utils.urlquote(k) for k in self.kt)}")
396416
397 for key in ('tr', 'ws'):
398 seq = getattr(self, f'{key}')
417 for key in ("tr", "ws"):
418 seq = getattr(self, f"{key}")
399419 if seq is not None:
400420 for item in seq:
401 uri.append(f'{key}={utils.urlquote(item)}')
421 uri.append(f"{key}={utils.urlquote(item)}")
402422
403 for key,value in self._x.items():
404 uri.append(f'x.{key}={utils.urlquote(value)}')
423 for key, value in self._x.items():
424 uri.append(f"x.{key}={utils.urlquote(value)}")
405425
406 return '&'.join(uri)
426 return "&".join(uri)
407427
408428 def __repr__(self):
409429 clsname = type(self).__name__
410430 kwargs = {}
411431 for param in self._KNOWN_PARAMETERS:
412 if param == 'as':
413 param = 'as_'
432 if param == "as":
433 param = "as_"
414434 value = getattr(self, param)
415435 if value:
416436 kwargs[param] = value
417 for k,v in self.x.items():
418 kwargs[f'x_{k}'] = v
419 kwargs_str = ', '.join(f'{k}={repr(v)}' for k,v in kwargs.items())
420 return f'{clsname}({kwargs_str})'
437 for k, v in self.x.items():
438 kwargs[f"x_{k}"] = v
439 kwargs_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
440 return f"{clsname}({kwargs_str})"
plugin.video.jacktook/lib/vendor/torf/_reuse.py +26 -25
……@@ -9,7 +9,7 @@ from . import _stream as stream
99class find_torrent_files:
1010 """Iterator over ``(torrent_file, torrent_file_counter, exception)`` tuples"""
1111
12 def __init__(self, *paths, max_file_size=float('inf')):
12 def __init__(self, *paths, max_file_size=float("inf")):
1313 self._paths = paths
1414 self._counter = 0
1515 self._max_file_size = max_file_size
……@@ -34,7 +34,7 @@ class find_torrent_files:
3434 except OSError as e:
3535 yield None, self._counter, error.ReadError(e.errno, str(path))
3636
37 elif os.path.basename(path).lower().endswith('.torrent'):
37 elif os.path.basename(path).lower().endswith(".torrent"):
3838 try:
3939 file_size = os.path.getsize(path)
4040 except OSError:
……@@ -75,10 +75,10 @@ def is_file_match(torrent, candidate):
7575 """
7676 # Compare relative file paths and file sizes.
7777 # Order of files is important.
78 torrent_info, candidate_info = torrent.metainfo['info'], candidate.metainfo['info']
78 torrent_info, candidate_info = torrent.metainfo["info"], candidate.metainfo["info"]
7979
8080 # Don't bother doing anything else if the names are different
81 if torrent_info['name'] != candidate_info['name']:
81 if torrent_info["name"] != candidate_info["name"]:
8282 return False
8383
8484 torrent_id = _get_filepaths_and_sizes(torrent_info)
……@@ -89,27 +89,30 @@ def is_file_match(torrent, candidate):
8989
9090 return False
9191
92
9293def _get_filepaths_and_sizes(info):
93 name = info['name']
94 name = info["name"]
9495
9596 # Singlefile torrent
96 length = info.get('length', None)
97 length = info.get("length", None)
9798 if length:
9899 return [(name, length)]
99100
100101 # Multifile torrent
101 files = info.get('files', None)
102 files = info.get("files", None)
102103 if files:
103104 files_and_sizes = []
104105 for file in files:
105 files_and_sizes.append((
106 os.sep.join((name, *file['path'])),
107 file['length'],
108 ))
106 files_and_sizes.append(
107 (
108 os.sep.join((name, *file["path"])),
109 file["length"],
110 )
111 )
109112 return sorted(files_and_sizes)
110113
111114 else:
112 raise RuntimeError(f'Unable to find files: {info!r}')
115 raise RuntimeError(f"Unable to find files: {info!r}")
113116
114117
115118def is_content_match(torrent, candidate):
……@@ -126,7 +129,7 @@ def is_content_match(torrent, candidate):
126129 returned `True`.
127130 """
128131 if not torrent.path:
129 raise RuntimeError(f'Torrent does not have a file system path: {torrent!r}')
132 raise RuntimeError(f"Torrent does not have a file system path: {torrent!r}")
130133
131134 # Compare some piece hashes for each file
132135 with stream.TorrentFileStream(candidate, content_path=torrent.path) as tfs:
……@@ -135,9 +138,7 @@ def is_content_match(torrent, candidate):
135138 all_file_piece_indexes = tfs.get_piece_indexes_of_file(file)
136139 middle_piece_index = int(len(all_file_piece_indexes) / 2)
137140 some_file_piece_indexes = (
138 all_file_piece_indexes[:1]
139 + [middle_piece_index]
140 + all_file_piece_indexes[-1:]
141 all_file_piece_indexes[:1] + [middle_piece_index] + all_file_piece_indexes[-1:]
141142 )
142143 check_piece_indexes.update(some_file_piece_indexes)
143144
……@@ -152,25 +153,25 @@ def copy(from_torrent, to_torrent):
152153 Copy ``pieces``, ``piece length`` and ``files`` from `from_torrent` to
153154 `to_torrent`
154155 """
155 source_info = from_torrent.metainfo['info']
156 to_torrent.metainfo['info']['pieces'] = source_info['pieces']
157 to_torrent.metainfo['info']['piece length'] = source_info['piece length']
158 if 'files' in from_torrent.metainfo['info']:
156 source_info = from_torrent.metainfo["info"]
157 to_torrent.metainfo["info"]["pieces"] = source_info["pieces"]
158 to_torrent.metainfo["info"]["piece length"] = source_info["piece length"]
159 if "files" in from_torrent.metainfo["info"]:
159160 # Confirm both file lists are identical while ignoring order
160161 def make_sortable(files):
161162 return [tuple(f.items()) for f in files]
162163
163164 # Only include "length" and "files" fields
164165 source_files = [
165 {'length': file['length'], 'path': file['path']}
166 for file in source_info['files']
166 {"length": file["length"], "path": file["path"]} for file in source_info["files"]
167167 ]
168168
169 assert sorted(make_sortable(to_torrent.metainfo['info']['files'])) \
170 == sorted(make_sortable(source_files))
169 assert sorted(make_sortable(to_torrent.metainfo["info"]["files"])) == sorted(
170 make_sortable(source_files)
171 )
171172
172173 # Copy file order from `source_info`
173 to_torrent.metainfo['info']['files'] = source_files
174 to_torrent.metainfo["info"]["files"] = source_files
174175
175176
176177class ReuseCallback(generate._IntervaledCallback):
plugin.video.jacktook/lib/vendor/torf/_stream.py +37 -30
……@@ -1,5 +1,4 @@
11import errno
2import functools
32import hashlib
43import itertools
54import math
……@@ -19,7 +18,6 @@ class TorrentFileStream:
1918 closed when no longer needed.
2019
2120 Example:
22
2321 >>> torrent = torf.Torrent(...)
2422 >>> with TorrentFileStream(torrent) as tfs:
2523 >>> # Get the 29th piece of the concatenated file stream
……@@ -43,9 +41,9 @@ class TorrentFileStream:
4341 elif none_ok:
4442 content_path = None
4543 else:
46 raise ValueError('Missing content_path argument and torrent has no path specified')
44 raise ValueError("Missing content_path argument and torrent has no path specified")
4745
48 if self._torrent.mode == 'singlefile':
46 if self._torrent.mode == "singlefile":
4947 # Torrent contains no directory, just a file
5048 return content_path or file
5149
……@@ -102,7 +100,7 @@ class TorrentFileStream:
102100 try:
103101 file_index = self._torrent.files.index(file)
104102 except ValueError:
105 raise ValueError(f'File not specified: {file}')
103 raise ValueError(f"File not specified: {file}")
106104 else:
107105 stream_pos = sum(f.size for f in self._torrent.files[:file_index])
108106 return stream_pos
……@@ -127,7 +125,7 @@ class TorrentFileStream:
127125 else:
128126 pos += 1
129127
130 raise ValueError(f'position is out of bounds (0 - {self._torrent.size - 1}): {position}')
128 raise ValueError(f"position is out of bounds (0 - {self._torrent.size - 1}): {position}")
131129
132130 def get_piece_indexes_of_file(self, file, exclusive=False):
133131 """
……@@ -176,11 +174,16 @@ class TorrentFileStream:
176174 file_last_byte_index = pos + file.size - 1
177175 if (
178176 # Is first byte of file inside of range?
179 first_byte_index <= file_first_byte_index <= last_byte_index or
177 first_byte_index <= file_first_byte_index <= last_byte_index
178 or
180179 # Is last byte of file inside of range?
181 first_byte_index <= file_last_byte_index <= last_byte_index or
180 first_byte_index <= file_last_byte_index <= last_byte_index
181 or
182182 # Are all bytes of file inside of range?
183 (first_byte_index >= file_first_byte_index and last_byte_index <= file_last_byte_index)
183 (
184 first_byte_index >= file_first_byte_index
185 and last_byte_index <= file_last_byte_index
186 )
184187 ):
185188 content_file_path = self._get_content_path(content_path, none_ok=True, file=file)
186189 files.append(content_file_path)
……@@ -217,7 +220,9 @@ class TorrentFileStream:
217220 if files:
218221 return files
219222
220 raise ValueError(f'piece_index is out of bounds (0 - {self.max_piece_index}): {piece_index}')
223 raise ValueError(
224 f"piece_index is out of bounds (0 - {self.max_piece_index}): {piece_index}"
225 )
221226
222227 def get_absolute_piece_indexes(self, file, relative_piece_indexes):
223228 """
……@@ -229,7 +234,6 @@ class TorrentFileStream:
229234 -1, -2]
230235
231236 Example:
232
233237 >>> # Assume `file` starts in the 50th piece in the stream of
234238 >>> # concatenated files and is 100 pieces long. `1000` and `-1000` are
235239 >>> # ignored because they are out of bounds.
……@@ -269,7 +273,6 @@ class TorrentFileStream:
269273 -1, -2]
270274
271275 Example:
272
273276 >>> # Assume `file` starts in the 50th piece in the stream of
274277 >>> # concatenated files and is 100 pieces long. `1000` and `-1000` are
275278 >>> # ignored because they are out of bounds.
……@@ -307,8 +310,7 @@ class TorrentFileStream:
307310 max_piece_index = math.floor((torrent_size - 1) / piece_size)
308311 if not min_piece_index <= piece_index <= max_piece_index:
309312 raise ValueError(
310 'piece_index must be in range '
311 f'{min_piece_index} - {max_piece_index}: {piece_index}'
313 f"piece_index must be in range {min_piece_index} - {max_piece_index}: {piece_index}"
312314 )
313315
314316 # Find out which files we need to read from
……@@ -321,7 +323,7 @@ class TorrentFileStream:
321323 first_byte_index_of_piece,
322324 last_byte_index_of_piece,
323325 # Ensure we get the torrent path, not the file system path
324 content_path='',
326 content_path="",
325327 )
326328
327329 # Find out where to start reading in the first relevant file
……@@ -331,7 +333,7 @@ class TorrentFileStream:
331333 seek_to = first_byte_index_of_piece - file_pos
332334 else:
333335 # Our piece is spread over multiple files
334 file = self.get_file_at_position(first_byte_index_of_piece, content_path='')
336 file = self.get_file_at_position(first_byte_index_of_piece, content_path="")
335337 file_pos = self.get_file_position(file)
336338 seek_to = file.size - ((file_pos + file.size) % piece_size)
337339
……@@ -389,7 +391,7 @@ class TorrentFileStream:
389391 del self._open_files[old_filepath]
390392
391393 try:
392 self._open_files[filepath] = open(filepath, 'rb')
394 self._open_files[filepath] = open(filepath, "rb")
393395 except OSError as e:
394396 raise error.ReadError(e.errno, filepath)
395397
……@@ -435,7 +437,7 @@ class TorrentFileStream:
435437 :raise ReadError: if file exists but is not readable
436438 :raise VerifyFileSizeError: if file has unexpected size
437439 """
438 trailing_bytes = b''
440 trailing_bytes = b""
439441 missing_pieces = _MissingPieces(torrent=self._torrent, stream=self)
440442 skip_bytes = 0
441443
……@@ -467,7 +469,7 @@ class TorrentFileStream:
467469 skip_bytes=skip_bytes,
468470 oom_callback=oom_callback,
469471 )
470 trailing_bytes = b''
472 trailing_bytes = b""
471473 piece_size = self._torrent.piece_size
472474 for piece in pieces:
473475 if len(piece) == piece_size:
……@@ -478,7 +480,7 @@ class TorrentFileStream:
478480 else:
479481 # _debug(f'{file}: Faking {filepath}')
480482 # We can't complete the current piece
481 trailing_bytes = b''
483 trailing_bytes = b""
482484 # Opening file failed
483485 items, skip_bytes = missing_pieces(file, content_path, reason=exception)
484486 for item in items:
……@@ -502,15 +504,15 @@ class TorrentFileStream:
502504
503505 def iter_pieces(fh, prepend):
504506 piece_size = self._torrent.piece_size
505 piece = b''
507 piece = b""
506508
507509 # Iterate over pieces in `prepend`ed bytes, store incomplete piece
508510 # in `piece`
509511 for pos in range(0, len(prepend), piece_size):
510 piece = prepend[pos:pos + piece_size]
512 piece = prepend[pos : pos + piece_size]
511513 if len(piece) == piece_size:
512514 yield piece
513 piece = b''
515 piece = b""
514516
515517 try:
516518 # Fill incomplete piece with first bytes from `fh`
……@@ -543,8 +545,10 @@ class TorrentFileStream:
543545 while True:
544546 try:
545547 return fh.read(size)
546 except MemoryError as e:
547 e = error.MemoryError(f'Out of memory while reading from {fh.name} at position {fh.tell()}')
548 except MemoryError:
549 e = error.MemoryError(
550 f"Out of memory while reading from {fh.name} at position {fh.tell()}"
551 )
548552 if oom_callback is None:
549553 raise e
550554 else:
……@@ -597,7 +601,9 @@ class TorrentFileStream:
597601 try:
598602 stored_piece_hash = self._torrent.hashes[piece_index]
599603 except IndexError:
600 raise ValueError(f'piece_index must be in range 0 - {self.max_piece_index}: {piece_index}')
604 raise ValueError(
605 f"piece_index must be in range 0 - {self.max_piece_index}: {piece_index}"
606 )
601607
602608 generated_piece_hash = self.get_piece_hash(piece_index, content_path=content_path)
603609 if generated_piece_hash is not None:
……@@ -624,7 +630,7 @@ class _MissingPieces:
624630
625631 # Figure out which subsequent files are affected by the missing last
626632 # piece of `file`
627 affected_files = self._stream.get_files_at_piece_index(piece_indexes[-1], content_path='')
633 affected_files = self._stream.get_files_at_piece_index(piece_indexes[-1], content_path="")
628634 affected_files.remove(file)
629635 # _debug(f'{affected_files=}')
630636
……@@ -647,8 +653,7 @@ class _MissingPieces:
647653
648654 # Stream index of the last byte of the last missing piece of `file`
649655 next_piece_boundary_index = (
650 (piece_indexes[-1] * self._torrent.piece_size)
651 + self._torrent.piece_size - 1
656 (piece_indexes[-1] * self._torrent.piece_size) + self._torrent.piece_size - 1
652657 )
653658
654659 if next_file_end > next_piece_boundary_index:
……@@ -725,7 +730,9 @@ class _MissingPieces:
725730 # No such file
726731 exceptions.append(error.ReadError(errno.ENOENT, bc_filepath))
727732 elif bc_filepath.size != actual_size:
728 exceptions.append(error.VerifyFileSizeError(bc_filepath, actual_size, bc_filepath.size))
733 exceptions.append(
734 error.VerifyFileSizeError(bc_filepath, actual_size, bc_filepath.size)
735 )
729736 # if exceptions:
730737 # _debug(f'bycatch: {exceptions[-1]!r}')
731738 return exceptions
plugin.video.jacktook/lib/vendor/torf/_torrent.py +319 -253
……@@ -33,18 +33,20 @@ from . import _generate as generate
3333from . import _reuse as reuse
3434from . import _utils as utils
3535
36_PACKAGE_NAME = __name__.split('.')[0]
36_PACKAGE_NAME = __name__.split(".")[0]
3737
3838# os.sched_getaffinity() is only available on some Unix platforms.
3939try:
4040 NCORES = len(os.sched_getaffinity(0))
4141except AttributeError:
4242 import multiprocessing
43
4344 NCORES = multiprocessing.cpu_count()
4445
45DEFAULT_TORRENT_NAME = 'UNNAMED TORRENT'
46DEFAULT_TORRENT_NAME = "UNNAMED TORRENT"
47
4648
47class Torrent():
49class Torrent:
4850 """
4951 Torrent metainfo representation
5052
……@@ -97,20 +99,37 @@ class Torrent():
9799 {'bar': ['x', 'y', 'z'], 'foo': 12}
98100 """
99101
100 def __init__(self, path=None, name=None,
101 exclude_globs=(), exclude_regexs=(),
102 include_globs=(), include_regexs=(),
103 trackers=None, webseeds=None, httpseeds=None,
104 private=None, comment=None, source=None, creation_date=None,
105 created_by='%s %s' % (_PACKAGE_NAME, __version__),
106 piece_size=None, piece_size_min=None, piece_size_max=None,
107 randomize_infohash=False):
102 def __init__(
103 self,
104 path=None,
105 name=None,
106 exclude_globs=(),
107 exclude_regexs=(),
108 include_globs=(),
109 include_regexs=(),
110 trackers=None,
111 webseeds=None,
112 httpseeds=None,
113 private=None,
114 comment=None,
115 source=None,
116 creation_date=None,
117 created_by=f"{_PACKAGE_NAME} {__version__}",
118 piece_size=None,
119 piece_size_min=None,
120 piece_size_max=None,
121 randomize_infohash=False,
122 ):
108123 self._path = None
109124 self._metainfo = {}
110 self._exclude = {'globs' : utils.MonitoredList(callback=self._filters_changed, type=str),
111 'regexs' : utils.MonitoredList(callback=self._filters_changed, type=re.compile)}
112 self._include = {'globs' : utils.MonitoredList(callback=self._filters_changed, type=str),
113 'regexs' : utils.MonitoredList(callback=self._filters_changed, type=re.compile)}
125 self._exclude = {
126 "globs": utils.MonitoredList(callback=self._filters_changed, type=str),
127 "regexs": utils.MonitoredList(callback=self._filters_changed, type=re.compile),
128 }
129 self._include = {
130 "globs": utils.MonitoredList(callback=self._filters_changed, type=str),
131 "regexs": utils.MonitoredList(callback=self._filters_changed, type=re.compile),
132 }
114133
115134 self.piece_size_min = piece_size_min
116135 self.piece_size_max = piece_size_max
……@@ -150,8 +169,8 @@ class Torrent():
150169
151170 The ``info`` key is guaranteed to exist.
152171 """
153 if 'info' not in self._metainfo:
154 self._metainfo['info'] = {}
172 if "info" not in self._metainfo:
173 self._metainfo["info"] = {}
155174 return self._metainfo
156175
157176 @property
……@@ -179,11 +198,12 @@ class Torrent():
179198 if value is None:
180199 # Keep info about name and files, but forget where they are stored
181200 self._path = None
182 self.metainfo['info'].pop('pieces', None)
201 self.metainfo["info"].pop("pieces", None)
183202 else:
184203 basepath = pathlib.Path(str(value))
185 filepaths = tuple(utils.File(fp, size=utils.real_size(fp))
186 for fp in utils.list_files(basepath))
204 filepaths = tuple(
205 utils.File(fp, size=utils.real_size(fp)) for fp in utils.list_files(basepath)
206 )
187207 self._set_files(filepaths, basepath)
188208
189209 @property
……@@ -215,14 +235,15 @@ class Torrent():
215235 directory
216236 :raises ValueError: if any file is not a :class:`File` object
217237 """
218 info = self.metainfo['info']
219 if self.mode == 'singlefile':
220 files = (utils.File(info.get('name', DEFAULT_TORRENT_NAME), size=self.size),)
221 elif self.mode == 'multifile':
222 basedir = info.get('name', DEFAULT_TORRENT_NAME)
223 files = (utils.File(os.path.join(basedir, *fileinfo['path']),
224 size=fileinfo['length'])
225 for fileinfo in info['files'])
238 info = self.metainfo["info"]
239 if self.mode == "singlefile":
240 files = (utils.File(info.get("name", DEFAULT_TORRENT_NAME), size=self.size),)
241 elif self.mode == "multifile":
242 basedir = info.get("name", DEFAULT_TORRENT_NAME)
243 files = (
244 utils.File(os.path.join(basedir, *fileinfo["path"]), size=fileinfo["length"])
245 for fileinfo in info["files"]
246 )
226247 else:
227248 files = ()
228249 return utils.Files(files, callback=self._files_changed)
……@@ -233,12 +254,12 @@ class Torrent():
233254 @files.setter
234255 def files(self, files):
235256 if not isinstance(files, utils.Iterable):
236 raise ValueError(f'Not an Iterable: {files}')
257 raise ValueError(f"Not an Iterable: {files}")
237258 for f in files:
238259 if not isinstance(f, utils.File):
239 raise ValueError(f'Not a File object: {f}')
260 raise ValueError(f"Not a File object: {f}")
240261 elif f.is_absolute():
241 raise error.PathError(f, msg='Not a relative path')
262 raise error.PathError(f, msg="Not a relative path")
242263
243264 if not files:
244265 self._set_files(files=())
……@@ -248,8 +269,8 @@ class Torrent():
248269 try:
249270 basepath = os.path.commonpath(files)
250271 except ValueError:
251 basepath = ''
252 if basepath == '':
272 basepath = ""
273 if basepath == "":
253274 raise error.CommonPathError(files)
254275 self._set_files(files, pathlib.Path(basepath))
255276
……@@ -271,12 +292,14 @@ class Torrent():
271292 """
272293 filepaths = ()
273294 if self.path is not None:
274 if self.mode == 'singlefile':
295 if self.mode == "singlefile":
275296 filepaths = (self.path,)
276 elif self.mode == 'multifile':
297 elif self.mode == "multifile":
277298 dirpath = self.path
278 filepaths = (os.path.join(dirpath, *fileinfo['path'])
279 for fileinfo in self.metainfo['info']['files'])
299 filepaths = (
300 os.path.join(dirpath, *fileinfo["path"])
301 for fileinfo in self.metainfo["info"]["files"]
302 )
280303 return utils.Filepaths(filepaths, callback=self._filepaths_changed)
281304
282305 def _filepaths_changed(self, filepaths):
……@@ -285,7 +308,7 @@ class Torrent():
285308 @filepaths.setter
286309 def filepaths(self, filepaths):
287310 if not isinstance(filepaths, utils.Iterable):
288 raise ValueError(f'Not an Iterable: {filepaths}')
311 raise ValueError(f"Not an Iterable: {filepaths}")
289312
290313 filepaths = utils.Filepaths(filepaths) # Resolve directories
291314 if not filepaths:
……@@ -295,14 +318,12 @@ class Torrent():
295318 # resolve symlinks so the user isn't confronted with unexpected
296319 # paths in case of an error.
297320 cwd = pathlib.Path.cwd()
298 filepaths_abs = tuple(fp if fp.is_absolute() else cwd / fp
299 for fp in filepaths)
321 filepaths_abs = tuple(fp if fp.is_absolute() else cwd / fp for fp in filepaths)
300322 try:
301323 basepath = pathlib.Path(os.path.commonpath(filepaths_abs))
302324 except ValueError:
303325 raise error.CommonPathError(filepaths)
304 filepaths = tuple(utils.File(fp, size=utils.real_size(fp))
305 for fp in filepaths)
326 filepaths = tuple(utils.File(fp, size=utils.real_size(fp)) for fp in filepaths)
306327 self._set_files(filepaths, basepath)
307328
308329 def _set_files(self, files, basepath=None):
……@@ -314,6 +335,7 @@ class Torrent():
314335 :param basepath: path-like that all paths in `files` start with; may be
315336 ``None`` if ``files`` is empty
316337 """
338
317339 def abspath(p):
318340 # Absolute path without resolved symlinks
319341 if p.is_absolute():
……@@ -330,30 +352,35 @@ class Torrent():
330352 return pathlib.Path(abspath(p)).relative_to(abspath(basepath).parent)
331353
332354 # Apply filters to relative paths with torrent name as first segment
333 exclude_globs = tuple(str(g) for g in self._exclude['globs'])
334 exclude_regexs = tuple(re.compile(r) for r in self._exclude['regexs'])
355 exclude_globs = tuple(str(g) for g in self._exclude["globs"])
356 exclude_regexs = tuple(re.compile(r) for r in self._exclude["regexs"])
335357 exclude = tuple(itertools.chain(exclude_globs, exclude_regexs))
336 include_globs = tuple(str(g) for g in self._include['globs'])
337 include_regexs = tuple(re.compile(r) for r in self._include['regexs'])
358 include_globs = tuple(str(g) for g in self._include["globs"])
359 include_regexs = tuple(re.compile(r) for r in self._include["regexs"])
338360 include = tuple(itertools.chain(include_globs, include_regexs))
339 files = utils.filter_files(files, getter=relpath_with_parent,
340 exclude=exclude, include=include,
341 hidden=False, empty=False)
361 files = utils.filter_files(
362 files,
363 getter=relpath_with_parent,
364 exclude=exclude,
365 include=include,
366 hidden=False,
367 empty=False,
368 )
342369
343 info = self.metainfo['info']
370 info = self.metainfo["info"]
344371 if not files or all(f.size <= 0 for f in files):
345 info.pop('files', None)
346 info.pop('length', None)
347 info.pop('pieces', None)
348 info.pop('md5sum', None)
372 info.pop("files", None)
373 info.pop("length", None)
374 info.pop("pieces", None)
375 info.pop("md5sum", None)
349376 elif len(files) == 1 and files[0] == basepath:
350377 # There is only one file and it is not in a directory.
351378 # NOTE: A directory with a single file in it is a multifile torrent.
352 info['length'] = files[0].size
353 info['name'] = files[0].name
354 info.pop('files', None)
355 info.pop('pieces', None)
356 info.pop('md5sum', None)
379 info["length"] = files[0].size
380 info["name"] = files[0].name
381 info.pop("files", None)
382 info.pop("pieces", None)
383 info.pop("md5sum", None)
357384 else:
358385 if str(basepath) == os.curdir:
359386 # Name of current working directory
……@@ -371,13 +398,12 @@ class Torrent():
371398
372399 files_info = []
373400 for f in sorted(files):
374 files_info.append({'length': f.size,
375 'path' : list(relpath_without_parent(f).parts)})
376 info['name'] = name
377 info['files'] = files_info
378 info.pop('length', None)
379 info.pop('pieces', None)
380 info.pop('md5sum', None)
401 files_info.append({"length": f.size, "path": list(relpath_without_parent(f).parts)})
402 info["name"] = name
403 info["files"] = files_info
404 info.pop("length", None)
405 info.pop("pieces", None)
406 info.pop("md5sum", None)
381407
382408 # Set new path attribute if basepath exists
383409 if basepath is not None and os.path.exists(basepath):
……@@ -407,13 +433,13 @@ class Torrent():
407433 ``[!SEQ]`` matches any char not in ``SEQ``
408434 ========== ================================
409435 """
410 return self._exclude['globs']
436 return self._exclude["globs"]
411437
412438 @exclude_globs.setter
413439 def exclude_globs(self, value):
414440 if not isinstance(value, utils.Iterable):
415 raise ValueError(f'Must be Iterable, not {type(value).__name__}: {value}')
416 self._exclude['globs'][:] = value
441 raise ValueError(f"Must be Iterable, not {type(value).__name__}: {value}")
442 self._exclude["globs"][:] = value
417443
418444 @property
419445 def include_globs(self):
……@@ -422,13 +448,13 @@ class Torrent():
422448
423449 See :attr:`exclude_globs`.
424450 """
425 return self._include['globs']
451 return self._include["globs"]
426452
427453 @include_globs.setter
428454 def include_globs(self, value):
429455 if not isinstance(value, utils.Iterable):
430 raise ValueError(f'Must be Iterable, not {type(value).__name__}: {value}')
431 self._include['globs'][:] = value
456 raise ValueError(f"Must be Iterable, not {type(value).__name__}: {value}")
457 self._include["globs"][:] = value
432458
433459 @property
434460 def exclude_regexs(self):
……@@ -442,13 +468,13 @@ class Torrent():
442468
443469 :raises re.error: if any regular expression is invalid
444470 """
445 return self._exclude['regexs']
471 return self._exclude["regexs"]
446472
447473 @exclude_regexs.setter
448474 def exclude_regexs(self, value):
449475 if not isinstance(value, utils.Iterable):
450 raise ValueError(f'Must be Iterable, not {type(value).__name__}: {value}')
451 self._exclude['regexs'][:] = value
476 raise ValueError(f"Must be Iterable, not {type(value).__name__}: {value}")
477 self._exclude["regexs"][:] = value
452478
453479 @property
454480 def include_regexs(self):
……@@ -457,13 +483,13 @@ class Torrent():
457483
458484 See :attr:`exclude_regexs`.
459485 """
460 return self._include['regexs']
486 return self._include["regexs"]
461487
462488 @include_regexs.setter
463489 def include_regexs(self, value):
464490 if not isinstance(value, utils.Iterable):
465 raise ValueError(f'Must be Iterable, not {type(value).__name__}: {value}')
466 self._include['regexs'][:] = value
491 raise ValueError(f"Must be Iterable, not {type(value).__name__}: {value}")
492 self._include["regexs"][:] = value
467493
468494 def _filters_changed(self, _):
469495 """Callback for MonitoredLists in Torrent._exclude"""
……@@ -494,7 +520,7 @@ class Torrent():
494520 'foo.txt': File('Torrent/foo.txt',
495521 size=123456)}}
496522 """
497 tree = {} # Complete directory tree
523 tree = {} # Complete directory tree
498524 paths = (tuple(f.parts) for f in self.files)
499525 for path in paths:
500526 dirpath = path[:-1] # Path without filename
……@@ -521,17 +547,17 @@ class Torrent():
521547 Setting this property sets or removes ``name`` in
522548 :attr:`metainfo`\\ ``['info']``.
523549 """
524 if 'name' not in self.metainfo['info'] and self.path is not None:
525 self.metainfo['info']['name'] = self.path.name
526 return self.metainfo['info'].get('name', None)
550 if "name" not in self.metainfo["info"] and self.path is not None:
551 self.metainfo["info"]["name"] = self.path.name
552 return self.metainfo["info"].get("name", None)
527553
528554 @name.setter
529555 def name(self, value):
530556 if value is None:
531 self.metainfo['info'].pop('name', None)
557 self.metainfo["info"].pop("name", None)
532558 self.name # Set default name
533559 else:
534 self.metainfo['info']['name'] = str(value)
560 self.metainfo["info"]["name"] = str(value)
535561
536562 @property
537563 def mode(self):
……@@ -540,19 +566,18 @@ class Torrent():
540566 ``multifile`` if it contains one or more files in a directory, or
541567 ``None`` if no content is specified (i.e. :attr:`files` is empty).
542568 """
543 if 'length' in self.metainfo['info']:
544 return 'singlefile'
545 elif 'files' in self.metainfo['info']:
546 return 'multifile'
569 if "length" in self.metainfo["info"]:
570 return "singlefile"
571 elif "files" in self.metainfo["info"]:
572 return "multifile"
547573
548574 @property
549575 def size(self):
550576 """Total size of content in bytes"""
551 if self.mode == 'singlefile':
552 return self.metainfo['info']['length']
553 elif self.mode == 'multifile':
554 return sum(fileinfo['length']
555 for fileinfo in self.metainfo['info']['files'])
577 if self.mode == "singlefile":
578 return self.metainfo["info"]["length"]
579 elif self.mode == "multifile":
580 return sum(fileinfo["length"] for fileinfo in self.metainfo["info"]["files"])
556581 else:
557582 return 0
558583
……@@ -573,22 +598,22 @@ class Torrent():
573598 elif isinstance(path, abc.Iterable):
574599 path = tuple(str(part) for part in path)
575600 else:
576 raise ValueError(f'Must be str, Path or Iterable, not {type(path).__name__}: {path}')
577 if self.mode == 'singlefile' and path == (self.name,):
578 return self.metainfo['info']['length']
579 elif self.mode == 'multifile':
601 raise ValueError(f"Must be str, Path or Iterable, not {type(path).__name__}: {path}")
602 if self.mode == "singlefile" and path == (self.name,):
603 return self.metainfo["info"]["length"]
604 elif self.mode == "multifile":
580605 file_sizes = []
581 for info in self.metainfo['info']['files']:
582 this_path = (self.name,) + tuple(c for c in info['path'] if c)
606 for info in self.metainfo["info"]["files"]:
607 this_path = (self.name,) + tuple(c for c in info["path"] if c)
583608 if this_path == path:
584609 # path points to file
585 return info['length']
610 return info["length"]
586611 elif utils.iterable_startswith(this_path, path):
587612 # path points to directory
588 file_sizes.append(info['length'])
613 file_sizes.append(info["length"])
589614 if file_sizes:
590615 return sum(file_sizes)
591 raise error.PathError(os.path.join(*path), msg='Unknown path')
616 raise error.PathError(os.path.join(*path), msg="Unknown path")
592617
593618 @property
594619 def piece_size(self):
……@@ -603,13 +628,13 @@ class Torrent():
603628 Setting this property sets or removes ``piece length`` in
604629 :attr:`metainfo`\\ ``['info']``.
605630 """
606 return self.metainfo['info'].get('piece length', 0)
631 return self.metainfo["info"].get("piece length", 0)
607632
608633 @piece_size.setter
609634 def piece_size(self, value):
610635 if value is None:
611636 if self.size <= 0:
612 self.metainfo['info'].pop('piece length', None)
637 self.metainfo["info"].pop("piece length", None)
613638 return
614639 else:
615640 value = self.calculate_piece_size(
……@@ -621,15 +646,15 @@ class Torrent():
621646 try:
622647 piece_length = int(value)
623648 except (TypeError, ValueError):
624 raise ValueError(f'piece_size must be int, not {type(value).__name__}: {value!r}')
649 raise ValueError(f"piece_size must be int, not {type(value).__name__}: {value!r}")
625650 else:
626651 if not utils.is_divisible_by_16_kib(piece_length):
627652 raise error.PieceSizeError(piece_length)
628653 elif not self.piece_size_min <= piece_length <= self.piece_size_max:
629 raise error.PieceSizeError(piece_length,
630 min=self.piece_size_min,
631 max=self.piece_size_max)
632 self.metainfo['info']['piece length'] = piece_length
654 raise error.PieceSizeError(
655 piece_length, min=self.piece_size_min, max=self.piece_size_max
656 )
657 self.metainfo["info"]["piece length"] = piece_length
633658
634659 @property
635660 def piece_size_min(self):
……@@ -706,19 +731,17 @@ class Torrent():
706731
707732 :return: calculated piece size
708733 """
709 if size <= 2**30: # 1 GiB / 1024 pieces = 1 MiB max
734 if size <= 2**30: # 1 GiB / 1024 pieces = 1 MiB max
710735 pieces = size / 1024
711 elif size <= 4 * 2**30: # 4 GiB / 2048 pieces = 2 MiB max
736 elif size <= 4 * 2**30: # 4 GiB / 2048 pieces = 2 MiB max
712737 pieces = size / 2048
713 elif size <= 6 * 2**30: # 6 GiB / 3072 pieces = 2 MiB max
738 elif size <= 6 * 2**30: # 6 GiB / 3072 pieces = 2 MiB max
714739 pieces = size / 3072
715 elif size <= 8 * 2**30: # 8 GiB / 2048 pieces = 4 MiB max
716 pieces = size / 2048
717 elif size <= 16 * 2**30: # 16 GiB / 2048 pieces = 8 MiB max
740 elif size <= 8 * 2**30 or size <= 16 * 2**30: # 8 GiB / 2048 pieces = 4 MiB max
718741 pieces = size / 2048
719 elif size <= 32 * 2**30: # 32 GiB / 4096 pieces = 8 MiB max
742 elif size <= 32 * 2**30: # 32 GiB / 4096 pieces = 8 MiB max
720743 pieces = size / 4096
721 elif size <= 64 * 2**30: # 64 GiB / 8192 pieces = 8 MiB max
744 elif size <= 64 * 2**30: # 64 GiB / 8192 pieces = 8 MiB max
722745 pieces = size / 8192
723746 else:
724747 pieces = size / 10240
……@@ -744,11 +767,10 @@ class Torrent():
744767 @property
745768 def hashes(self):
746769 """Tuple of SHA1 piece hashes as :class:`bytes`"""
747 hashes = self.metainfo['info'].get('pieces')
770 hashes = self.metainfo["info"].get("pieces")
748771 if isinstance(hashes, (bytes, bytearray)):
749772 # Each hash is 20 bytes long
750 return tuple(bytes(hashes[pos : pos + 20])
751 for pos in range(0, len(hashes), 20))
773 return tuple(bytes(hashes[pos : pos + 20]) for pos in range(0, len(hashes), 20))
752774 else:
753775 return ()
754776
……@@ -783,8 +805,8 @@ class Torrent():
783805 :raises ValueError: if set to anything that isn't an iterable or a
784806 string
785807 """
786 tiers = list(self.metainfo.get('announce-list', ()))
787 announce = self.metainfo.get('announce', None)
808 tiers = list(self.metainfo.get("announce-list", ()))
809 announce = self.metainfo.get("announce", None)
788810 flat_urls = tuple(url for tier in tiers for url in tier)
789811 if announce is not None and announce not in flat_urls:
790812 tiers.insert(0, [announce])
……@@ -797,24 +819,23 @@ class Torrent():
797819 if isinstance(value, abc.Iterable):
798820 self._trackers_changed(utils.Trackers(value))
799821 else:
800 raise ValueError(f'Must be Iterable, str or None, not {type(value).__name__}: {value}')
822 raise ValueError(f"Must be Iterable, str or None, not {type(value).__name__}: {value}")
801823
802824 def _trackers_changed(self, trackers):
803825 # Set "announce" to first tracker of first tier
804826 try:
805 self.metainfo['announce'] = str(trackers[0][0])
827 self.metainfo["announce"] = str(trackers[0][0])
806828 except IndexError:
807 self.metainfo.pop('announce', None)
829 self.metainfo.pop("announce", None)
808830
809831 # Remove "announce-list" if there's only one tracker
810832 if len(trackers.flat) <= 1:
811 self.metainfo.pop('announce-list', None)
833 self.metainfo.pop("announce-list", None)
812834 else:
813 if 'announce-list' not in self.metainfo:
814 self.metainfo['announce-list'] = []
835 if "announce-list" not in self.metainfo:
836 self.metainfo["announce-list"] = []
815837 # Set announce-list without changing its identity
816 self.metainfo['announce-list'][:] = ([str(url) for url in tier]
817 for tier in trackers)
838 self.metainfo["announce-list"][:] = ([str(url) for url in tier] for tier in trackers)
818839
819840 @property
820841 def webseeds(self):
……@@ -831,8 +852,7 @@ class Torrent():
831852 :raises ValueError: if set to anything that isn't an iterable or a
832853 string
833854 """
834 return utils.URLs(self.metainfo.get('url-list', ()),
835 callback=self._webseeds_changed)
855 return utils.URLs(self.metainfo.get("url-list", ()), callback=self._webseeds_changed)
836856
837857 @webseeds.setter
838858 def webseeds(self, value):
……@@ -843,14 +863,14 @@ class Torrent():
843863 elif value is None:
844864 urls = utils.URLs(())
845865 else:
846 raise ValueError(f'Must be Iterable, str or None, not {type(value).__name__}: {value}')
866 raise ValueError(f"Must be Iterable, str or None, not {type(value).__name__}: {value}")
847867 self._webseeds_changed(urls)
848868
849869 def _webseeds_changed(self, webseeds):
850870 if webseeds:
851 self.metainfo['url-list'] = [str(url) for url in webseeds]
871 self.metainfo["url-list"] = [str(url) for url in webseeds]
852872 else:
853 self.metainfo.pop('url-list', None)
873 self.metainfo.pop("url-list", None)
854874
855875 @property
856876 def httpseeds(self):
……@@ -867,8 +887,7 @@ class Torrent():
867887 :raises ValueError: if set to anything that isn't an iterable or a
868888 string
869889 """
870 return utils.URLs(self.metainfo.get('httpseeds', ()),
871 callback=self._httpseeds_changed)
890 return utils.URLs(self.metainfo.get("httpseeds", ()), callback=self._httpseeds_changed)
872891
873892 @httpseeds.setter
874893 def httpseeds(self, value):
……@@ -879,14 +898,14 @@ class Torrent():
879898 elif value is None:
880899 urls = utils.URLs(())
881900 else:
882 raise ValueError(f'Must be Iterable, str or None, not {type(value).__name__}: {value}')
901 raise ValueError(f"Must be Iterable, str or None, not {type(value).__name__}: {value}")
883902 self._httpseeds_changed(urls)
884903
885904 def _httpseeds_changed(self, httpseeds):
886905 if httpseeds:
887 self.metainfo['httpseeds'] = [str(url) for url in httpseeds]
906 self.metainfo["httpseeds"] = [str(url) for url in httpseeds]
888907 else:
889 self.metainfo.pop('httpseeds', None)
908 self.metainfo.pop("httpseeds", None)
890909
891910 @property
892911 def private(self):
……@@ -897,17 +916,17 @@ class Torrent():
897916 :attr:`metainfo`\\ ``['info']``\\ ``['private']`` exists, ``None``
898917 otherwise.
899918 """
900 if 'private' in self.metainfo['info']:
901 return bool(self.metainfo['info']['private'])
919 if "private" in self.metainfo["info"]:
920 return bool(self.metainfo["info"]["private"])
902921 else:
903922 return None
904923
905924 @private.setter
906925 def private(self, value):
907926 if value is None:
908 self.metainfo['info'].pop('private', None)
927 self.metainfo["info"].pop("private", None)
909928 else:
910 self.metainfo['info']['private'] = bool(value)
929 self.metainfo["info"]["private"] = bool(value)
911930
912931 @property
913932 def comment(self):
……@@ -916,14 +935,14 @@ class Torrent():
916935
917936 Setting this property sets or removes :attr:`metainfo`\\ ``['comment']``.
918937 """
919 return self.metainfo.get('comment', None)
938 return self.metainfo.get("comment", None)
920939
921940 @comment.setter
922941 def comment(self, value):
923942 if value is not None:
924 self.metainfo['comment'] = str(value)
943 self.metainfo["comment"] = str(value)
925944 else:
926 self.metainfo.pop('comment', None)
945 self.metainfo.pop("comment", None)
927946
928947 @property
929948 def creation_date(self):
……@@ -936,20 +955,19 @@ class Torrent():
936955 Setting this property sets or removes
937956 :attr:`metainfo`\\ ``['creation date']``.
938957 """
939 return self.metainfo.get('creation date', None)
958 return self.metainfo.get("creation date", None)
940959
941960 @creation_date.setter
942961 def creation_date(self, value):
943962 if isinstance(value, (float, int)):
944 self.metainfo['creation date'] = datetime.fromtimestamp(value)
963 self.metainfo["creation date"] = datetime.fromtimestamp(value)
945964 elif isinstance(value, datetime):
946 self.metainfo['creation date'] = value
965 self.metainfo["creation date"] = value
947966 elif not value:
948 self.metainfo.pop('creation date', None)
967 self.metainfo.pop("creation date", None)
949968 else:
950969 raise ValueError(
951 'Must be None, int or datetime object, '
952 f'not {type(value).__name__}: {value!r}'
970 f"Must be None, int or datetime object, not {type(value).__name__}: {value!r}"
953971 )
954972
955973 @property
……@@ -960,14 +978,14 @@ class Torrent():
960978 Setting this property sets or removes
961979 :attr:`metainfo`\\ ``['created by']``.
962980 """
963 return self.metainfo.get('created by', None)
981 return self.metainfo.get("created by", None)
964982
965983 @created_by.setter
966984 def created_by(self, value):
967985 if value is not None:
968 self.metainfo['created by'] = str(value)
986 self.metainfo["created by"] = str(value)
969987 else:
970 self.metainfo.pop('created by', None)
988 self.metainfo.pop("created by", None)
971989
972990 @property
973991 def source(self):
……@@ -977,14 +995,14 @@ class Torrent():
977995 Setting this property sets or removes
978996 :attr:`metainfo`\\ ``['info']``\\ ``['created by']``.
979997 """
980 return self.metainfo['info'].get('source', None)
998 return self.metainfo["info"].get("source", None)
981999
9821000 @source.setter
9831001 def source(self, value):
9841002 if value is not None:
985 self.metainfo['info']['source'] = str(value)
1003 self.metainfo["info"]["source"] = str(value)
9861004 else:
987 self.metainfo['info'].pop('source', None)
1005 self.metainfo["info"].pop("source", None)
9881006
9891007 @property
9901008 def infohash(self):
……@@ -998,7 +1016,7 @@ class Torrent():
9981016 # Try to calculate infohash
9991017 self.validate()
10001018 try:
1001 info = utils.encode_dict(self.metainfo['info'])
1019 info = utils.encode_dict(self.metainfo["info"])
10021020 except ValueError as e:
10031021 raise error.MetainfoError(e)
10041022 else:
……@@ -1027,7 +1045,7 @@ class Torrent():
10271045 :attr:`metainfo`\\ ``['info']``\\ ``['entropy']`` to a random integer.
10281046 Setting it to ``False`` removes that field.
10291047 """
1030 return bool(self.metainfo['info'].get('entropy', False))
1048 return bool(self.metainfo["info"].get("entropy", False))
10311049
10321050 @randomize_infohash.setter
10331051 def randomize_infohash(self, value):
……@@ -1035,9 +1053,10 @@ class Torrent():
10351053 # According to BEP0003 "Integers have no size limitation", but some
10361054 # parsers seem to have problems with large numbers.
10371055 import random
1038 self.metainfo['info']['entropy'] = random.randint(int(-2e9), int(2e9))
1056
1057 self.metainfo["info"]["entropy"] = random.randint(int(-2e9), int(2e9))
10391058 else:
1040 self.metainfo['info'].pop('entropy', None)
1059 self.metainfo["info"].pop("entropy", None)
10411060
10421061 @property
10431062 def is_ready(self):
……@@ -1081,9 +1100,9 @@ class Torrent():
10811100 otherwise
10821101 """
10831102 if self.path is None:
1084 raise RuntimeError('generate() called with no path specified')
1103 raise RuntimeError("generate() called with no path specified")
10851104 elif sum(utils.real_size(fp) for fp in self.filepaths) < 1:
1086 raise error.PathError(self.path, msg='Empty or all files excluded')
1105 raise error.PathError(self.path, msg="Empty or all files excluded")
10871106
10881107 hasher_threads = threads or NCORES
10891108
……@@ -1112,17 +1131,18 @@ class Torrent():
11121131
11131132 # Collect piece hashes
11141133 piece_hashes = collector.collect()
1115 concatenated_piece_hashes = b''.join(piece_hashes)
1134 concatenated_piece_hashes = b"".join(piece_hashes)
11161135 hashes_count = len(concatenated_piece_hashes) / 20
11171136 if hashes_count == self.pieces:
1118 self.metainfo['info']['pieces'] = concatenated_piece_hashes
1137 self.metainfo["info"]["pieces"] = concatenated_piece_hashes
11191138 return True
11201139 elif hashes_count < self.pieces:
11211140 # Hashing was cancelled
11221141 return False
11231142 else:
1124 raise RuntimeError('Unexpected number of hashes generated: '
1125 f'{hashes_count} instead of {self.pieces}')
1143 raise RuntimeError(
1144 f"Unexpected number of hashes generated: {hashes_count} instead of {self.pieces}"
1145 )
11261146
11271147 def verify(self, path, threads=None, callback=None, interval=0):
11281148 """
……@@ -1185,13 +1205,15 @@ class Torrent():
11851205 filepath = None
11861206 piece_hash = None
11871207 exceptions = (exception,)
1188 verify_callback(piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions)
1208 verify_callback(
1209 piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions
1210 )
11891211
1190 if self.mode == 'singlefile' and os.path.isdir(path):
1212 if self.mode == "singlefile" and os.path.isdir(path):
11911213 early_exception(error.VerifyIsDirectoryError(path))
11921214 return False
11931215
1194 elif self.mode == 'multifile' and not os.path.isdir(path):
1216 elif self.mode == "multifile" and not os.path.isdir(path):
11951217 early_exception(error.VerifyNotDirectoryError(path))
11961218 return False
11971219
……@@ -1284,8 +1306,14 @@ class Torrent():
12841306 fs_filepath = filepaths[file_index][0]
12851307 torrent_filepath = filepaths[file_index][1]
12861308 files_done = file_index + 1
1287 return_value = callback(self, fs_filepath, torrent_filepath,
1288 files_done, files_total, exception)
1309 return_value = callback(
1310 self,
1311 fs_filepath,
1312 torrent_filepath,
1313 files_done,
1314 files_total,
1315 exception,
1316 )
12891317 if return_value is not None:
12901318 return True
12911319 elif exception:
……@@ -1300,7 +1328,7 @@ class Torrent():
13001328 # for a directory if we expect one because we are iterating over files
13011329 # (filepaths), so the path "foo/bar/baz" will result in a ReadError if
13021330 # "foo" or "foo/bar" is a file.
1303 if self.mode == 'singlefile' and os.path.isdir(path):
1331 if self.mode == "singlefile" and os.path.isdir(path):
13041332 exception = error.VerifyIsDirectoryError(path)
13051333 cancel(file_index=0, exception=exception)
13061334 return False
……@@ -1348,91 +1376,123 @@ class Torrent():
13481376 torrent file or magnet link
13491377 """
13501378 md = self.metainfo
1351 info = md['info']
1379 info = md["info"]
13521380
13531381 # Check values shared by singlefile and multifile torrents
1354 utils.assert_type(md, ('info',), (dict,), must_exist=True)
1355 utils.assert_type(md, ('info', 'name'), (str,), must_exist=True)
1356 utils.assert_type(md, ('info', 'piece length'), (int,), must_exist=True,
1357 check=utils.is_divisible_by_16_kib)
1358 utils.assert_type(md, ('info', 'pieces'), (abc.ByteString,), must_exist=True)
1359 utils.assert_type(md, ('info', 'private'), (bool, int), must_exist=False)
1360 utils.assert_type(md, ('announce',), (str,), must_exist=False, check=utils.is_url)
1361 utils.assert_type(md, ('announce-list',), (utils.Iterable,), must_exist=False)
1362 for i,_ in enumerate(md.get('announce-list', ())):
1363 utils.assert_type(md, ('announce-list', i), (utils.Iterable,))
1364 for j,_ in enumerate(md['announce-list'][i]):
1365 utils.assert_type(md, ('announce-list', i, j), (str,), check=utils.is_url)
1366
1367 if len(info['pieces']) == 0:
1382 utils.assert_type(md, ("info",), (dict,), must_exist=True)
1383 utils.assert_type(md, ("info", "name"), (str,), must_exist=True)
1384 utils.assert_type(
1385 md,
1386 ("info", "piece length"),
1387 (int,),
1388 must_exist=True,
1389 check=utils.is_divisible_by_16_kib,
1390 )
1391 utils.assert_type(md, ("info", "pieces"), (abc.ByteString,), must_exist=True)
1392 utils.assert_type(md, ("info", "private"), (bool, int), must_exist=False)
1393 utils.assert_type(md, ("announce",), (str,), must_exist=False, check=utils.is_url)
1394 utils.assert_type(md, ("announce-list",), (utils.Iterable,), must_exist=False)
1395 for i, _ in enumerate(md.get("announce-list", ())):
1396 utils.assert_type(md, ("announce-list", i), (utils.Iterable,))
1397 for j, _ in enumerate(md["announce-list"][i]):
1398 utils.assert_type(md, ("announce-list", i, j), (str,), check=utils.is_url)
1399
1400 if len(info["pieces"]) == 0:
13681401 raise error.MetainfoError("['info']['pieces'] is empty")
13691402
1370 elif len(info['pieces']) % 20 != 0:
1403 elif len(info["pieces"]) % 20 != 0:
13711404 raise error.MetainfoError("length of ['info']['pieces'] is not divisible by 20")
13721405
1373 elif 'length' in info and 'files' in info:
1406 elif "length" in info and "files" in info:
13741407 raise error.MetainfoError("['info'] includes both 'length' and 'files'")
13751408
1376 elif 'length' in info:
1409 elif "length" in info:
13771410 # Validate info as singlefile torrent
1378 utils.assert_type(md, ('info', 'length'), (int, float), must_exist=True)
1379 utils.assert_type(md, ('info', 'md5sum'), (str,), must_exist=False, check=utils.is_md5sum)
1411 utils.assert_type(md, ("info", "length"), (int, float), must_exist=True)
1412 utils.assert_type(
1413 md, ("info", "md5sum"), (str,), must_exist=False, check=utils.is_md5sum
1414 )
13801415
13811416 # Validate expected number of pieces
1382 piece_count = int(len(info['pieces']) / 20)
1383 exp_piece_count = math.ceil(info['length'] / info['piece length'])
1417 piece_count = int(len(info["pieces"]) / 20)
1418 exp_piece_count = math.ceil(info["length"] / info["piece length"])
13841419 if piece_count != exp_piece_count:
1385 raise error.MetainfoError(f'Expected {exp_piece_count} pieces but there are {piece_count}')
1420 raise error.MetainfoError(
1421 f"Expected {exp_piece_count} pieces but there are {piece_count}"
1422 )
13861423
13871424 if self.path is not None:
13881425 # Check if filepath actually points to a file
13891426 if not os.path.isfile(self.path):
1390 raise error.MetainfoError(f"Metainfo includes {self.path} as file, but it is not a file")
1427 raise error.MetainfoError(
1428 f"Metainfo includes {self.path} as file, but it is not a file"
1429 )
13911430
13921431 # Check if size matches
13931432 path_size = utils.real_size(self.path)
1394 if path_size != info['length']:
1395 raise error.MetainfoError(f"Mismatching file sizes in metainfo ({info['length']})"
1396 f" and file system ({path_size}): {self.path}")
1433 if path_size != info["length"]:
1434 raise error.MetainfoError(
1435 f"Mismatching file sizes in metainfo ({info['length']})"
1436 f" and file system ({path_size}): {self.path}"
1437 )
13971438
1398 elif 'files' in info:
1439 elif "files" in info:
13991440 # Validate info as multifile torrent
1400 utils.assert_type(md, ('info', 'files'), (utils.Iterable,), must_exist=True)
1401 for i,fileinfo in enumerate(info['files']):
1402 utils.assert_type(md, ('info', 'files', i), (abc.Mapping,), must_exist=True)
1403 utils.assert_type(md, ('info', 'files', i, 'length'), (int, float), must_exist=True)
1404 utils.assert_type(md, ('info', 'files', i, 'path'), (utils.Iterable,), must_exist=True)
1405 utils.assert_type(md, ('info', 'files', i, 'md5sum'), (str,), must_exist=False, check=utils.is_md5sum)
1406 for j,item in enumerate(fileinfo['path']):
1407 utils.assert_type(md, ('info', 'files', i, 'path', j), (str,))
1441 utils.assert_type(md, ("info", "files"), (utils.Iterable,), must_exist=True)
1442 for i, fileinfo in enumerate(info["files"]):
1443 utils.assert_type(md, ("info", "files", i), (abc.Mapping,), must_exist=True)
1444 utils.assert_type(md, ("info", "files", i, "length"), (int, float), must_exist=True)
1445 utils.assert_type(
1446 md, ("info", "files", i, "path"), (utils.Iterable,), must_exist=True
1447 )
1448 utils.assert_type(
1449 md,
1450 ("info", "files", i, "md5sum"),
1451 (str,),
1452 must_exist=False,
1453 check=utils.is_md5sum,
1454 )
1455 for j, item in enumerate(fileinfo["path"]):
1456 utils.assert_type(md, ("info", "files", i, "path", j), (str,))
14081457
14091458 # - validate() should ensure that ['info']['pieces'] is math.ceil(self.size /
14101459 # self.piece_size) bytes long.
1411 piece_count = int(len(info['pieces']) / 20)
1412 exp_piece_count = math.ceil(sum(fileinfo['length'] for fileinfo in info['files'])
1413 / info['piece length'])
1460 piece_count = int(len(info["pieces"]) / 20)
1461 exp_piece_count = math.ceil(
1462 sum(fileinfo["length"] for fileinfo in info["files"]) / info["piece length"]
1463 )
14141464 if piece_count != exp_piece_count:
1415 raise error.MetainfoError(f'Expected {exp_piece_count} pieces but there are {piece_count}')
1465 raise error.MetainfoError(
1466 f"Expected {exp_piece_count} pieces but there are {piece_count}"
1467 )
14161468
14171469 if self.path is not None:
14181470 # Check if filepath actually points to a directory
14191471 if not os.path.isdir(self.path):
1420 raise error.MetainfoError(f"Metainfo includes {self.path} as directory, but it is not a directory")
1472 raise error.MetainfoError(
1473 f"Metainfo includes {self.path} as directory, but it is not a directory"
1474 )
14211475
1422 for i,fileinfo in enumerate(info['files']):
1423 filepath = os.path.join(self.path, os.path.join(*fileinfo['path']))
1476 for i, fileinfo in enumerate(info["files"]):
1477 filepath = os.path.join(self.path, os.path.join(*fileinfo["path"]))
14241478
14251479 # Check if filepath exists and is a file
14261480 if not os.path.exists(filepath):
1427 raise error.MetainfoError(f"Metainfo includes file that doesn't exist: {filepath}")
1481 raise error.MetainfoError(
1482 f"Metainfo includes file that doesn't exist: {filepath}"
1483 )
14281484 if not os.path.isfile(filepath):
1429 raise error.MetainfoError(f"Metainfo includes file that isn't a file: {filepath}")
1485 raise error.MetainfoError(
1486 f"Metainfo includes file that isn't a file: {filepath}"
1487 )
14301488
14311489 # Check if sizes match
14321490 filesize = utils.real_size(filepath)
1433 if filesize != fileinfo['length']:
1434 raise error.MetainfoError(f"Mismatching file sizes in metainfo ({fileinfo['length']})"
1435 f" and file system ({filesize}): {filepath}")
1491 if filesize != fileinfo["length"]:
1492 raise error.MetainfoError(
1493 f"Mismatching file sizes in metainfo ({fileinfo['length']})"
1494 f" and file system ({filesize}): {filepath}"
1495 )
14361496
14371497 else:
14381498 raise error.MetainfoError("Missing 'length' or 'files' in 'info'")
……@@ -1504,7 +1564,7 @@ class Torrent():
15041564 self.write_stream(content, validate=validate)
15051565 content.seek(0)
15061566 try:
1507 with open(filepath, 'wb') as f:
1567 with open(filepath, "wb") as f:
15081568 f.write(content.read())
15091569 except OSError as e:
15101570 raise error.WriteError(e.errno, filepath)
……@@ -1521,24 +1581,23 @@ class Torrent():
15211581
15221582 :raises MetainfoError: if :attr:`metainfo` is invalid
15231583 """
1524 kwargs = {'xt': 'urn:btih:' + self.infohash}
1584 kwargs = {"xt": "urn:btih:" + self.infohash}
15251585 if name:
1526 kwargs['dn'] = self.name
1586 kwargs["dn"] = self.name
15271587 if size:
1528 kwargs['xl'] = self.size
1588 kwargs["xl"] = self.size
15291589
15301590 if tracker:
1531 kwargs['tr'] = (self.trackers[0][0],)
1591 kwargs["tr"] = (self.trackers[0][0],)
15321592 elif trackers:
1533 kwargs['tr'] = (url
1534 for tier in self.trackers
1535 for url in tier)
1593 kwargs["tr"] = (url for tier in self.trackers for url in tier)
15361594
15371595 if self.webseeds is not None:
1538 kwargs['ws'] = self.webseeds
1596 kwargs["ws"] = self.webseeds
15391597
15401598 # Prevent circular import issues
15411599 from ._magnet import Magnet
1600
15421601 return Magnet(**kwargs)
15431602
15441603 # Maximum number of bytes that read() reads from torrent files. This limit
……@@ -1581,29 +1640,31 @@ class Torrent():
15811640
15821641 # Extract 'pieces' from metainfo before decoding because it's the
15831642 # only byte sequence that isn't supposed to be decoded to a string.
1584 if (b'info' in metainfo_enc and
1585 isinstance(metainfo_enc[b'info'], dict) and
1586 b'pieces' in metainfo_enc[b'info']):
1587 pieces = metainfo_enc[b'info'].pop(b'pieces')
1643 if (
1644 b"info" in metainfo_enc
1645 and isinstance(metainfo_enc[b"info"], dict)
1646 and b"pieces" in metainfo_enc[b"info"]
1647 ):
1648 pieces = metainfo_enc[b"info"].pop(b"pieces")
15881649 metainfo = utils.decode_dict(metainfo_enc)
1589 metainfo['info']['pieces'] = pieces
1650 metainfo["info"]["pieces"] = pieces
15901651 else:
15911652 metainfo = utils.decode_dict(metainfo_enc)
15921653
15931654 # "info" must be a dictionary. If validation is not wanted, it's OK
15941655 # if it doesn't exist because the "metainfo" property will add it
15951656 # automatically.
1596 utils.assert_type(metainfo, ('info',), (dict,), must_exist=validate)
1657 utils.assert_type(metainfo, ("info",), (dict,), must_exist=validate)
15971658
15981659 torrent = cls()
15991660 torrent._metainfo = metainfo
16001661
16011662 # Convert "creation date" to datetime.datetime and "private" to
16021663 # bool, but only if they exist
1603 if b'creation date' in metainfo_enc:
1604 torrent.creation_date = metainfo_enc[b'creation date']
1605 if b'private' in metainfo_enc.get(b'info', {}):
1606 torrent.private = metainfo_enc[b'info'][b'private']
1664 if b"creation date" in metainfo_enc:
1665 torrent.creation_date = metainfo_enc[b"creation date"]
1666 if b"private" in metainfo_enc.get(b"info", {}):
1667 torrent.private = metainfo_enc[b"info"][b"private"]
16071668
16081669 if validate:
16091670 torrent.validate()
……@@ -1628,7 +1689,7 @@ class Torrent():
16281689 :return: New :class:`Torrent` instance
16291690 """
16301691 try:
1631 with open(filepath, 'rb') as f:
1692 with open(filepath, "rb") as f:
16321693 return cls.read_stream(f, validate=validate)
16331694 except (OSError, error.ReadError) as e:
16341695 raise error.ReadError(e.errno, filepath)
……@@ -1638,6 +1699,7 @@ class Torrent():
16381699 def copy(self):
16391700 """Create a new :class:`Torrent` instance with the same metainfo"""
16401701 from copy import deepcopy
1702
16411703 cp = type(self)()
16421704 cp._metainfo = deepcopy(self._metainfo)
16431705 return cp
……@@ -1697,18 +1759,20 @@ class Torrent():
16971759 :return: `True` if a matching torrent was found, `False` otherwise
16981760 """
16991761 if not self.path:
1700 raise RuntimeError('reuse() called with no path specified')
1762 raise RuntimeError("reuse() called with no path specified")
17011763 elif not self.files:
1702 raise RuntimeError('reuse() called while file list is empty')
1764 raise RuntimeError("reuse() called while file list is empty")
17031765
17041766 if isinstance(path, (str, pathlib.PurePath)):
17051767 paths = [path]
17061768 elif isinstance(path, abc.Iterable):
17071769 paths = tuple(path)
17081770 else:
1709 raise ValueError(f'Invalid path argument: {path!r}')
1771 raise ValueError(f"Invalid path argument: {path!r}")
17101772
1711 torrent_file_items = reuse.find_torrent_files(*paths, max_file_size=self.MAX_TORRENT_FILE_SIZE)
1773 torrent_file_items = reuse.find_torrent_files(
1774 *paths, max_file_size=self.MAX_TORRENT_FILE_SIZE
1775 )
17121776 maybe_call_callback = reuse.ReuseCallback(
17131777 callback=callback,
17141778 interval=interval,
……@@ -1723,7 +1787,7 @@ class Torrent():
17231787 elif exception:
17241788 raise exception
17251789 else:
1726 raise RuntimeError('Both candidate_path and exception are None?!')
1790 raise RuntimeError("Both candidate_path and exception are None?!")
17271791
17281792 except (error.ReadError, error.BdecodeError, error.MetainfoError) as e:
17291793 cancelled = maybe_call_callback(candidate_path, files_done, False, e)
……@@ -1742,7 +1806,9 @@ class Torrent():
17421806 reuse.copy(candidate, self)
17431807 return True
17441808 else:
1745 cancelled = maybe_call_callback(candidate_path, files_done, False, exception)
1809 cancelled = maybe_call_callback(
1810 candidate_path, files_done, False, exception
1811 )
17461812 if cancelled is not None:
17471813 break
17481814
……@@ -1758,8 +1824,8 @@ class Torrent():
17581824 args = []
17591825
17601826 def get_class_default(name):
1761 if hasattr(type(self), f'{param.name}_default'):
1762 return getattr(type(self), f'{param.name}_default')
1827 if hasattr(type(self), f"{param.name}_default"):
1828 return getattr(type(self), f"{param.name}_default")
17631829 elif hasattr(type(self), param.name):
17641830 return getattr(type(self), param.name)
17651831
……@@ -1776,8 +1842,8 @@ class Torrent():
17761842 # Keyword argument value is different from class default
17771843 and value != get_class_default(param.name)
17781844 ):
1779 args.append(f'{param.name}={value!r}')
1780 return type(self).__name__ + '(' + ', '.join(args) + ')'
1845 args.append(f"{param.name}={value!r}")
1846 return type(self).__name__ + "(" + ", ".join(args) + ")"
17811847
17821848 def __eq__(self, other):
17831849 if isinstance(other, type(self)):
plugin.video.jacktook/lib/vendor/torf/_utils.py +125 -83
……@@ -20,7 +20,6 @@ import fnmatch
2020import functools
2121import http.client
2222import itertools
23import math
2423import os
2524import pathlib
2625import re
……@@ -41,6 +40,7 @@ def is_divisible_by_16_kib(num):
4140 return False
4241 return num % 16384 == 0
4342
43
4444def iterable_startswith(a, b):
4545 a_len = len(a)
4646 for i, b_item in enumerate(b):
……@@ -51,6 +51,7 @@ def iterable_startswith(a, b):
5151 return False
5252 return True
5353
54
5455def flatten(items):
5556 for item in items:
5657 if isinstance(item, Iterable):
……@@ -58,7 +59,10 @@ def flatten(items):
5859 else:
5960 yield item
6061
61_md5sum_regex = re.compile(r'^[0-9a-fA-F]{32}$')
62
63_md5sum_regex = re.compile(r"^[0-9a-fA-F]{32}$")
64
65
6266def is_md5sum(value):
6367 return bool(_md5sum_regex.match(value))
6468
……@@ -70,13 +74,13 @@ def real_size(path):
7074 Raise ReadError on failure
7175 """
7276 if os.path.isdir(os.path.realpath(path)):
77
7378 def onerror(exc):
74 raise error.ReadError(getattr(exc, 'errno', None),
75 getattr(exc, 'filename', None))
79 raise error.ReadError(getattr(exc, "errno", None), getattr(exc, "filename", None))
7680
7781 size = 0
7882 walker = os.walk(path, followlinks=True, onerror=onerror)
79 for dirpath,dirnames,filenames in walker:
83 for dirpath, dirnames, filenames in walker:
8084 for filename in filenames:
8185 filepath = os.path.join(dirpath, filename)
8286 size += os.path.getsize(filepath)
……@@ -85,8 +89,8 @@ def real_size(path):
8589 try:
8690 return os.path.getsize(path)
8791 except OSError as exc:
88 raise error.ReadError(getattr(exc, 'errno', None),
89 getattr(exc, 'filename', None))
92 raise error.ReadError(getattr(exc, "errno", None), getattr(exc, "filename", None))
93
9094
9195def list_files(path):
9296 """
……@@ -95,6 +99,7 @@ def list_files(path):
9599 Raise ReadError if `path` or any file or directory underneath it is not
96100 readable.
97101 """
102
98103 def assert_readable(path):
99104 os_supports_effective_ids = os.access in os.supports_effective_ids
100105 if not os.access(path, os.R_OK, effective_ids=os_supports_effective_ids):
……@@ -104,9 +109,10 @@ def list_files(path):
104109 assert_readable(path)
105110 return [path]
106111 else:
112
107113 def onerror(exc):
108 raise error.ReadError(getattr(exc, 'errno', None),
109 getattr(exc, 'filename', None))
114 raise error.ReadError(getattr(exc, "errno", None), getattr(exc, "filename", None))
115
110116 filepaths = []
111117 for dirpath, dirnames, filenames in os.walk(path, onerror=onerror):
112118 for filename in filenames:
……@@ -116,8 +122,7 @@ def list_files(path):
116122 return list(sorted(filepaths, key=lambda fp: str(fp).casefold()))
117123
118124
119def filter_files(items, getter=lambda f: f, hidden=True, empty=True,
120 exclude=(), include=()):
125def filter_files(items, getter=lambda f: f, hidden=True, empty=True, exclude=(), include=()):
121126 """
122127 Return reduced copy of `items`
123128
……@@ -131,25 +136,28 @@ def filter_files(items, getter=lambda f: f, hidden=True, empty=True,
131136 hidden: Whether to include hidden files
132137 empty: Whether to include empty files
133138 """
139
134140 def is_hidden(path):
135141 for name in str(path).split(os.sep):
136 if name != '.' and name != '..' and name and name[0] == '.':
142 if name != "." and name != ".." and name and name[0] == ".":
137143 return True
138144 return False
139145
140 def is_excluded(path,
141 ex_regexs=tuple(x for x in exclude if isinstance(x, typing.Pattern)),
142 ex_globs=tuple(x for x in exclude if isinstance(x, str)),
143 in_regexs=tuple(i for i in include if isinstance(i, typing.Pattern)),
144 in_globs=tuple(i for i in include if isinstance(i, str))):
146 def is_excluded(
147 path,
148 ex_regexs=tuple(x for x in exclude if isinstance(x, typing.Pattern)),
149 ex_globs=tuple(x for x in exclude if isinstance(x, str)),
150 in_regexs=tuple(i for i in include if isinstance(i, typing.Pattern)),
151 in_globs=tuple(i for i in include if isinstance(i, str)),
152 ):
145153 # Include patterns take precedence over exclude pattersn
146 if any(r.search(str(path)) for r in in_regexs):
154 if any(r.search(str(path)) for r in in_regexs) or any(
155 fnmatch.fnmatch(str(path).casefold(), g.casefold()) for g in in_globs
156 ):
147157 return False
148 elif any(fnmatch.fnmatch(str(path).casefold(), g.casefold()) for g in in_globs):
149 return False
150 elif any(r.search(str(path)) for r in ex_regexs):
151 return True
152 elif any(fnmatch.fnmatch(str(path).casefold(), g.casefold()) for g in ex_globs):
158 elif any(r.search(str(path)) for r in ex_regexs) or any(
159 fnmatch.fnmatch(str(path).casefold(), g.casefold()) for g in ex_globs
160 ):
153161 return True
154162 return False
155163
……@@ -167,13 +175,11 @@ def filter_files(items, getter=lambda f: f, hidden=True, empty=True,
167175 relpath_with_base = pathlib.Path(basepath.parent, filepath)
168176 # Exclude hidden files and directories, but not hidden directories in
169177 # `basepath`
170 if not hidden and is_hidden(relpath_without_base):
171 continue
172 # Exclude empty file
173 elif not empty and os.path.exists(filepath) and real_size(filepath) <= 0:
174 continue
175 # Exclude file matching regex
176 elif is_excluded(relpath_with_base):
178 if (
179 (not hidden and is_hidden(relpath_without_base))
180 or (not empty and os.path.exists(filepath) and real_size(filepath) <= 0)
181 or is_excluded(relpath_with_base)
182 ):
177183 continue
178184 else:
179185 items_filtered.append(item)
……@@ -182,6 +188,7 @@ def filter_files(items, getter=lambda f: f, hidden=True, empty=True,
182188
183189class MonitoredList(collections.abc.MutableSequence):
184190 """List with change callback"""
191
185192 def __init__(self, items=(), callback=None, type=None):
186193 self._items = []
187194 self._type = type
……@@ -232,7 +239,7 @@ class MonitoredList(collections.abc.MutableSequence):
232239
233240 def replace(self, items):
234241 if not isinstance(items, Iterable):
235 raise ValueError(f'Not an iterable: {items!r}')
242 raise ValueError(f"Not an iterable: {items!r}")
236243 # Don't clear list before we know all new values are valid
237244 items = tuple(map(self._coerce, items))
238245 self._items.clear()
……@@ -253,8 +260,7 @@ class MonitoredList(collections.abc.MutableSequence):
253260 if isinstance(other, type(self)):
254261 return frozenset(other._items) == frozenset(self._items)
255262 elif isinstance(other, collections.abc.Iterable):
256 return (len(other) == len(self._items) and
257 all(item in self._items for item in other))
263 return len(other) == len(self._items) and all(item in self._items for item in other)
258264 else:
259265 return False
260266
……@@ -302,12 +308,14 @@ class File(os.PathLike):
302308 elif isinstance(path, collections.abc.Iterable):
303309 self._path = pathlib.Path(*path)
304310 else:
305 raise ValueError(f'Path must be str, PathLike or Iterable, not {type(path).__name__}: {path}')
311 raise ValueError(
312 f"Path must be str, PathLike or Iterable, not {type(path).__name__}: {path}"
313 )
306314
307315 try:
308316 self._size = int(size)
309317 except (ValueError, TypeError):
310 raise ValueError(f'Size must be int, not {type(size).__name__}: {size}')
318 raise ValueError(f"Size must be int, not {type(size).__name__}: {size}")
311319
312320 @property
313321 def size(self):
……@@ -351,11 +359,12 @@ class File(os.PathLike):
351359 return self._path <= other._path
352360
353361 def __repr__(self):
354 return f'{type(self).__name__}({repr(str(self._path))}, size={self._size})'
362 return f"{type(self).__name__}({str(self._path)!r}, size={self._size})"
355363
356364
357365class Files(MonitoredList):
358366 """Deduplicated list of :class:`Files` objects"""
367
359368 def __init__(self, files, callback=None):
360369 if isinstance(files, str):
361370 files = (files,)
……@@ -365,13 +374,14 @@ class Files(MonitoredList):
365374
366375 def _coerce(self, value):
367376 if not isinstance(value, self._type):
368 raise ValueError(f'Not a File object: {value} ({type(value).__name__})')
377 raise ValueError(f"Not a File object: {value} ({type(value).__name__})")
369378 else:
370379 return value
371380
372381
373382class Filepath(type(pathlib.Path())):
374383 """Path-like that makes relative paths equal to their absolute versions"""
384
375385 @classmethod
376386 def _realpath(cls, path):
377387 if os.path.islink(path):
……@@ -398,6 +408,7 @@ class Filepath(type(pathlib.Path())):
398408
399409class Filepaths(MonitoredList):
400410 """Deduplicated list of :class:`Filepath` objects with change callback"""
411
401412 def __init__(self, filepaths, callback=None):
402413 if isinstance(filepaths, str):
403414 filepaths = (filepaths,)
……@@ -428,12 +439,11 @@ class Filepaths(MonitoredList):
428439 try:
429440 path_is_dir = path.is_dir()
430441 except OSError as exc:
431 raise error.ReadError(getattr(exc, 'errno', None),
432 getattr(exc, 'filename', None))
442 raise error.ReadError(getattr(exc, "errno", None), getattr(exc, "filename", None))
433443 if path_is_dir:
434444 # Add files in directory recursively
435445 with self._callback_disabled():
436 for i,child in enumerate(sorted(path.iterdir())):
446 for i, child in enumerate(sorted(path.iterdir())):
437447 self.insert(index + i, child)
438448 if self._callback is not None:
439449 self._callback(self)
……@@ -453,9 +463,10 @@ def is_url(url):
453463 return False
454464 return True
455465
466
456467class URL(str):
457468 def __new__(cls, s):
458 return super().__new__(cls, str(s).replace(' ', '+'))
469 return super().__new__(cls, str(s).replace(" ", "+"))
459470
460471 def __init__(self, url):
461472 if not is_url(url):
……@@ -464,24 +475,41 @@ class URL(str):
464475 self._parsed = urllib.parse.urlparse(url)
465476
466477 @property
467 def scheme(self): return self._parsed.scheme
478 def scheme(self):
479 return self._parsed.scheme
480
468481 @property
469 def netloc(self): return self._parsed.netloc
482 def netloc(self):
483 return self._parsed.netloc
484
470485 @property
471 def hostname(self): return self._parsed.hostname
486 def hostname(self):
487 return self._parsed.hostname
488
472489 @property
473 def port(self): return self._parsed.port
490 def port(self):
491 return self._parsed.port
492
474493 @property
475 def path(self): return self._parsed.path
494 def path(self):
495 return self._parsed.path
496
476497 @property
477 def params(self): return self._parsed.params
498 def params(self):
499 return self._parsed.params
500
478501 @property
479 def query(self): return self._parsed.query
502 def query(self):
503 return self._parsed.query
504
480505 @property
481 def fragment(self): return self._parsed.fragment
506 def fragment(self):
507 return self._parsed.fragment
508
482509
483510class URLs(MonitoredList):
484511 """Auto-flattening list of `:class:URL` objects with change callback"""
512
485513 def __init__(self, urls, callback=None, _get_known_urls=lambda: ()):
486514 self._get_known_urls = _get_known_urls
487515 if isinstance(urls, str):
……@@ -502,6 +530,7 @@ class URLs(MonitoredList):
502530
503531class Trackers(collections.abc.MutableSequence):
504532 """List of :class:`URLs` instances with change callback"""
533
505534 def __init__(self, tiers, callback=None):
506535 self._callback = None
507536 self._tiers = []
……@@ -511,7 +540,7 @@ class Trackers(collections.abc.MutableSequence):
511540 for urls in tiers:
512541 self.append(urls)
513542 else:
514 raise ValueError(f'Must be str or Iterable, not {type(tiers).__name__}: {repr(tiers)}')
543 raise ValueError(f"Must be str or Iterable, not {type(tiers).__name__}: {tiers!r}")
515544 self._callback = callback
516545
517546 @property
……@@ -537,8 +566,11 @@ class Trackers(collections.abc.MutableSequence):
537566 return self._tiers[index]
538567
539568 def __setitem__(self, index, value):
540 tier = URLs(value, callback=self._tier_changed,
541 _get_known_urls=lambda self=self: self.flat)
569 tier = URLs(
570 value,
571 callback=self._tier_changed,
572 _get_known_urls=lambda self=self: self.flat,
573 )
542574 if len(tier) > 0 and tier not in self._tiers:
543575 self._tiers[index] = tier
544576 if self._callback is not None:
……@@ -550,8 +582,11 @@ class Trackers(collections.abc.MutableSequence):
550582 self._callback(self)
551583
552584 def insert(self, index, value):
553 tier = URLs(value, callback=self._tier_changed,
554 _get_known_urls=lambda self=self: self.flat)
585 tier = URLs(
586 value,
587 callback=self._tier_changed,
588 _get_known_urls=lambda self=self: self.flat,
589 )
555590 if len(tier) > 0 and tier not in self._tiers:
556591 self._tiers.insert(index, tier)
557592 if self._callback is not None:
……@@ -559,7 +594,7 @@ class Trackers(collections.abc.MutableSequence):
559594
560595 def replace(self, tiers):
561596 if not isinstance(tiers, Iterable):
562 raise ValueError(f'Not an iterable: {tiers!r}')
597 raise ValueError(f"Not an iterable: {tiers!r}")
563598 with self._callback_disabled():
564599 self._tiers.clear()
565600 for urls in tiers:
……@@ -592,7 +627,7 @@ class Trackers(collections.abc.MutableSequence):
592627 elif isinstance(other, collections.abc.Iterable):
593628 other_tiers = other
594629 new_tiers = []
595 for tier1,x in itertools.zip_longest(self._tiers, other_tiers):
630 for tier1, x in itertools.zip_longest(self._tiers, other_tiers):
596631 if tier1 is None:
597632 tier1 = []
598633 if isinstance(x, str) and len(x) > 1:
……@@ -620,11 +655,12 @@ def download(url, timeout=60):
620655 :return: the downloaded data
621656 """
622657 if timeout <= 0:
623 raise error.ConnectionError(url, 'Timed out')
624 elif url.startswith('http://') or url.startswith('https://'):
658 raise error.ConnectionError(url, "Timed out")
659 elif url.startswith("http://") or url.startswith("https://"):
625660 return download_http(url, timeout=timeout)
626661 else:
627 raise error.ConnectionError(url, 'Unsupported protocol')
662 raise error.ConnectionError(url, "Unsupported protocol")
663
628664
629665def download_http(url, timeout=60):
630666 try:
……@@ -633,16 +669,14 @@ def download_http(url, timeout=60):
633669 try:
634670 msg = e.args[0].strerror
635671 except (AttributeError, IndexError):
636 msg = (getattr(e, 'msg', None) or
637 getattr(e, 'strerror', None) or
638 'Failed')
672 msg = getattr(e, "msg", None) or getattr(e, "strerror", None) or "Failed"
639673 raise error.ConnectionError(url, msg)
640674 except socket.timeout:
641 raise error.ConnectionError(url, 'Timed out')
675 raise error.ConnectionError(url, "Timed out")
642676 except http.client.HTTPException:
643 raise error.ConnectionError(url, 'No HTTP response')
644 except (OSError, IOError):
645 raise error.ConnectionError(url, 'Unknown error')
677 raise error.ConnectionError(url, "No HTTP response")
678 except OSError:
679 raise error.ConnectionError(url, "Unknown error")
646680 else:
647681 return response
648682
……@@ -659,6 +693,7 @@ class Iterable(abc.ABC):
659693
660694 isinstance(x, collections.abc.Iterable) and not isinstance(x, str)
661695 """
696
662697 @classmethod
663698 def __subclasshook__(cls, C):
664699 if cls is Iterable:
……@@ -677,6 +712,7 @@ def key_exists_in_list_or_dict(key, lst_or_dct):
677712 return True
678713 return False
679714
715
680716def assert_type(obj, keys, exp_types, must_exist=True, check=None):
681717 """
682718 Raise MetainfoError if value is not of a particular type
……@@ -704,25 +740,26 @@ def assert_type(obj, keys, exp_types, must_exist=True, check=None):
704740 break
705741 keychain.append(key)
706742
707 keychain_str = ''.join(f'[{key!r}]' for key in keychain)
743 keychain_str = "".join(f"[{key!r}]" for key in keychain)
708744 key = keys.pop(0)
709745
710746 if not key_exists_in_list_or_dict(key, obj):
711747 if must_exist:
712748 if keychain_str:
713 raise error.MetainfoError(f'Missing {key!r} in {keychain_str}')
749 raise error.MetainfoError(f"Missing {key!r} in {keychain_str}")
714750 else:
715 raise error.MetainfoError(f'Missing {key!r}')
751 raise error.MetainfoError(f"Missing {key!r}")
716752
717753 elif not isinstance(obj[key], exp_types):
718754 if len(exp_types) > 2:
719 exp_types_str = ', '.join(t.__name__ for t in exp_types[:-1])
720 exp_types_str += ' or ' + exp_types[-1].__name__
755 exp_types_str = ", ".join(t.__name__ for t in exp_types[:-1])
756 exp_types_str += " or " + exp_types[-1].__name__
721757 else:
722 exp_types_str = ' or '.join(t.__name__ for t in exp_types)
758 exp_types_str = " or ".join(t.__name__ for t in exp_types)
723759 type_str = type(obj[key]).__name__
724 raise error.MetainfoError(f'{keychain_str}[{key!r}] must be {exp_types_str}, '
725 f'not {type_str}: {obj[key]!r}')
760 raise error.MetainfoError(
761 f"{keychain_str}[{key!r}] must be {exp_types_str}, not {type_str}: {obj[key]!r}"
762 )
726763
727764 elif check is not None and not check(obj[key]):
728765 raise error.MetainfoError(f"{keychain_str}[{key!r}] is invalid: {obj[key]!r}")
……@@ -732,7 +769,7 @@ def decode_value(value):
732769 if isinstance(value, collections.abc.ByteString):
733770 # WARNING: Torrents can contain binary data (e.g. "pieces" field). You
734771 # should handle and remove those separately beforehand.
735 return bytes.decode(value, encoding='utf-8', errors='replace')
772 return bytes.decode(value, encoding="utf-8", errors="replace")
736773 elif isinstance(value, collections.abc.Sequence):
737774 return decode_list(value)
738775 elif isinstance(value, collections.abc.Mapping):
……@@ -740,15 +777,17 @@ def decode_value(value):
740777 else:
741778 return value
742779
780
743781def decode_list(lst):
744782 lst_dec = []
745783 for value in lst:
746784 lst_dec.append(decode_value(value))
747785 return lst_dec
748786
787
749788def decode_dict(dct):
750789 dct_dec = {}
751 for key,value in dct.items():
790 for key, value in dct.items():
752791 value_dec = decode_value(value)
753792 key_dec = decode_value(key)
754793 dct_dec[key_dec] = value_dec
……@@ -759,30 +798,33 @@ def encode_value(value):
759798 if type(value) in ENCODE_ALLOWED_TYPES:
760799 return value
761800 else:
762 for cls,converter in ENCODE_CONVERTERS.items():
801 for cls, converter in ENCODE_CONVERTERS.items():
763802 if isinstance(value, cls):
764803 return converter(value)
765 raise ValueError(f'Invalid value: {value!r}')
804 raise ValueError(f"Invalid value: {value!r}")
805
766806
767807def encode_list(lst):
768808 lst_enc = []
769 for i,value in enumerate(lst):
809 for i, value in enumerate(lst):
770810 lst_enc.append(encode_value(value))
771811 return lst_enc
772812
813
773814def encode_dict(dct):
774815 dct_enc = collections.OrderedDict()
775 for key,value in sorted(dct.items()):
816 for key, value in sorted(dct.items()):
776817 if not isinstance(key, str):
777 raise ValueError(f'Invalid key: {key!r}')
778 key_enc = str(key).encode('utf8')
818 raise ValueError(f"Invalid key: {key!r}")
819 key_enc = str(key).encode("utf8")
779820 value_enc = encode_value(value)
780821 dct_enc[key_enc] = value_enc
781822 return dct_enc
782823
824
783825ENCODE_ALLOWED_TYPES = (bytes, int)
784826ENCODE_CONVERTERS = {
785 str: lambda val: str(val).encode(encoding='utf-8', errors='replace'),
827 str: lambda val: str(val).encode(encoding="utf-8", errors="replace"),
786828 float: int,
787829 bool: int,
788830 collections.abc.ByteString: bytes,
plugin.video.jacktook/plugin.video.jacktook.cached.sqlite +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/pyproject.toml +189 -0
……@@ -0,0 +1,189 @@
1[build-system]
2requires = ["setuptools"]
3build-backend = "setuptools.build_meta"
4
5[project]
6name = "plugin.video.jacktook"
7version = "1.15.0"
8description = "Kodi addon for searching and streaming torrents via debrid services"
9requires-python = ">=3.7"
10
11# ──────────────────────────────────────────────
12# Ruff — linter + formatter
13# ──────────────────────────────────────────────
14
15[tool.ruff]
16# Target Python 3.7+ for Kodi compatibility (the addon has a py37 compat checker)
17target-version = "py37"
18line-length = 100
19indent-width = 4
20
21# Files/directories to exclude
22exclude = [
23 ".git",
24 ".venv",
25 ".agent",
26 ".agents",
27 ".opencode",
28 ".worktrees",
29 ".mypy_cache",
30 ".pytest_cache",
31 "docs",
32 "scripts",
33 "tests/manual_*.py",
34 "__pycache__",
35 "*.pyc",
36]
37
38# ── Formatter ─────────────────────────────────
39[tool.ruff.format]
40# Use Black-compatible formatting
41quote-style = "double"
42indent-style = "space"
43line-ending = "auto"
44skip-magic-trailing-comma = false
45
46# ── Linter ────────────────────────────────────
47[tool.ruff.lint]
48# Start with a pragmatic set of rules — expand as needed
49select = [
50 "E", # pycodestyle errors
51 "W", # pycodestyle warnings
52 "F", # Pyflakes (logic errors)
53 "I", # isort (import ordering)
54 "N", # pep8-naming
55 "UP", # pyupgrade (only safe auto-fixes for target version)
56 "B", # flake8-bugbear (likely bugs)
57 "SIM", # flake8-simplify (simplifications)
58 "D", # pydocstyle (docstring conventions)
59 "YTT", # flake8-2020 (version check idioms)
60 "RUF", # Ruff-specific rules
61]
62
63# Ignore rules that are too noisy for this codebase
64ignore = [
65 "D100", # Missing docstring in public module
66 "D101", # Missing docstring in public class
67 "D102", # Missing docstring in public method
68 "D103", # Missing docstring in public function
69 "D104", # Missing docstring in public package
70 "D105", # Missing docstring in magic method
71 "D107", # Missing docstring in __init__
72 "D200", # One-line docstring should fit on one line
73 "D203", # 1 blank line required before class docstring
74 "D212", # Multi-line docstring summary should start at first line
75 "D213", # Multi-line docstring summary should start at second line
76 "D401", # First line should be in imperative mood
77 "D402", # First line should not be function's signature
78 "D413", # Blank line after last section
79 "D414", # Section has no content
80 "D415", # First line should end with period
81 "D416", # Section name should end with colon
82 "D417", # Missing argument descriptions
83 "N802", # Function name should be lowercase (allow camelCase for Kodi compat)
84 "N803", # Argument name should be lowercase
85 "N806", # Variable in function should be lowercase
86 "N815", # Variable in class scope should not be mixedCase
87 "SIM108", # Use ternary operator (if-else blocks)
88 "SIM115", # Use context handler for opening files
89 "SIM117", # Merge with-statements
90 "UP007", # Use X | Y for type unions (not compatible with py37)
91 "UP006", # Use X instead of typing.X
92 "UP035", # Import from typing deprecated
93 "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`
94]
95
96# ── isort settings ────────────────────────────
97[tool.ruff.lint.isort]
98known-first-party = ["lib"]
99known-third-party = ["xbmc", "xbmcgui", "xbmcplugin", "xbmcaddon", "xbmcvfs"]
100force-single-line = false
101order-by-type = true
102
103# ── pydocstyle ────────────────────────────────
104[tool.ruff.lint.pydocstyle]
105convention = "google"
106
107# ── pycodestyle ───────────────────────────────
108[tool.ruff.lint.pycodestyle]
109max-doc-length = 100
110
111# ── Per-file-rule overrides ───────────────────
112[tool.ruff.lint.per-file-ignores]
113"tests/**" = ["D", "N", "SIM", "E402", "E501"]
114"scripts/**" = ["D", "N", "SIM", "UP"]
115# Vendor code: ignore everything except critical bugs (F, B)
116"lib/vendor/**" = ["ALL"]
117"lib/api/tmdbv3api/**" = ["ALL"]
118"lib/utils/parsers/xmltodict.py" = ["ALL"]
119
120# ──────────────────────────────────────────────
121# mypy — static type checker
122# ──────────────────────────────────────────────
123
124[tool.mypy]
125python_version = "3.13"
126strict_optional = true
127warn_unused_ignores = true
128warn_redundant_casts = true
129warn_return_any = true
130no_implicit_optional = true
131show_error_codes = true
132ignore_missing_imports = true
133
134# Exclude test/script/doc directories from initial file discovery
135exclude = [
136 "tests/",
137 "scripts/",
138 "docs/",
139]
140
141# ──────────────────────────────────────────────
142# pytest — test runner
143# ──────────────────────────────────────────────
144
145# Per-module overrides: skip vendored third-party code
146[[tool.mypy.overrides]]
147module = "lib.vendor.*"
148follow_imports = "skip"
149ignore_errors = true
150
151[[tool.mypy.overrides]]
152module = "lib.utils.parsers.xmltodict"
153follow_imports = "skip"
154ignore_errors = true
155
156[[tool.mypy.overrides]]
157module = "lib.api.tmdbv3api.*"
158follow_imports = "skip"
159ignore_errors = true
160
161[tool.pytest.ini_options]
162minversion = "7.0"
163addopts = [
164 "-q",
165 "--tb=short",
166]
167testpaths = ["tests"]
168python_files = ["test_*.py", "verify_*.py"]
169filterwarnings = [
170 "ignore::DeprecationWarning:lib.db.cached:413",
171]
172
173# ──────────────────────────────────────────────
174# Coverage — pytest-cov
175# ──────────────────────────────────────────────
176
177[tool.coverage.run]
178source = ["lib"]
179omit = [
180 "tests/*",
181 "scripts/*",
182 "*/__pycache__/*",
183]
184
185[tool.coverage.report]
186show_missing = true
187skip_covered = true
188skip_empty = true
189fail_under = 0
plugin.video.jacktook/resources/img/spinner.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/language/English/strings.po +475 -19
……@@ -312,8 +312,8 @@ msgid "Unknown"
312312msgstr ""
313313
314314msgctxt "#30700"
315msgid "Buffer and play"
316msgstr ""
315msgid "Play Torrent"
316msgstr "Play Torrent"
317317
318318msgctxt "#30701"
319319msgid "Stop downloading"
……@@ -431,6 +431,58 @@ msgctxt "#30939"
431431msgid "Auto-resume from last position without asking"
432432msgstr ""
433433
434msgctxt "#30940"
435msgid "Use Percentage Trigger"
436msgstr ""
437
438msgctxt "#30941"
439msgid "Show Dialog at Playback Percentage"
440msgstr ""
441
442msgctxt "#30942"
443msgid "Trigger the next-episode dialog based on percentage of video played instead of seconds remaining."
444msgstr ""
445
446msgctxt "#30943"
447msgid "The percentage at which the next-episode dialog appears (e.g. 95 means 95% of the video)."
448msgstr ""
449
450msgctxt "#30944"
451msgid "Auto-Play Timeout"
452msgstr ""
453
454msgctxt "#30945"
455msgid "Seconds before auto-playing next episode. 0 = disabled, dialog waits for user input."
456msgstr ""
457
458msgctxt "#30946"
459msgid "Offcloud Configuration"
460msgstr "Offcloud Configuration"
461
462msgctxt "#30947"
463msgid "Enable Offcloud cached torrent and cloud transfer support."
464msgstr "Enable Offcloud cached torrent and cloud transfer support."
465
466msgctxt "#30948"
467msgid "Your Offcloud account username/email."
468msgstr "Your Offcloud account username/email."
469
470msgctxt "#30949"
471msgid "Offcloud API key from Account API Key settings."
472msgstr "Offcloud API key from Account API Key settings."
473
474msgctxt "#30950"
475msgid "View Offcloud account information."
476msgstr "View Offcloud account information."
477
478msgctxt "#30951"
479msgid "Authorize add-on to access Offcloud account."
480msgstr "Authorize add-on to access Offcloud account."
481
482msgctxt "#30952"
483msgid "Revoke Offcloud account access."
484msgstr "Revoke Offcloud account access."
485
434486msgctxt "#30728"
435487msgid "Seeds"
436488msgstr ""
……@@ -459,6 +511,10 @@ msgctxt "#30734"
459511msgid "Enable"
460512msgstr ""
461513
514msgctxt "#30735"
515msgid "Still Watching? Threshold"
516msgstr ""
517
462518msgctxt "#30736"
463519msgid "Subtitle Language"
464520msgstr ""
……@@ -607,6 +663,10 @@ msgctxt "#30772"
607663msgid "Zilean Host"
608664msgstr ""
609665
666msgctxt "#30773"
667msgid "Number of consecutive auto-played episodes before asking 'Still Watching?'. Set to 0 to disable."
668msgstr ""
669
610670msgctxt "#30774"
611671msgid "Zilean Timeout"
612672msgstr ""
……@@ -1557,7 +1617,7 @@ msgstr ""
15571617
15581618
15591619msgctxt "#90022"
1560msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:"
1620msgid "Jacktook is free and built with care in spare time. If you enjoy the addon, consider supporting its development. Donations are optional and help keep updates coming."
15611621msgstr ""
15621622
15631623msgctxt "#90023"
……@@ -1999,21 +2059,37 @@ msgctxt "#90144"
19992059msgid "Could not resolve playback URL"
20002060msgstr "Could not resolve playback URL"
20012061
2002msgctxt "#90145"
2003msgid "Precaching Enabled"
2004msgstr "Precaching Enabled"
2062msgctxt "#90171"
2063msgid "Autoscrape Next Episode"
2064msgstr "Autoscrape Next Episode"
2065
2066msgctxt "#90172"
2067msgid "Pre-resolve the next episode's playback URL in the background while watching."
2068msgstr "Pre-resolve the next episode's playback URL in the background while watching."
2069
2070msgctxt "#90173"
2071msgid "Autoscrape Threshold"
2072msgstr "Autoscrape Threshold"
2073
2074msgctxt "#90174"
2075msgid "Percentage of playback to trigger background pre-resolution."
2076msgstr "Percentage of playback to trigger background pre-resolution."
2077
2078msgctxt "#90175"
2079msgid "Autoscrape Episode Count"
2080msgstr "Autoscrape Episode Count"
20052081
2006msgctxt "#90146"
2007msgid "Enable or disable precaching of next episodes."
2008msgstr "Enable or disable precaching of next episodes."
2082msgctxt "#90176"
2083msgid "Number of upcoming episodes to pre-resolve."
2084msgstr "Number of upcoming episodes to pre-resolve."
20092085
2010msgctxt "#90147"
2011msgid "Precaching Episode Count"
2012msgstr "Precaching Episode Count"
2086msgctxt "#90177"
2087msgid "Autoscrape Cache TTL"
2088msgstr "Autoscrape Cache TTL"
20132089
2014msgctxt "#90148"
2015msgid "Number of next episodes to precache."
2016msgstr "Number of next episodes to precache."
2090msgctxt "#90178"
2091msgid "Hours to keep resolved playback data in cache."
2092msgstr "Hours to keep resolved playback data in cache."
20172093
20182094msgctxt "#30901"
20192095msgid "Show Only Stremio Catalogs"
……@@ -2563,6 +2639,10 @@ msgctxt "#90298"
25632639msgid "Liked Lists"
25642640msgstr "Liked Lists"
25652641
2642msgctxt "#90299"
2643msgid "No metadata found for this torrent"
2644msgstr "No metadata found for this torrent"
2645
25662646msgctxt "#90331"
25672647msgid "Stremio Addons"
25682648msgstr "Stremio Addons"
……@@ -4016,12 +4096,16 @@ msgid "Invalid episode"
40164096msgstr "Invalid episode"
40174097
40184098msgctxt "#90740"
4019msgid "Edit current title"
4020msgstr "Edit current title"
4099msgid "Search with current title"
4100msgstr "Search with current title"
40214101
40224102msgctxt "#90741"
4023msgid "Use original title"
4024msgstr "Use original title"
4103msgid "Search with original title"
4104msgstr "Search with original title"
4105
4106msgctxt "#90773"
4107msgid "Search full show with title + year"
4108msgstr "Search full show with title + year"
40254109
40264110msgctxt "#90744"
40274111msgid "Upload Subtitle"
……@@ -4066,3 +4150,375 @@ msgstr "English only"
40664150msgctxt "#90754"
40674151msgid "Localized first"
40684152msgstr "Localized first"
4153
4154msgctxt "#90755"
4155msgid "Manage Sources"
4156msgstr "Manage Sources"
4157
4158msgctxt "#90756"
4159msgid "No sources available"
4160msgstr "No sources available"
4161
4162msgctxt "#90179"
4163msgid "Stinger Notifications"
4164msgstr "Stinger Notifications"
4165
4166msgctxt "#90180"
4167msgid "Show a notification when a movie has post-credit scenes."
4168msgstr "Show a notification when a movie has post-credit scenes."
4169
4170msgctxt "#90181"
4171msgid "Stinger Notification Time"
4172msgstr "Stinger Notification Time"
4173
4174msgctxt "#90182"
4175msgid "Seconds before the end to show the stinger notification."
4176msgstr "Seconds before the end to show the stinger notification."
4177
4178msgctxt "#90183"
4179msgid "Stay tuned after the credits!"
4180msgstr "Stay tuned after the credits!"
4181
4182msgctxt "#90757"
4183msgid "Image Resolution"
4184msgstr "Image Resolution"
4185
4186msgctxt "#90758"
4187msgid "Low"
4188msgstr "Low"
4189
4190msgctxt "#90759"
4191msgid "Medium"
4192msgstr "Medium"
4193
4194msgctxt "#90760"
4195msgid "High"
4196msgstr "High"
4197
4198msgctxt "#90761"
4199msgid "Original"
4200msgstr "Original"
4201
4202msgctxt "#90762"
4203msgid "Enable RPDB Posters"
4204msgstr ""
4205
4206msgctxt "#90763"
4207msgid "RPDB API Key"
4208msgstr ""
4209
4210msgctxt "#90764"
4211msgid "Get your API key at ratingposterdb.com"
4212msgstr ""
4213
4214msgctxt "#90765"
4215msgid "Filter by Codecs"
4216msgstr ""
4217
4218msgctxt "#90766"
4219msgid "Filter by HDR"
4220msgstr ""
4221
4222msgctxt "#90767"
4223msgid "Include HEVC / H.265"
4224msgstr ""
4225
4226msgctxt "#90768"
4227msgid "Include AV1"
4228msgstr ""
4229
4230msgctxt "#90769"
4231msgid "Include H.264"
4232msgstr ""
4233
4234msgctxt "#90770"
4235msgid "Include Dolby Vision"
4236msgstr ""
4237
4238msgctxt "#90771"
4239msgid "Include HDR10 / HDR10+"
4240msgstr ""
4241
4242msgctxt "#90772"
4243msgid "Include HDR (generic)"
4244msgstr ""
4245
4246msgctxt "#90774"
4247msgid "Organize downloads by type"
4248msgstr ""
4249
4250msgctxt "#90775"
4251msgid "Movies folder name"
4252msgstr ""
4253
4254msgctxt "#90776"
4255msgid "TV Shows folder name"
4256msgstr ""
4257
4258msgctxt "#90777"
4259msgid "Pause"
4260msgstr ""
4261
4262msgctxt "#90778"
4263msgid "Resume"
4264msgstr ""
4265
4266msgctxt "#90779"
4267msgid "Downloading"
4268msgstr ""
4269
4270msgctxt "#90780"
4271msgid "Paused"
4272msgstr ""
4273
4274msgctxt "#90781"
4275msgid "Completed"
4276msgstr ""
4277
4278msgctxt "#90782"
4279msgid "Delete"
4280msgstr ""
4281
4282msgctxt "#90783"
4283msgid "Startup"
4284msgstr ""
4285
4286msgctxt "#90784"
4287msgid "Auto-start Jacktook on Kodi boot"
4288msgstr ""
4289
4290msgctxt "#90785"
4291msgid "Automatically open Jacktook when Kodi starts"
4292msgstr ""
4293
4294msgctxt "#90786"
4295msgid "Widgets"
4296msgstr ""
4297
4298msgctxt "#90787"
4299msgid "Widget refresh interval (minutes)"
4300msgstr ""
4301
4302msgctxt "#90788"
4303msgid "Set to 0 to disable automatic widget refresh"
4304msgstr ""
4305
4306msgctxt "#90789"
4307msgid "Show refresh notification"
4308msgstr ""
4309
4310msgctxt "#90790"
4311msgid "Display a notification when widgets are refreshed"
4312msgstr ""
4313
4314msgctxt "#90791"
4315msgid "Download to Device"
4316msgstr ""
4317
4318msgctxt "#90792"
4319msgid "Download Manager"
4320msgstr ""
4321
4322msgctxt "#90793"
4323msgid "No active downloads"
4324msgstr ""
4325
4326msgctxt "#90794"
4327msgid "Progress"
4328msgstr ""
4329
4330msgctxt "#90795"
4331msgid "Speed"
4332msgstr ""
4333
4334msgctxt "#90796"
4335msgid "ETA"
4336msgstr ""
4337
4338msgctxt "#90797"
4339msgid "Size"
4340msgstr ""
4341
4342msgctxt "#90798"
4343msgid "Pause"
4344msgstr ""
4345
4346msgctxt "#90799"
4347msgid "Resume"
4348msgstr ""
4349
4350msgctxt "#90800"
4351msgid "Cancel"
4352msgstr ""
4353
4354msgctxt "#90801"
4355msgid "Delete"
4356msgstr ""
4357
4358msgctxt "#90802"
4359msgid "Clear Completed"
4360msgstr ""
4361
4362msgctxt "#90803"
4363msgid "Download not yet completed"
4364msgstr ""
4365
4366msgctxt "#90804"
4367msgid "File not found"
4368msgstr ""
4369
4370msgctxt "#90805"
4371msgid "Download Manager"
4372msgstr ""
4373
4374msgctxt "#90806"
4375msgid "My Downloads"
4376msgstr ""
4377
4378msgctxt "#90807"
4379msgid "Connecting..."
4380msgstr ""
4381
4382msgctxt "#90808"
4383msgid "Close"
4384msgstr ""
4385
4386msgctxt "#90809"
4387msgid "Delete download?"
4388msgstr ""
4389
4390msgctxt "#90810"
4391msgid "This will permanently delete the downloaded file. Are you sure?"
4392msgstr ""
4393
4394msgctxt "#90811"
4395msgid "Completed"
4396msgstr ""
4397
4398msgctxt "#90812"
4399msgid "Excluded TMDB Languages"
4400msgstr ""
4401
4402msgctxt "#90813"
4403msgid "Select languages to exclude"
4404msgstr ""
4405
4406msgctxt "#90814"
4407msgid "Choose original languages to hide from TMDB listings"
4408msgstr ""
4409
4410msgctxt "#90815"
4411msgid "Support Jacktook"
4412msgstr ""
4413
4414msgctxt "#90816"
4415msgid "Support Jacktook development with an optional Ko-fi donation."
4416msgstr ""
4417
4418msgctxt "#90817"
4419msgid "Scan the QR code or open this link:"
4420msgstr ""
4421
4422msgctxt "#90818"
4423msgid "Jacktook was updated with new improvements. Donations are optional, but they help keep development active. Would you like to see the support link?"
4424msgstr ""
4425
4426msgctxt "#90819"
4427msgid "Rename Stremio add-on"
4428msgstr ""
4429
4430msgctxt "#90820"
4431msgid "Enter add-on alias."
4432msgstr ""
4433
4434msgctxt "#90821"
4435msgid "Stremio alias"
4436msgstr ""
4437
4438msgctxt "#90822"
4439msgid "Alias saved."
4440msgstr ""
4441
4442msgctxt "#90823"
4443msgid "Alias cleared."
4444msgstr ""
4445
4446msgctxt "#90824"
4447msgid "Rename Stremio catalog"
4448msgstr ""
4449
4450msgctxt "#90825"
4451msgid "Select catalog"
4452msgstr ""
4453
4454msgctxt "#90826"
4455msgid "Enter catalog alias."
4456msgstr ""
4457
4458msgctxt "#90827"
4459msgid "Set a Kodi-only display alias for a Stremio add-on."
4460msgstr ""
4461
4462msgctxt "#90828"
4463msgid "Set a Kodi-only display alias for a Stremio catalog."
4464msgstr ""
4465
4466msgctxt "#90829"
4467msgid "Edit"
4468msgstr ""
4469
4470msgctxt "#90830"
4471msgid "Clear"
4472msgstr ""
4473
4474msgctxt "#90831"
4475msgid "This item already has an alias. Do you want to clear it?"
4476msgstr ""
4477
4478msgctxt "#90832"
4479msgid "Stremio: Addons Management"
4480msgstr ""
4481
4482msgctxt "#90833"
4483msgid "Stremio: Catalogs & Visibility"
4484msgstr ""
4485
4486msgctxt "#90834"
4487msgid "Stremio: Rename & Customization"
4488msgstr ""
4489
4490msgctxt "#90835"
4491msgid "Stremio: Search & Behavior"
4492msgstr ""
4493
4494msgctxt "#90300"
4495msgid "External Scraper"
4496msgstr ""
4497
4498msgctxt "#90301"
4499msgid "Enable external scraper module integration."
4500msgstr ""
4501
4502msgctxt "#90302"
4503msgid "Choose External Scraper Module"
4504msgstr ""
4505
4506msgctxt "#90303"
4507msgid "Select an external scraper addon module to use for torrent search."
4508msgstr ""
4509
4510msgctxt "#90304"
4511msgid "Choose External Scraper"
4512msgstr ""
4513
4514msgctxt "#90305"
4515msgid "Selected Module"
4516msgstr ""
4517
4518msgctxt "#90306"
4519msgid "Open External Scraper Settings"
4520msgstr ""
4521
4522msgctxt "#90307"
4523msgid "Open the settings for the selected external scraper module."
4524msgstr ""
plugin.video.jacktook/resources/language/Portuguese (Brazil)/strings.po +870 -66
……@@ -312,8 +312,8 @@ msgid "Unknown"
312312msgstr "Desconhecido"
313313
314314msgctxt "#30700"
315msgid "Buffer and play"
316msgstr "Carregar e reproduzir"
315msgid "Play Torrent"
316msgstr "Reproduzir torrent"
317317
318318msgctxt "#30701"
319319msgid "Stop downloading"
……@@ -431,6 +431,58 @@ msgctxt "#30939"
431431msgid "Auto-resume from last position without asking"
432432msgstr "Retomar automaticamente da última posição sem perguntar"
433433
434msgctxt "#30940"
435msgid "Use Percentage Trigger"
436msgstr "Usar acionamento por porcentagem"
437
438msgctxt "#30941"
439msgid "Show Dialog at Playback Percentage"
440msgstr "Mostrar diálogo na porcentagem de reprodução"
441
442msgctxt "#30942"
443msgid "Trigger the next-episode dialog based on percentage of video played instead of seconds remaining."
444msgstr "Acionar o diálogo do próximo episódio com base na porcentagem do vídeo reproduzida em vez dos segundos restantes."
445
446msgctxt "#30943"
447msgid "The percentage at which the next-episode dialog appears (e.g. 95 means 95% of the video)."
448msgstr "A porcentagem em que o diálogo do próximo episódio aparece (por exemplo, 95 significa 95% do vídeo)."
449
450msgctxt "#30944"
451msgid "Auto-Play Timeout"
452msgstr "Tempo limite da reprodução automática"
453
454msgctxt "#30945"
455msgid "Seconds before auto-playing next episode. 0 = disabled, dialog waits for user input."
456msgstr "Segundos antes de reproduzir automaticamente o próximo episódio. 0 = desativado, o diálogo aguarda a entrada do usuário."
457
458
459msgctxt "#30946"
460msgid "Offcloud Configuration"
461msgstr "Configuração do Offcloud"
462
463msgctxt "#30947"
464msgid "Enable Offcloud cached torrent and cloud transfer support."
465msgstr "Ativar suporte a torrents em cache e transferências em nuvem do Offcloud."
466
467msgctxt "#30948"
468msgid "Your Offcloud account username/email."
469msgstr "Seu nome de usuário ou email do Offcloud."
470
471msgctxt "#30949"
472msgid "Offcloud API key from Account API Key settings."
473msgstr "Chave API do Offcloud nas configurações da Conta → Chave API."
474
475msgctxt "#30950"
476msgid "View Offcloud account information."
477msgstr "Ver informações da conta Offcloud."
478
479msgctxt "#30951"
480msgid "Authorize add-on to access Offcloud account."
481msgstr "Autorizar o addon a acessar a conta Offcloud."
482
483msgctxt "#30952"
484msgid "Revoke Offcloud account access."
485msgstr "Revogar o acesso da conta Offcloud."
434486msgctxt "#30728"
435487msgid "Seeds"
436488msgstr "Sementes"
……@@ -459,6 +511,10 @@ msgctxt "#30734"
459511msgid "Enable"
460512msgstr "Habilitar"
461513
514msgctxt "#30735"
515msgid "Still Watching? Threshold"
516msgstr "Limite de Ainda assistindo?"
517
462518msgctxt "#30736"
463519msgid "Subtitle Language"
464520msgstr "Idioma das legendas"
……@@ -607,6 +663,10 @@ msgctxt "#30772"
607663msgid "Zilean Host"
608664msgstr "Host Zilean"
609665
666msgctxt "#30773"
667msgid "Number of consecutive auto-played episodes before asking 'Still Watching?'. Set to 0 to disable."
668msgstr "Número de episódios reproduzidos automaticamente em sequência antes de perguntar 'Ainda assistindo?'. Defina como 0 para desativar."
669
610670msgctxt "#30774"
611671msgid "Zilean Timeout"
612672msgstr "Zilean Tempo limite"
……@@ -1556,8 +1616,8 @@ msgid "TV Calendar"
15561616msgstr "Calendário de TV"
15571617
15581618msgctxt "#90022"
1559msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:"
1560msgstr "Se você gosta de usar o Jacktook e quer apoiar seu desenvolvimento, você pode me pagar um café! Seu apoio ajuda a manter o projeto ativo e permite futuras melhorias:"
1619msgid "Jacktook is free and built with care in spare time. If you enjoy the addon, consider supporting its development. Donations are optional and help keep updates coming."
1620msgstr "O Jacktook é gratuito e feito com carinho no tempo livre. Se você gosta do addon, considere apoiar seu desenvolvimento. As doações são opcionais e ajudam a manter as atualizações."
15611621
15621622msgctxt "#90023"
15631623msgid "Support Jacktook"
……@@ -1933,15 +1993,15 @@ msgstr "Senha"
19331993
19341994msgctxt "#90129"
19351995msgid "Test Connection"
1936msgstr ""
1996msgstr "Testar conexão"
19371997
19381998msgctxt "#90131"
19391999msgid "Webdav"
1940msgstr ""
2000msgstr "WebDAV"
19412001
19422002msgctxt "#90132"
19432003msgid "Remote Path"
1944msgstr ""
2004msgstr "Caminho remoto"
19452005
19462006msgctxt "#90133"
19472007msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)."
……@@ -1949,11 +2009,11 @@ msgstr "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.
19492009
19502010msgctxt "#90134"
19512011msgid "The username required for authenticating with your WebDAV server"
1952msgstr ""
2012msgstr "O nome de usuário necessário para autenticação no seu servidor WebDAV"
19532013
19542014msgctxt "#90135"
19552015msgid "The password for the specified username to authenticate with your WebDAV server"
1956msgstr ""
2016msgstr "A senha do nome de usuário especificado para autenticação no seu servidor WebDAV"
19572017
19582018msgctxt "#90136"
19592019msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)."
……@@ -1961,11 +2021,11 @@ msgstr "The base path or directory on the WebDAV server where your media files a
19612021
19622022msgctxt "#90137"
19632023msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials."
1964msgstr ""
2024msgstr "Clique para verificar se o cliente WebDAV consegue se conectar com sucesso ao servidor configurado usando as credenciais fornecidas."
19652025
19662026msgctxt "#90138"
19672027msgid "Remove Custom Addon"
1968msgstr ""
2028msgstr "Remover addon personalizado"
19692029
19702030msgctxt "#90139"
19712031msgid "Playback"
……@@ -1973,43 +2033,59 @@ msgstr "Reprodução"
19732033
19742034msgctxt "#90140"
19752035msgid "Super quick play"
1976msgstr ""
2036msgstr "Reprodução super rápida"
19772037
19782038msgctxt "#90141"
19792039msgid "Re-ask to play last played video"
1980msgstr ""
2040msgstr "Perguntar novamente para reproduzir o último vídeo reproduzido"
19812041
19822042msgctxt "#90142"
19832043msgid "Replay Dialog"
1984msgstr ""
2044msgstr "Diálogo de repetição"
19852045
19862046msgctxt "#90143"
19872047msgid "Do you want to play last watched?"
1988msgstr ""
2048msgstr "Deseja reproduzir o último assistido?"
19892049
19902050msgctxt "#90144"
19912051msgid "Could not resolve playback URL"
1992msgstr ""
2052msgstr "Não foi possível resolver a URL de reprodução"
19932053
1994msgctxt "#90145"
1995msgid "Precaching Enabled"
1996msgstr "Pré-carregamento Ativado"
2054msgctxt "#90171"
2055msgid "Autoscrape Next Episode"
2056msgstr "Autoscrape do próximo episódio"
19972057
1998msgctxt "#90146"
1999msgid "Enable or disable precaching of next episodes."
2000msgstr "Ativar ou desativar o pré-carregamento dos próximos episódios."
2058msgctxt "#90172"
2059msgid "Pre-resolve the next episode's playback URL in the background while watching."
2060msgstr "Pré-resolve em segundo plano a URL de reprodução do próximo episódio enquanto você assiste."
20012061
2002msgctxt "#90147"
2003msgid "Precaching Episode Count"
2004msgstr "Contagem de Episódios para Pré-carregar"
2062msgctxt "#90173"
2063msgid "Autoscrape Threshold"
2064msgstr "Limiar do autoscrape"
20052065
2006msgctxt "#90148"
2007msgid "Number of next episodes to precache."
2008msgstr "Número de próximos episódios para pré-carregar."
2066msgctxt "#90174"
2067msgid "Percentage of playback to trigger background pre-resolution."
2068msgstr "Percentual da reprodução para acionar a pré-resolução em segundo plano."
2069
2070msgctxt "#90175"
2071msgid "Autoscrape Episode Count"
2072msgstr "Quantidade de episódios do autoscrape"
2073
2074msgctxt "#90176"
2075msgid "Number of upcoming episodes to pre-resolve."
2076msgstr "Número de próximos episódios para pré-resolver."
2077
2078msgctxt "#90177"
2079msgid "Autoscrape Cache TTL"
2080msgstr "TTL do cache do autoscrape"
2081
2082msgctxt "#90178"
2083msgid "Hours to keep resolved playback data in cache."
2084msgstr "Horas para manter em cache os dados de reprodução resolvidos."
20092085
20102086msgctxt "#30901"
20112087msgid "Show Only Stremio Catalogs"
2012msgstr ""
2088msgstr "Mostrar apenas catálogos do Stremio"
20132089
20142090msgctxt "#30902"
20152091msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus."
……@@ -2073,19 +2149,19 @@ msgstr "Filtrar por Qualidade"
20732149
20742150msgctxt "#32301"
20752151msgid "Filter by Provider"
2076msgstr ""
2152msgstr "Filtrar por provedor"
20772153
20782154msgctxt "#32302"
20792155msgid "Filter by Source"
2080msgstr ""
2156msgstr "Filtrar por fonte"
20812157
20822158msgctxt "#32303"
20832159msgid "Filter by Language"
2084msgstr ""
2160msgstr "Filtrar por idioma"
20852161
20862162msgctxt "#32304"
20872163msgid "Filter by Torrents"
2088msgstr ""
2164msgstr "Filtrar por torrents"
20892165
20902166msgctxt "#32305"
20912167msgid "Filter by Debrid"
……@@ -2093,7 +2169,7 @@ msgstr "Filtrar por Debrid"
20932169
20942170msgctxt "#32306"
20952171msgid "Reset Filter"
2096msgstr ""
2172msgstr "Redefinir filtro"
20972173
20982174msgctxt "#30930"
20992175msgid "Ping Addons"
……@@ -2189,7 +2265,7 @@ msgstr "Ignorar a ordenação nativa dos add-ons do Stremio"
21892265
21902266msgctxt "#90212"
21912267msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds."
2192msgstr ""
2268msgstr "Se ativado, os addons especificados não terão seus resultados reordenados nem filtrados pelos limites nativos de qualidade e tamanho do Jacktook."
21932269
21942270msgctxt "#90213"
21952271msgid "Select Bypassed Addons"
……@@ -2197,7 +2273,7 @@ msgstr "Selecionar add-ons a ignorar"
21972273
21982274msgctxt "#90214"
21992275msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering."
2200msgstr ""
2276msgstr "Selecione quais addons do Stremio ignoram a filtragem nativa de tamanho e qualidade do Jacktook."
22012277
22022278msgctxt "#90215"
22032279msgid "Downgrade"
……@@ -2209,27 +2285,27 @@ msgstr "Instala uma versao publicada anterior do Jacktook. A versao atual e salv
22092285
22102286msgctxt "#30932"
22112287msgid "Update Action"
2212msgstr ""
2288msgstr "Ação de atualização"
22132289
22142290msgctxt "#30933"
22152291msgid "Choose how to handle background update checks."
2216msgstr ""
2292msgstr "Escolha como lidar com as verificações de atualização em segundo plano."
22172293
22182294msgctxt "#30934"
22192295msgid "Ask"
2220msgstr ""
2296msgstr "Perguntar"
22212297
22222298msgctxt "#30935"
22232299msgid "Notify"
2224msgstr ""
2300msgstr "Notificar"
22252301
22262302msgctxt "#90216"
22272303msgid "Volume Check Enabled"
2228msgstr ""
2304msgstr "Verificação de volume ativada"
22292305
22302306msgctxt "#90217"
22312307msgid "Automatically limits volume if it is above the threshold before playback."
2232msgstr ""
2308msgstr "Limita automaticamente o volume se ele estiver acima do limite antes da reprodução."
22332309
22342310msgctxt "#90218"
22352311msgid "Volume Threshold"
……@@ -2237,15 +2313,15 @@ msgstr "Limite de volume"
22372313
22382314msgctxt "#90219"
22392315msgid "The volume percentage above which the warning triggers."
2240msgstr ""
2316msgstr "A porcentagem de volume acima da qual o aviso é acionado."
22412317
22422318msgctxt "#90220"
22432319msgid "High Volume Warning"
2244msgstr ""
2320msgstr "Aviso de volume alto"
22452321
22462322msgctxt "#90221"
22472323msgid "High volume detected. Adjusting to %s%% for safety."
2248msgstr ""
2324msgstr "Volume alto detectado. Ajustando para %s%% por segurança."
22492325
22502326msgctxt "#90222"
22512327msgid "Fanart.tv Configuration"
……@@ -2253,19 +2329,19 @@ msgstr "Fanart.tv Configuração"
22532329
22542330msgctxt "#90223"
22552331msgid "Client Key"
2256msgstr ""
2332msgstr "Chave do cliente"
22572333
22582334msgctxt "#90224"
22592335msgid "Your personal Fanart.tv client key for higher quality assets."
2260msgstr ""
2336msgstr "Sua chave pessoal de cliente do Fanart.tv para recursos de maior qualidade."
22612337
22622338msgctxt "#90225"
22632339msgid "Debrid Notifications"
2264msgstr ""
2340msgstr "Notificações Debrid"
22652341
22662342msgctxt "#90226"
22672343msgid "Debrid Expiration Notification"
2268msgstr ""
2344msgstr "Notificação de expiração do Debrid"
22692345
22702346msgctxt "#90227"
22712347msgid "Enable or disable notifications when your Debrid subscription is about to expire."
……@@ -2277,15 +2353,15 @@ msgstr "Expiration Threshold (Days)"
22772353
22782354msgctxt "#90229"
22792355msgid "How many days before expiration to start notifying."
2280msgstr ""
2356msgstr "Quantos dias antes da expiração começar a notificar."
22812357
22822358msgctxt "#90230"
22832359msgid "%s expires in %d days."
2284msgstr ""
2360msgstr "%s expira em %d dias."
22852361
22862362msgctxt "#90231"
22872363msgid "%s expires today!"
2288msgstr ""
2364msgstr "%s expira hoje!"
22892365
22902366msgctxt "#90232"
22912367msgid "Easynews"
……@@ -2293,19 +2369,19 @@ msgstr "Easynews"
22932369
22942370msgctxt "#90233"
22952371msgid "Enable EasyNews as a direct stream provider."
2296msgstr ""
2372msgstr "Ativar o EasyNews como provedor de stream direto."
22972373
22982374msgctxt "#90234"
22992375msgid "Your EasyNews username."
2300msgstr ""
2376msgstr "Seu nome de usuário do EasyNews."
23012377
23022378msgctxt "#90235"
23032379msgid "Your EasyNews password."
2304msgstr ""
2380msgstr "Sua senha do EasyNews."
23052381
23062382msgctxt "#90236"
23072383msgid "Request timeout in seconds."
2308msgstr ""
2384msgstr "Tempo limite da requisição em segundos."
23092385
23102386msgctxt "#90237"
23112387msgid "Account Information"
……@@ -2313,15 +2389,15 @@ msgstr "Informações da conta"
23132389
23142390msgctxt "#90238"
23152391msgid "Search Dialog Style"
2316msgstr ""
2392msgstr "Estilo da caixa de diálogo de busca"
23172393
23182394msgctxt "#90239"
23192395msgid "Background"
2320msgstr ""
2396msgstr "Plano de fundo"
23212397
23222398msgctxt "#90240"
23232399msgid "Detailed Status"
2324msgstr ""
2400msgstr "Status detalhado"
23252401
23262402msgctxt "#90241"
23272403msgid "Search Status"
……@@ -2357,7 +2433,7 @@ msgstr "Cancelado"
23572433
23582434msgctxt "#90249"
23592435msgid "Your personal Jackgram API token for authentication."
2360msgstr ""
2436msgstr "Seu token pessoal da API do Jackgram para autenticação."
23612437
23622438msgctxt "#90250"
23632439msgid "Subtitles Found"
……@@ -2555,6 +2631,10 @@ msgctxt "#90298"
25552631msgid "Liked Lists"
25562632msgstr "Listas Curtidas"
25572633
2634msgctxt "#90299"
2635msgid "No metadata found for this torrent"
2636msgstr "Nenhum metadado encontrado para este torrent"
2637
25582638msgctxt "#90331"
25592639msgid "Stremio Addons"
25602640msgstr "Addons do Stremio"
……@@ -2641,27 +2721,27 @@ msgstr "Nao foi possivel baixar o backup de configuracoes."
26412721
26422722msgctxt "#90352"
26432723msgid "Source Select Style"
2644msgstr ""
2724msgstr "Estilo da seleção de fontes"
26452725
26462726msgctxt "#90353"
26472727msgid "Modern"
2648msgstr ""
2728msgstr "Moderno"
26492729
26502730msgctxt "#90354"
26512731msgid "OLED"
2652msgstr ""
2732msgstr "OLED"
26532733
26542734msgctxt "#90355"
26552735msgid "Cyberpunk"
2656msgstr ""
2736msgstr "Cyberpunk"
26572737
26582738msgctxt "#90356"
26592739msgid "Forest"
2660msgstr ""
2740msgstr "Floresta"
26612741
26622742msgctxt "#90357"
26632743msgid "Gold"
2664msgstr ""
2744msgstr "Dourado"
26652745
26662746msgctxt "#90358"
26672747msgid "No playable results found"
……@@ -3651,6 +3731,18 @@ msgctxt "#90749"
36513731msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file"
36523732msgstr "Escaneie o código QR ou abra a URL (%s) no seu navegador para enviar um arquivo de legendas"
36533733
3734msgctxt "#90740"
3735msgid "Search with current title"
3736msgstr "Buscar com o título atual"
3737
3738msgctxt "#90741"
3739msgid "Search with original title"
3740msgstr "Buscar com o título original"
3741
3742msgctxt "#90773"
3743msgid "Search full show with title + year"
3744msgstr "Buscar série completa com título + ano"
3745
36543746msgctxt "#90750"
36553747msgid "Search Title Language"
36563748msgstr "Idioma do título de busca"
……@@ -3670,3 +3762,715 @@ msgstr "Somente inglês"
36703762msgctxt "#90754"
36713763msgid "Localized first"
36723764msgstr "Localizado primeiro"
3765
3766msgctxt "#90755"
3767msgid "Manage Sources"
3768msgstr "Gerenciar Fontes"
3769
3770msgctxt "#90756"
3771msgid "No sources available"
3772msgstr "Nenhuma fonte disponível"
3773
3774msgctxt "#90179"
3775msgid "Stinger Notifications"
3776msgstr "Notificações de stinger"
3777
3778msgctxt "#90180"
3779msgid "Show a notification when a movie has post-credit scenes."
3780msgstr "Mostrar uma notificação quando um filme tiver cenas pós-créditos."
3781
3782msgctxt "#90181"
3783msgid "Stinger Notification Time"
3784msgstr "Momento da notificação de stinger"
3785
3786msgctxt "#90182"
3787msgid "Seconds before the end to show the stinger notification."
3788msgstr "Segundos antes do fim para mostrar a notificação de stinger."
3789
3790msgctxt "#90183"
3791msgid "Stay tuned after the credits!"
3792msgstr "Fique ligado após os créditos!"
3793
3794msgctxt "#90757"
3795msgid "Image Resolution"
3796msgstr "Resolução da imagem"
3797
3798msgctxt "#90758"
3799msgid "Low"
3800msgstr "Baixa"
3801
3802msgctxt "#90759"
3803msgid "Medium"
3804msgstr "Média"
3805
3806msgctxt "#90760"
3807msgid "High"
3808msgstr "Alta"
3809
3810msgctxt "#90761"
3811msgid "Original"
3812msgstr "Original"
3813
3814msgctxt "#90762"
3815msgid "Enable RPDB Posters"
3816msgstr "Ativar pôsteres RPDB"
3817
3818msgctxt "#90763"
3819msgid "RPDB API Key"
3820msgstr "Chave da API do RPDB"
3821
3822msgctxt "#90764"
3823msgid "Get your API key at ratingposterdb.com"
3824msgstr "Obtenha sua chave de API em ratingposterdb.com"
3825
3826msgctxt "#90765"
3827msgid "Filter by Codecs"
3828msgstr "Filtrar por codecs"
3829
3830msgctxt "#90766"
3831msgid "Filter by HDR"
3832msgstr "Filtrar por HDR"
3833
3834msgctxt "#90767"
3835msgid "Include HEVC / H.265"
3836msgstr "Incluir HEVC / H.265"
3837
3838msgctxt "#90768"
3839msgid "Include AV1"
3840msgstr "Incluir AV1"
3841
3842msgctxt "#90769"
3843msgid "Include H.264"
3844msgstr "Incluir H.264"
3845
3846msgctxt "#90770"
3847msgid "Include Dolby Vision"
3848msgstr "Incluir Dolby Vision"
3849
3850msgctxt "#90771"
3851msgid "Include HDR10 / HDR10+"
3852msgstr "Incluir HDR10 / HDR10+"
3853
3854msgctxt "#90772"
3855msgid "Include HDR (generic)"
3856msgstr "Incluir HDR (genérico)"
3857
3858msgctxt "#90774"
3859msgid "Organize downloads by type"
3860msgstr "Organizar downloads por tipo"
3861
3862msgctxt "#90775"
3863msgid "Movies folder name"
3864msgstr "Nome da pasta de filmes"
3865
3866msgctxt "#90776"
3867msgid "TV Shows folder name"
3868msgstr "Nome da pasta de séries"
3869
3870msgctxt "#90777"
3871msgid "Pause"
3872msgstr "Pausar"
3873
3874msgctxt "#90778"
3875msgid "Resume"
3876msgstr "Retomar"
3877
3878msgctxt "#90779"
3879msgid "Downloading"
3880msgstr "Baixando"
3881
3882msgctxt "#90780"
3883msgid "Paused"
3884msgstr "Pausado"
3885
3886msgctxt "#90781"
3887msgid "Completed"
3888msgstr "Concluído"
3889
3890msgctxt "#90782"
3891msgid "Delete"
3892msgstr "Excluir"
3893
3894msgctxt "#90783"
3895msgid "Startup"
3896msgstr "Inicialização"
3897
3898msgctxt "#90784"
3899msgid "Auto-start Jacktook on Kodi boot"
3900msgstr "Iniciar Jacktook automaticamente na inicialização do Kodi"
3901
3902msgctxt "#90785"
3903msgid "Automatically open Jacktook when Kodi starts"
3904msgstr "Abrir Jacktook automaticamente quando o Kodi iniciar"
3905
3906msgctxt "#90786"
3907msgid "Widgets"
3908msgstr "Widgets"
3909
3910msgctxt "#90787"
3911msgid "Widget refresh interval (minutes)"
3912msgstr "Intervalo de atualização de widgets (minutos)"
3913
3914msgctxt "#90788"
3915msgid "Set to 0 to disable automatic widget refresh"
3916msgstr "Definir como 0 para desativar a atualização automática"
3917
3918msgctxt "#90789"
3919msgid "Show refresh notification"
3920msgstr "Mostrar notificação de atualização"
3921
3922msgctxt "#90790"
3923msgid "Display a notification when widgets are refreshed"
3924msgstr "Mostrar uma notificação quando os widgets são atualizados"
3925
3926msgctxt "#90791"
3927msgid "Download to Device"
3928msgstr "Baixar para o dispositivo"
3929
3930msgctxt "#90792"
3931msgid "Download Manager"
3932msgstr "Gerenciador de downloads"
3933
3934msgctxt "#90793"
3935msgid "No active downloads"
3936msgstr "Nenhum download ativo"
3937
3938msgctxt "#90794"
3939msgid "Progress"
3940msgstr "Progresso"
3941
3942msgctxt "#90795"
3943msgid "Speed"
3944msgstr "Velocidade"
3945
3946msgctxt "#90796"
3947msgid "ETA"
3948msgstr "Tempo restante"
3949
3950msgctxt "#90797"
3951msgid "Size"
3952msgstr "Tamanho"
3953
3954msgctxt "#90798"
3955msgid "Pause"
3956msgstr "Pausar"
3957
3958msgctxt "#90799"
3959msgid "Resume"
3960msgstr "Retomar"
3961
3962msgctxt "#90800"
3963msgid "Cancel"
3964msgstr "Cancelar"
3965
3966msgctxt "#90801"
3967msgid "Delete"
3968msgstr "Excluir"
3969
3970msgctxt "#90802"
3971msgid "Clear Completed"
3972msgstr "Limpar concluídos"
3973
3974msgctxt "#90803"
3975msgid "Download not yet completed"
3976msgstr "Download ainda não concluído"
3977
3978msgctxt "#90804"
3979msgid "File not found"
3980msgstr "Arquivo não encontrado"
3981
3982msgctxt "#90805"
3983msgid "Download Manager"
3984msgstr "Gerenciador de downloads"
3985
3986msgctxt "#90806"
3987msgid "My Downloads"
3988msgstr "Meus downloads"
3989
3990msgctxt "#90807"
3991msgid "Connecting..."
3992msgstr "Conectando..."
3993
3994msgctxt "#90808"
3995msgid "Close"
3996msgstr "Fechar"
3997
3998msgctxt "#90809"
3999msgid "Delete download?"
4000msgstr "Excluir download?"
4001
4002msgctxt "#90810"
4003msgid "This will permanently delete the downloaded file. Are you sure?"
4004msgstr "Isso excluirá permanentemente o arquivo baixado. Tem certeza?"
4005
4006msgctxt "#90811"
4007msgid "Completed"
4008msgstr "Concluído"
4009
4010msgctxt "#90812"
4011msgid "Excluded TMDB Languages"
4012msgstr "Idiomas excluídos do TMDB"
4013
4014msgctxt "#90813"
4015msgid "Select languages to exclude"
4016msgstr "Selecione os idiomas para excluir"
4017
4018msgctxt "#90814"
4019msgid "Choose original languages to hide from TMDB listings"
4020msgstr "Escolha os idiomas originais para ocultar das listagens do TMDB"
4021
4022msgctxt "#90815"
4023msgid "Support Jacktook"
4024msgstr "Apoiar o Jacktook"
4025
4026msgctxt "#90816"
4027msgid "Support Jacktook development with an optional Ko-fi donation."
4028msgstr "Apoie o desenvolvimento do Jacktook com uma doação opcional no Ko-fi."
4029
4030msgctxt "#90817"
4031msgid "Scan the QR code or open this link:"
4032msgstr "Escaneie o código QR ou abra este link:"
4033
4034msgctxt "#90818"
4035msgid "Jacktook was updated with new improvements. Donations are optional, but they help keep development active. Would you like to see the support link?"
4036msgstr "O Jacktook foi atualizado com novas melhorias. As doações são opcionais, mas ajudam a manter o desenvolvimento ativo. Quer ver o link de apoio?"
4037
4038msgctxt "#90300"
4039msgid "External Scraper"
4040msgstr "Scraper Externo"
4041
4042msgctxt "#90301"
4043msgid "Enable external scraper module integration."
4044msgstr "Ativar a integração do módulo scraper externo."
4045
4046msgctxt "#90302"
4047msgid "Choose External Scraper Module"
4048msgstr "Escolher Módulo Scraper Externo"
4049
4050msgctxt "#90303"
4051msgid "Select an external scraper addon module to use for torrent search."
4052msgstr "Selecione um módulo addon scraper externo para pesquisar torrents."
4053
4054msgctxt "#90304"
4055msgid "Choose External Scraper"
4056msgstr "Escolher Scraper Externo"
4057
4058msgctxt "#90305"
4059msgid "Selected Module"
4060msgstr "Módulo Selecionado"
4061
4062msgctxt "#90306"
4063msgid "Open External Scraper Settings"
4064msgstr "Abrir Configurações do Scraper Externo"
4065
4066msgctxt "#90307"
4067msgid "Open the settings for the selected external scraper module."
4068msgstr "Abrir as configurações do módulo scraper externo selecionado."
4069
4070msgctxt "#90382"
4071msgid "Please set a valid Trakt Client ID Key"
4072msgstr "Defina uma chave de ID de cliente do Trakt válida"
4073
4074msgctxt "#90383"
4075msgid "Please set a valid Trakt Client Secret Key"
4076msgstr "Defina uma chave secreta de cliente do Trakt válida"
4077
4078msgctxt "#90384"
4079msgid "Trakt Error Authorizing"
4080msgstr "Erro ao autorizar o Trakt"
4081
4082msgctxt "#90385"
4083msgid "Trakt Account Authorized"
4084msgstr "Conta do Trakt autorizada"
4085
4086msgctxt "#90386"
4087msgid "You are now logged out from Trakt.tv"
4088msgstr "Você saiu do Trakt.tv"
4089
4090msgctxt "#90387"
4091msgid "Failed to mark as watched"
4092msgstr "Falha ao marcar como assistido"
4093
4094msgctxt "#90388"
4095msgid "Marked as watched"
4096msgstr "Marcado como assistido"
4097
4098msgctxt "#90389"
4099msgid "No results found"
4100msgstr "Nenhum resultado encontrado"
4101
4102msgctxt "#90390"
4103msgid "Invalid mode"
4104msgstr "Modo inválido"
4105
4106msgctxt "#90391"
4107msgid "No recommendations found"
4108msgstr "Nenhuma recomendação encontrada"
4109
4110msgctxt "#90392"
4111msgid "No similar items found"
4112msgstr "Nenhum item semelhante encontrado"
4113
4114msgctxt "#90393"
4115msgid "TMDB metadata not found"
4116msgstr "Metadados do TMDB não encontrados"
4117
4118msgctxt "#90394"
4119msgid "No TV shows found"
4120msgstr "Nenhuma série encontrada"
4121
4122msgctxt "#90395"
4123msgid "No shows airing today"
4124msgstr "Nenhuma série sendo exibida hoje"
4125
4126msgctxt "#90396"
4127msgid "Invalid collection query"
4128msgstr "Consulta de coleção inválida"
4129
4130msgctxt "#90397"
4131msgid "No keywords found"
4132msgstr "Nenhuma palavra-chave encontrada"
4133
4134msgctxt "#90398"
4135msgid "No keyword ID provided"
4136msgstr "Nenhum ID de palavra-chave fornecido"
4137
4138msgctxt "#90399"
4139msgid "No results found for this keyword"
4140msgstr "Nenhum resultado encontrado para esta palavra-chave"
4141
4142msgctxt "#90400"
4143msgid "No TMDB ID found"
4144msgstr "Nenhum ID do TMDB encontrado"
4145
4146msgctxt "#90401"
4147msgid "Unsupported media type or missing external_ids"
4148msgstr "Tipo de mídia não suportado ou external_ids ausentes"
4149
4150msgctxt "#90402"
4151msgid "Refreshed Metadata"
4152msgstr "Metadados atualizados"
4153
4154msgctxt "#90403"
4155msgid "No Stremio addons selected"
4156msgstr "Nenhum addon do Stremio selecionado"
4157
4158msgctxt "#90404"
4159msgid "No suitable source found for auto play."
4160msgstr "Nenhuma fonte adequada encontrada para reprodução automática."
4161
4162msgctxt "#90405"
4163msgid "No sources found with the preferred quality."
4164msgstr "Nenhuma fonte encontrada com a qualidade preferida."
4165
4166msgctxt "#90406"
4167msgid "No languages found"
4168msgstr "Nenhum idioma encontrado"
4169
4170msgctxt "#90407"
4171msgid "Failed to resolve playback source"
4172msgstr "Falha ao resolver a fonte de reprodução"
4173
4174msgctxt "#90408"
4175msgid "Trakt not authorized. Please authenticate."
4176msgstr "Trakt não autorizado. Faça a autenticação."
4177
4178msgctxt "#90409"
4179msgid "Added to Trakt Watchlist"
4180msgstr "Adicionado à lista de acompanhamento do Trakt"
4181
4182msgctxt "#90428"
4183msgid "Database read error. File may be corrupted."
4184msgstr "Erro ao ler o banco de dados. O arquivo pode estar corrompido."
4185
4186msgctxt "#90429"
4187msgid "Failed to delete data. Database error."
4188msgstr "Falha ao excluir dados. Erro no banco de dados."
4189
4190msgctxt "#90430"
4191msgid "Failed to clear cache. Database in use."
4192msgstr "Falha ao limpar o cache. Banco de dados em uso."
4193
4194msgctxt "#90458"
4195msgid "OpenSubtitles Stremio: No results found."
4196msgstr "OpenSubtitles Stremio: nenhum resultado encontrado."
4197
4198msgctxt "#90460"
4199msgid "Subtitle Manager: No subtitles available for this video."
4200msgstr "Gerenciador de legendas: não há legendas disponíveis para este vídeo."
4201
4202msgctxt "#90468"
4203msgid "Search menu"
4204msgstr "Menu de pesquisa"
4205
4206msgctxt "#90469"
4207msgid "Continue watching menu"
4208msgstr "Menu Continuar assistindo"
4209
4210msgctxt "#90470"
4211msgid "TV Shows Items"
4212msgstr "Itens de séries"
4213
4214msgctxt "#90471"
4215msgid "Movies Items"
4216msgstr "Itens de filmes"
4217
4218msgctxt "#90472"
4219msgid "Anime menu"
4220msgstr "Menu de anime"
4221
4222msgctxt "#90473"
4223msgid "TV Menu"
4224msgstr "Menu de TV"
4225
4226msgctxt "#90474"
4227msgid "Library menu"
4228msgstr "Menu da biblioteca"
4229
4230msgctxt "#90475"
4231msgid "Torrents Management"
4232msgstr "Gerenciamento de torrents"
4233
4234msgctxt "#90476"
4235msgid "Telegram menu"
4236msgstr "Menu do Telegram"
4237
4238msgctxt "#90477"
4239msgid "Cloud Storage"
4240msgstr "Armazenamento na nuvem"
4241
4242msgctxt "#90478"
4243msgid "Downloads Management"
4244msgstr "Gerenciamento de downloads"
4245
4246msgctxt "#90479"
4247msgid "Settings Management"
4248msgstr "Gerenciamento de configurações"
4249
4250msgctxt "#90480"
4251msgid "History Menu"
4252msgstr "Menu de histórico"
4253
4254msgctxt "#90481"
4255msgid "Files History"
4256msgstr "Histórico de arquivos"
4257
4258msgctxt "#90482"
4259msgid "Titles History"
4260msgstr "Histórico de títulos"
4261
4262msgctxt "#90483"
4263msgid "Titles Calendar"
4264msgstr "Calendário de títulos"
4265
4266msgctxt "#90484"
4267msgid "My Shows"
4268msgstr "Minhas séries"
4269
4270msgctxt "#90485"
4271msgid "My Movies"
4272msgstr "Meus filmes"
4273
4274msgctxt "#90486"
4275msgid "Upcoming Episodes"
4276msgstr "Próximos episódios"
4277
4278msgctxt "#90487"
4279msgid "Discovery List"
4280msgstr "Lista de descoberta"
4281
4282msgctxt "#90488"
4283msgid "Library List"
4284msgstr "Lista da biblioteca"
4285
4286msgctxt "#90489"
4287msgid "Trakt Recommendations"
4288msgstr "Recomendações do Trakt"
4289
4290msgctxt "#90490"
4291msgid "Trending Lists"
4292msgstr "Listas em alta"
4293
4294msgctxt "#90491"
4295msgid "Popular Lists"
4296msgstr "Listas populares"
4297
4298msgctxt "#90492"
4299msgid "Search Lists"
4300msgstr "Pesquisar listas"
4301
4302msgctxt "#90493"
4303msgid "Recent Activity"
4304msgstr "Atividade recente"
4305
4306msgctxt "#90494"
4307msgid "Downloads List"
4308msgstr "Lista de downloads"
4309
4310msgctxt "#90495"
4311msgid "Download New"
4312msgstr "Novo download"
4313
4314msgctxt "#90496"
4315msgid "Cartoons Popular"
4316msgstr "Desenhos animados populares"
4317
4318msgctxt "#90497"
4319msgid "Animation Popular"
4320msgstr "Animação popular"
4321
4322msgctxt "#90500"
4323msgid "Translating subtitle..."
4324msgstr "Traduzindo legenda..."
4325
4326msgctxt "#90501"
4327msgid "Translation done."
4328msgstr "Tradução concluída."
4329
4330msgctxt "#90502"
4331msgid "Searching subtitles..."
4332msgstr "Pesquisando legendas..."
4333
4334msgctxt "#90503"
4335msgid "No subtitles found"
4336msgstr "Nenhuma legenda encontrada"
4337
4338msgctxt "#90504"
4339msgid "Failed to authenticate with DeepL. Please check your API key."
4340msgstr "Falha ao autenticar com o DeepL. Verifique sua chave de API."
4341
4342msgctxt "#90505"
4343msgid "DeepL API key is missing or invalid."
4344msgstr "A chave de API do DeepL está ausente ou é inválida."
4345
4346msgctxt "#90712"
4347msgid "Views"
4348msgstr "Visualizações"
4349
4350msgctxt "#90713"
4351msgid "Set Main View"
4352msgstr "Definir visualização principal"
4353
4354msgctxt "#90714"
4355msgid "Set Movies View"
4356msgstr "Definir visualização de filmes"
4357
4358msgctxt "#90715"
4359msgid "Set TV Shows View"
4360msgstr "Definir visualização de séries"
4361
4362msgctxt "#90716"
4363msgid "Set Seasons View"
4364msgstr "Definir visualização de temporadas"
4365
4366msgctxt "#90717"
4367msgid "Set Episodes View"
4368msgstr "Definir visualização de episódios"
4369
4370msgctxt "#90718"
4371msgid "Set Library View"
4372msgstr "Definir visualização da biblioteca"
4373
4374msgctxt "#90719"
4375msgid "Set History View"
4376msgstr "Definir visualização do histórico"
4377
4378msgctxt "#90720"
4379msgid "Set Downloads View"
4380msgstr "Definir visualização de downloads"
4381
4382msgctxt "#90721"
4383msgid "Save Current View"
4384msgstr "Salvar visualização atual"
4385
4386msgctxt "#90722"
4387msgid "View saved"
4388msgstr "Visualização salva"
4389
4390msgctxt "#90723"
4391msgid "Could not detect the current view"
4392msgstr "Não foi possível detectar a visualização atual"
4393
4394msgctxt "#90724"
4395msgid "Saved views reset"
4396msgstr "Visualizações salvas redefinidas"
4397
4398msgctxt "#90725"
4399msgid "Reset Saved Views"
4400msgstr "Redefinir visualizações salvas"
4401
4402msgctxt "#90726"
4403msgid "Open this screen, change the Kodi view manually, then select Save Current View."
4404msgstr "Abra esta tela, altere manualmente a visualização do Kodi e depois selecione Salvar visualização atual."
4405
4406msgctxt "#90730"
4407msgid "Search with title + year"
4408msgstr "Pesquisar com título + ano"
4409
4410msgctxt "#90731"
4411msgid "Search with original title"
4412msgstr "Pesquisar com título original"
4413
4414msgctxt "#90732"
4415msgid "Search with original title + year"
4416msgstr "Pesquisar com título original + ano"
4417
4418msgctxt "#90733"
4419msgid "Search modes"
4420msgstr "Modos de pesquisa"
4421
4422msgctxt "#90734"
4423msgid "Enter year"
4424msgstr "Digite o ano"
4425
4426msgctxt "#90735"
4427msgid "Invalid year"
4428msgstr "Ano inválido"
4429
4430msgctxt "#90736"
4431msgid "Edit title"
4432msgstr "Editar título"
4433
4434msgctxt "#90737"
4435msgid "Invalid season"
4436msgstr "Temporada inválida"
4437
4438msgctxt "#90738"
4439msgid "Episode"
4440msgstr "Episódio"
4441
4442msgctxt "#90739"
4443msgid "Invalid episode"
4444msgstr "Episódio inválido"
4445
4446msgctxt "#90819"
4447msgid "Rename Stremio add-on"
4448msgstr "Renomear complemento do Stremio"
4449
4450msgctxt "#90824"
4451msgid "Rename Stremio catalog"
4452msgstr "Renomear catálogo do Stremio"
4453
4454msgctxt "#90827"
4455msgid "Set a Kodi-only display alias for a Stremio add-on."
4456msgstr "Definir um alias de exibição apenas no Kodi para um complemento do Stremio."
4457
4458msgctxt "#90828"
4459msgid "Set a Kodi-only display alias for a Stremio catalog."
4460msgstr "Definir um alias de exibição apenas no Kodi para um catálogo do Stremio."
4461
4462msgctxt "#90832"
4463msgid "Stremio: Addons Management"
4464msgstr "Stremio: Gerenciamento de complementos"
4465
4466msgctxt "#90833"
4467msgid "Stremio: Catalogs & Visibility"
4468msgstr "Stremio: Catálogos e visibilidade"
4469
4470msgctxt "#90834"
4471msgid "Stremio: Rename & Customization"
4472msgstr "Stremio: Renomeação e personalização"
4473
4474msgctxt "#90835"
4475msgid "Stremio: Search & Behavior"
4476msgstr "Stremio: Pesquisa e comportamento"
plugin.video.jacktook/resources/language/Portuguese/strings.po +866 -62
……@@ -312,8 +312,8 @@ msgid "Unknown"
312312msgstr "Desconhecido"
313313
314314msgctxt "#30700"
315msgid "Buffer and play"
316msgstr "Bufferizar e reproduzir"
315msgid "Play Torrent"
316msgstr "Reproduzir torrent"
317317
318318msgctxt "#30701"
319319msgid "Stop downloading"
……@@ -431,6 +431,58 @@ msgctxt "#30939"
431431msgid "Auto-resume from last position without asking"
432432msgstr "Retomar automaticamente da última posição sem perguntar"
433433
434msgctxt "#30940"
435msgid "Use Percentage Trigger"
436msgstr "Usar acionamento por percentagem"
437
438msgctxt "#30941"
439msgid "Show Dialog at Playback Percentage"
440msgstr "Mostrar diálogo na percentagem de reprodução"
441
442msgctxt "#30942"
443msgid "Trigger the next-episode dialog based on percentage of video played instead of seconds remaining."
444msgstr "Acionar o diálogo do próximo episódio com base na percentagem do vídeo reproduzida em vez dos segundos restantes."
445
446msgctxt "#30943"
447msgid "The percentage at which the next-episode dialog appears (e.g. 95 means 95% of the video)."
448msgstr "A percentagem em que o diálogo do próximo episódio aparece (por exemplo, 95 significa 95% do vídeo)."
449
450msgctxt "#30944"
451msgid "Auto-Play Timeout"
452msgstr "Tempo limite da reprodução automática"
453
454msgctxt "#30945"
455msgid "Seconds before auto-playing next episode. 0 = disabled, dialog waits for user input."
456msgstr "Segundos antes de reproduzir automaticamente o próximo episódio. 0 = desativado, o diálogo aguarda a entrada do utilizador."
457
458
459msgctxt "#30946"
460msgid "Offcloud Configuration"
461msgstr "Configuração do Offcloud"
462
463msgctxt "#30947"
464msgid "Enable Offcloud cached torrent and cloud transfer support."
465msgstr "Ativar suporte a torrents em cache e transferências em nuvem do Offcloud."
466
467msgctxt "#30948"
468msgid "Your Offcloud account username/email."
469msgstr "O teu nome de utilizador ou email do Offcloud."
470
471msgctxt "#30949"
472msgid "Offcloud API key from Account API Key settings."
473msgstr "Chave API do Offcloud nas definições da Conta → Chave API."
474
475msgctxt "#30950"
476msgid "View Offcloud account information."
477msgstr "Ver informações da conta Offcloud."
478
479msgctxt "#30951"
480msgid "Authorize add-on to access Offcloud account."
481msgstr "Autorizar o addon a aceder à conta Offcloud."
482
483msgctxt "#30952"
484msgid "Revoke Offcloud account access."
485msgstr "Revogar o acesso da conta Offcloud."
434486msgctxt "#30728"
435487msgid "Seeds"
436488msgstr "Sementes"
……@@ -459,6 +511,10 @@ msgctxt "#30734"
459511msgid "Enable"
460512msgstr "Ativar"
461513
514msgctxt "#30735"
515msgid "Still Watching? Threshold"
516msgstr "Limite de Ainda a ver?"
517
462518msgctxt "#30736"
463519msgid "Subtitle Language"
464520msgstr "Idioma das legendas"
……@@ -607,6 +663,10 @@ msgctxt "#30772"
607663msgid "Zilean Host"
608664msgstr "Host Zilean"
609665
666msgctxt "#30773"
667msgid "Number of consecutive auto-played episodes before asking 'Still Watching?'. Set to 0 to disable."
668msgstr "Número de episódios reproduzidos automaticamente em sequência antes de perguntar 'Ainda a ver?'. Defina como 0 para desativar."
669
610670msgctxt "#30774"
611671msgid "Zilean Timeout"
612672msgstr "Zilean Tempo limite"
……@@ -1556,8 +1616,8 @@ msgid "TV Calendar"
15561616msgstr "Calendário de TV"
15571617
15581618msgctxt "#90022"
1559msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:"
1560msgstr "Se gosta de usar o Jacktook e quer apoiar o seu desenvolvimento, pode pagar-me um café! O seu apoio ajuda a manter o projeto ativo e permite futuras melhorias:"
1619msgid "Jacktook is free and built with care in spare time. If you enjoy the addon, consider supporting its development. Donations are optional and help keep updates coming."
1620msgstr "O Jacktook é gratuito e feito com cuidado no tempo livre. Se gosta do addon, considere apoiar o seu desenvolvimento. As doações são opcionais e ajudam a manter as atualizações."
15611621
15621622msgctxt "#90023"
15631623msgid "Support Jacktook"
……@@ -1933,15 +1993,15 @@ msgstr "Palavra-passe"
19331993
19341994msgctxt "#90129"
19351995msgid "Test Connection"
1936msgstr ""
1996msgstr "Testar ligação"
19371997
19381998msgctxt "#90131"
19391999msgid "Webdav"
1940msgstr ""
2000msgstr "WebDAV"
19412001
19422002msgctxt "#90132"
19432003msgid "Remote Path"
1944msgstr ""
2004msgstr "Caminho remoto"
19452005
19462006msgctxt "#90133"
19472007msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)."
……@@ -1949,11 +2009,11 @@ msgstr "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.
19492009
19502010msgctxt "#90134"
19512011msgid "The username required for authenticating with your WebDAV server"
1952msgstr ""
2012msgstr "O nome de utilizador necessário para autenticação no seu servidor WebDAV"
19532013
19542014msgctxt "#90135"
19552015msgid "The password for the specified username to authenticate with your WebDAV server"
1956msgstr ""
2016msgstr "A palavra-passe do nome de utilizador especificado para autenticação no seu servidor WebDAV"
19572017
19582018msgctxt "#90136"
19592019msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)."
……@@ -1961,11 +2021,11 @@ msgstr "The base path or directory on the WebDAV server where your media files a
19612021
19622022msgctxt "#90137"
19632023msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials."
1964msgstr ""
2024msgstr "Clique para verificar se o cliente WebDAV consegue ligar-se com sucesso ao servidor configurado usando as credenciais fornecidas."
19652025
19662026msgctxt "#90138"
19672027msgid "Remove Custom Addon"
1968msgstr ""
2028msgstr "Remover addon personalizado"
19692029
19702030msgctxt "#90139"
19712031msgid "Playback"
……@@ -1973,43 +2033,59 @@ msgstr "Reprodução"
19732033
19742034msgctxt "#90140"
19752035msgid "Super quick play"
1976msgstr ""
2036msgstr "Reprodução super rápida"
19772037
19782038msgctxt "#90141"
19792039msgid "Re-ask to play last played video"
1980msgstr ""
2040msgstr "Perguntar novamente para reproduzir o último vídeo reproduzido"
19812041
19822042msgctxt "#90142"
19832043msgid "Replay Dialog"
1984msgstr ""
2044msgstr "Diálogo de repetição"
19852045
19862046msgctxt "#90143"
19872047msgid "Do you want to play last watched?"
1988msgstr ""
2048msgstr "Deseja reproduzir o último visto?"
19892049
19902050msgctxt "#90144"
19912051msgid "Could not resolve playback URL"
1992msgstr ""
2052msgstr "Não foi possível resolver o URL de reprodução"
19932053
1994msgctxt "#90145"
1995msgid "Precaching Enabled"
1996msgstr "Pré-carregamento Ativado"
2054msgctxt "#90171"
2055msgid "Autoscrape Next Episode"
2056msgstr "Auto-scrape do próximo episódio"
19972057
1998msgctxt "#90146"
1999msgid "Enable or disable precaching of next episodes."
2000msgstr "Ativar ou desativar o pré-carregamento dos próximos episódios."
2058msgctxt "#90172"
2059msgid "Pre-resolve the next episode's playback URL in the background while watching."
2060msgstr "Pré-resolver em segundo plano o URL de reprodução do próximo episódio enquanto assiste."
20012061
2002msgctxt "#90147"
2003msgid "Precaching Episode Count"
2004msgstr "Contagem de Episódios para Pré-carregar"
2062msgctxt "#90173"
2063msgid "Autoscrape Threshold"
2064msgstr "Limiar do auto-scrape"
20052065
2006msgctxt "#90148"
2007msgid "Number of next episodes to precache."
2008msgstr "Número de próximos episódios para pré-carregar."
2066msgctxt "#90174"
2067msgid "Percentage of playback to trigger background pre-resolution."
2068msgstr "Percentagem da reprodução para acionar a pré-resolução em segundo plano."
2069
2070msgctxt "#90175"
2071msgid "Autoscrape Episode Count"
2072msgstr "Número de episódios do auto-scrape"
2073
2074msgctxt "#90176"
2075msgid "Number of upcoming episodes to pre-resolve."
2076msgstr "Número de próximos episódios a pré-resolver."
2077
2078msgctxt "#90177"
2079msgid "Autoscrape Cache TTL"
2080msgstr "TTL da cache do auto-scrape"
2081
2082msgctxt "#90178"
2083msgid "Hours to keep resolved playback data in cache."
2084msgstr "Horas para manter em cache os dados de reprodução resolvidos."
20092085
20102086msgctxt "#30901"
20112087msgid "Show Only Stremio Catalogs"
2012msgstr ""
2088msgstr "Mostrar apenas catálogos do Stremio"
20132089
20142090msgctxt "#30902"
20152091msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus."
……@@ -2073,19 +2149,19 @@ msgstr "Filtrar por Qualidade"
20732149
20742150msgctxt "#32301"
20752151msgid "Filter by Provider"
2076msgstr ""
2152msgstr "Filtrar por fornecedor"
20772153
20782154msgctxt "#32302"
20792155msgid "Filter by Source"
2080msgstr ""
2156msgstr "Filtrar por fonte"
20812157
20822158msgctxt "#32303"
20832159msgid "Filter by Language"
2084msgstr ""
2160msgstr "Filtrar por idioma"
20852161
20862162msgctxt "#32304"
20872163msgid "Filter by Torrents"
2088msgstr ""
2164msgstr "Filtrar por torrents"
20892165
20902166msgctxt "#32305"
20912167msgid "Filter by Debrid"
……@@ -2093,7 +2169,7 @@ msgstr "Filtrar por Debrid"
20932169
20942170msgctxt "#32306"
20952171msgid "Reset Filter"
2096msgstr ""
2172msgstr "Repor filtro"
20972173
20982174msgctxt "#30930"
20992175msgid "Ping Addons"
……@@ -2189,7 +2265,7 @@ msgstr "Ignorar a ordenação nativa dos extras do Stremio"
21892265
21902266msgctxt "#90212"
21912267msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds."
2192msgstr ""
2268msgstr "Se ativado, os addons especificados não terão os seus resultados reordenados nem filtrados pelos limites nativos de qualidade e tamanho do Jacktook."
21932269
21942270msgctxt "#90213"
21952271msgid "Select Bypassed Addons"
……@@ -2197,7 +2273,7 @@ msgstr "Selecionar extras a ignorar"
21972273
21982274msgctxt "#90214"
21992275msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering."
2200msgstr ""
2276msgstr "Selecione quais addons do Stremio ignoram a filtragem nativa de tamanho e qualidade do Jacktook."
22012277
22022278msgctxt "#90215"
22032279msgid "Downgrade"
……@@ -2209,27 +2285,27 @@ msgstr "Instala uma versao publicada anterior do Jacktook. A versao atual e salv
22092285
22102286msgctxt "#30932"
22112287msgid "Update Action"
2212msgstr ""
2288msgstr "Ação de atualização"
22132289
22142290msgctxt "#30933"
22152291msgid "Choose how to handle background update checks."
2216msgstr ""
2292msgstr "Escolha como tratar as verificações de atualização em segundo plano."
22172293
22182294msgctxt "#30934"
22192295msgid "Ask"
2220msgstr ""
2296msgstr "Perguntar"
22212297
22222298msgctxt "#30935"
22232299msgid "Notify"
2224msgstr ""
2300msgstr "Notificar"
22252301
22262302msgctxt "#90216"
22272303msgid "Volume Check Enabled"
2228msgstr ""
2304msgstr "Verificação de volume ativada"
22292305
22302306msgctxt "#90217"
22312307msgid "Automatically limits volume if it is above the threshold before playback."
2232msgstr ""
2308msgstr "Limita automaticamente o volume se estiver acima do limite antes da reprodução."
22332309
22342310msgctxt "#90218"
22352311msgid "Volume Threshold"
……@@ -2237,7 +2313,7 @@ msgstr "Limite de volume"
22372313
22382314msgctxt "#90219"
22392315msgid "The volume percentage above which the warning triggers."
2240msgstr ""
2316msgstr "A percentagem de volume acima da qual o aviso é acionado."
22412317
22422318msgctxt "#90220"
22432319msgid "High Volume Warning"
……@@ -2261,11 +2337,11 @@ msgstr "Tempo limite do pedido em segundos."
22612337
22622338msgctxt "#90225"
22632339msgid "Debrid Notifications"
2264msgstr ""
2340msgstr "Notificações Debrid"
22652341
22662342msgctxt "#90226"
22672343msgid "Debrid Expiration Notification"
2268msgstr ""
2344msgstr "Notificação de expiração do Debrid"
22692345
22702346msgctxt "#90227"
22712347msgid "Enable or disable notifications when your Debrid subscription is about to expire."
……@@ -2277,15 +2353,15 @@ msgstr "Expiration Threshold (Days)"
22772353
22782354msgctxt "#90229"
22792355msgid "How many days before expiration to start notifying."
2280msgstr ""
2356msgstr "Quantos dias antes da expiração começar a notificar."
22812357
22822358msgctxt "#90230"
22832359msgid "%s expires in %d days."
2284msgstr ""
2360msgstr "%s expira em %d dias."
22852361
22862362msgctxt "#90231"
22872363msgid "%s expires today!"
2288msgstr ""
2364msgstr "%s expira hoje!"
22892365
22902366msgctxt "#90232"
22912367msgid "Easynews"
……@@ -2293,19 +2369,19 @@ msgstr "Easynews"
22932369
22942370msgctxt "#90233"
22952371msgid "Enable EasyNews as a direct stream provider."
2296msgstr ""
2372msgstr "Ativar o EasyNews como fornecedor de stream direto."
22972373
22982374msgctxt "#90234"
22992375msgid "Your EasyNews username."
2300msgstr ""
2376msgstr "O seu nome de utilizador do EasyNews."
23012377
23022378msgctxt "#90235"
23032379msgid "Your EasyNews password."
2304msgstr ""
2380msgstr "A sua palavra-passe do EasyNews."
23052381
23062382msgctxt "#90236"
23072383msgid "Request timeout in seconds."
2308msgstr ""
2384msgstr "Tempo limite do pedido em segundos."
23092385
23102386msgctxt "#90237"
23112387msgid "Account Information"
……@@ -2313,15 +2389,15 @@ msgstr "Informações da conta"
23132389
23142390msgctxt "#90238"
23152391msgid "Search Dialog Style"
2316msgstr ""
2392msgstr "Estilo do diálogo de pesquisa"
23172393
23182394msgctxt "#90239"
23192395msgid "Background"
2320msgstr ""
2396msgstr "Fundo"
23212397
23222398msgctxt "#90240"
23232399msgid "Detailed Status"
2324msgstr ""
2400msgstr "Estado detalhado"
23252401
23262402msgctxt "#90241"
23272403msgid "Search Status"
……@@ -2357,7 +2433,7 @@ msgstr "Cancelado"
23572433
23582434msgctxt "#90249"
23592435msgid "Your personal Jackgram API token for authentication."
2360msgstr ""
2436msgstr "O seu token pessoal da API do Jackgram para autenticação."
23612437
23622438msgctxt "#90250"
23632439msgid "Subtitles Found"
……@@ -2555,6 +2631,10 @@ msgctxt "#90298"
25552631msgid "Liked Lists"
25562632msgstr "Listas com Gostos"
25572633
2634msgctxt "#90299"
2635msgid "No metadata found for this torrent"
2636msgstr "Nenhum metadado encontrado para este torrent"
2637
25582638msgctxt "#90331"
25592639msgid "Stremio Addons"
25602640msgstr "Addons do Stremio"
……@@ -2641,27 +2721,27 @@ msgstr "Nao foi possivel transferir a copia de seguranca."
26412721
26422722msgctxt "#90352"
26432723msgid "Source Select Style"
2644msgstr ""
2724msgstr "Estilo da seleção de fontes"
26452725
26462726msgctxt "#90353"
26472727msgid "Modern"
2648msgstr ""
2728msgstr "Moderno"
26492729
26502730msgctxt "#90354"
26512731msgid "OLED"
2652msgstr ""
2732msgstr "OLED"
26532733
26542734msgctxt "#90355"
26552735msgid "Cyberpunk"
2656msgstr ""
2736msgstr "Cyberpunk"
26572737
26582738msgctxt "#90356"
26592739msgid "Forest"
2660msgstr ""
2740msgstr "Floresta"
26612741
26622742msgctxt "#90357"
26632743msgid "Gold"
2664msgstr ""
2744msgstr "Dourado"
26652745
26662746msgctxt "#90358"
26672747msgid "No playable results found"
……@@ -3652,6 +3732,18 @@ msgctxt "#90749"
36523732msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file"
36533733msgstr "Digitalize o código QR ou abra o URL (%s) no seu navegador para carregar um ficheiro de legendas"
36543734
3735msgctxt "#90740"
3736msgid "Search with current title"
3737msgstr "Pesquisar com o título atual"
3738
3739msgctxt "#90741"
3740msgid "Search with original title"
3741msgstr "Pesquisar com o título original"
3742
3743msgctxt "#90773"
3744msgid "Search full show with title + year"
3745msgstr "Pesquisar série completa com título + ano"
3746
36553747msgctxt "#90750"
36563748msgid "Search Title Language"
36573749msgstr "Idioma do título de pesquisa"
……@@ -3671,3 +3763,715 @@ msgstr "Apenas inglês"
36713763msgctxt "#90754"
36723764msgid "Localized first"
36733765msgstr "Localizado primeiro"
3766
3767msgctxt "#90755"
3768msgid "Manage Sources"
3769msgstr "Gerenciar Fontes"
3770
3771msgctxt "#90756"
3772msgid "No sources available"
3773msgstr "Nenhuma fonte disponível"
3774
3775msgctxt "#90179"
3776msgid "Stinger Notifications"
3777msgstr "Notificações de stinger"
3778
3779msgctxt "#90180"
3780msgid "Show a notification when a movie has post-credit scenes."
3781msgstr "Mostrar uma notificação quando um filme tiver cenas pós-créditos."
3782
3783msgctxt "#90181"
3784msgid "Stinger Notification Time"
3785msgstr "Momento da notificação de stinger"
3786
3787msgctxt "#90182"
3788msgid "Seconds before the end to show the stinger notification."
3789msgstr "Segundos antes do fim para mostrar a notificação de stinger."
3790
3791msgctxt "#90183"
3792msgid "Stay tuned after the credits!"
3793msgstr "Fique atento após os créditos!"
3794
3795msgctxt "#90757"
3796msgid "Image Resolution"
3797msgstr "Resolução da Imagem"
3798
3799msgctxt "#90758"
3800msgid "Low"
3801msgstr "Baixa"
3802
3803msgctxt "#90759"
3804msgid "Medium"
3805msgstr "Média"
3806
3807msgctxt "#90760"
3808msgid "High"
3809msgstr "Alta"
3810
3811msgctxt "#90761"
3812msgid "Original"
3813msgstr "Original"
3814
3815msgctxt "#90762"
3816msgid "Enable RPDB Posters"
3817msgstr "Ativar Posters RPDB"
3818
3819msgctxt "#90763"
3820msgid "RPDB API Key"
3821msgstr "Chave API RPDB"
3822
3823msgctxt "#90764"
3824msgid "Get your API key at ratingposterdb.com"
3825msgstr "Obtenha a sua chave API em ratingposterdb.com"
3826
3827msgctxt "#90765"
3828msgid "Filter by Codecs"
3829msgstr "Filtrar por codecs"
3830
3831msgctxt "#90766"
3832msgid "Filter by HDR"
3833msgstr "Filtrar por HDR"
3834
3835msgctxt "#90767"
3836msgid "Include HEVC / H.265"
3837msgstr "Incluir HEVC / H.265"
3838
3839msgctxt "#90768"
3840msgid "Include AV1"
3841msgstr "Incluir AV1"
3842
3843msgctxt "#90769"
3844msgid "Include H.264"
3845msgstr "Incluir H.264"
3846
3847msgctxt "#90770"
3848msgid "Include Dolby Vision"
3849msgstr "Incluir Dolby Vision"
3850
3851msgctxt "#90771"
3852msgid "Include HDR10 / HDR10+"
3853msgstr "Incluir HDR10 / HDR10+"
3854
3855msgctxt "#90772"
3856msgid "Include HDR (generic)"
3857msgstr "Incluir HDR (genérico)"
3858
3859msgctxt "#90774"
3860msgid "Organize downloads by type"
3861msgstr "Organizar transferências por tipo"
3862
3863msgctxt "#90775"
3864msgid "Movies folder name"
3865msgstr "Nome da pasta de filmes"
3866
3867msgctxt "#90776"
3868msgid "TV Shows folder name"
3869msgstr "Nome da pasta de séries"
3870
3871msgctxt "#90777"
3872msgid "Pause"
3873msgstr "Pausar"
3874
3875msgctxt "#90778"
3876msgid "Resume"
3877msgstr "Retomar"
3878
3879msgctxt "#90779"
3880msgid "Downloading"
3881msgstr "A transferir"
3882
3883msgctxt "#90780"
3884msgid "Paused"
3885msgstr "Em pausa"
3886
3887msgctxt "#90781"
3888msgid "Completed"
3889msgstr "Concluído"
3890
3891msgctxt "#90782"
3892msgid "Delete"
3893msgstr "Eliminar"
3894
3895msgctxt "#90783"
3896msgid "Startup"
3897msgstr "Arranque"
3898
3899msgctxt "#90784"
3900msgid "Auto-start Jacktook on Kodi boot"
3901msgstr "Iniciar Jacktook automaticamente ao arrancar o Kodi"
3902
3903msgctxt "#90785"
3904msgid "Automatically open Jacktook when Kodi starts"
3905msgstr "Abrir Jacktook automaticamente quando o Kodi inicia"
3906
3907msgctxt "#90786"
3908msgid "Widgets"
3909msgstr "Widgets"
3910
3911msgctxt "#90787"
3912msgid "Widget refresh interval (minutes)"
3913msgstr "Intervalo de atualização de widgets (minutos)"
3914
3915msgctxt "#90788"
3916msgid "Set to 0 to disable automatic widget refresh"
3917msgstr "Definir como 0 para desativar a atualização automática"
3918
3919msgctxt "#90789"
3920msgid "Show refresh notification"
3921msgstr "Mostrar notificação de atualização"
3922
3923msgctxt "#90790"
3924msgid "Display a notification when widgets are refreshed"
3925msgstr "Mostrar uma notificação quando os widgets são atualizados"
3926
3927msgctxt "#90791"
3928msgid "Download to Device"
3929msgstr "Transferir para o dispositivo"
3930
3931msgctxt "#90792"
3932msgid "Download Manager"
3933msgstr "Gestor de transferências"
3934
3935msgctxt "#90793"
3936msgid "No active downloads"
3937msgstr "Sem transferências ativas"
3938
3939msgctxt "#90794"
3940msgid "Progress"
3941msgstr "Progresso"
3942
3943msgctxt "#90795"
3944msgid "Speed"
3945msgstr "Velocidade"
3946
3947msgctxt "#90796"
3948msgid "ETA"
3949msgstr "Tempo restante"
3950
3951msgctxt "#90797"
3952msgid "Size"
3953msgstr "Tamanho"
3954
3955msgctxt "#90798"
3956msgid "Pause"
3957msgstr "Pausa"
3958
3959msgctxt "#90799"
3960msgid "Resume"
3961msgstr "Retomar"
3962
3963msgctxt "#90800"
3964msgid "Cancel"
3965msgstr "Cancelar"
3966
3967msgctxt "#90801"
3968msgid "Delete"
3969msgstr "Eliminar"
3970
3971msgctxt "#90802"
3972msgid "Clear Completed"
3973msgstr "Limpar concluídas"
3974
3975msgctxt "#90803"
3976msgid "Download not yet completed"
3977msgstr "Transferência ainda não concluída"
3978
3979msgctxt "#90804"
3980msgid "File not found"
3981msgstr "Ficheiro não encontrado"
3982
3983msgctxt "#90805"
3984msgid "Download Manager"
3985msgstr "Gestor de transferências"
3986
3987msgctxt "#90806"
3988msgid "My Downloads"
3989msgstr "Minhas transferências"
3990
3991msgctxt "#90807"
3992msgid "Connecting..."
3993msgstr "A conectar..."
3994
3995msgctxt "#90808"
3996msgid "Close"
3997msgstr "Fechar"
3998
3999msgctxt "#90809"
4000msgid "Delete download?"
4001msgstr "Eliminar transferência?"
4002
4003msgctxt "#90810"
4004msgid "This will permanently delete the downloaded file. Are you sure?"
4005msgstr "Isto eliminará permanentemente o ficheiro transferido. Tem a certeza?"
4006
4007msgctxt "#90811"
4008msgid "Completed"
4009msgstr "Concluída"
4010
4011msgctxt "#90812"
4012msgid "Excluded TMDB Languages"
4013msgstr "Idiomas excluídos do TMDB"
4014
4015msgctxt "#90813"
4016msgid "Select languages to exclude"
4017msgstr "Seleciona os idiomas a excluir"
4018
4019msgctxt "#90814"
4020msgid "Choose original languages to hide from TMDB listings"
4021msgstr "Escolhe os idiomas originais a ocultar das listagens do TMDB"
4022
4023msgctxt "#90815"
4024msgid "Support Jacktook"
4025msgstr "Apoiar o Jacktook"
4026
4027msgctxt "#90816"
4028msgid "Support Jacktook development with an optional Ko-fi donation."
4029msgstr "Apoie o desenvolvimento do Jacktook com uma doação opcional no Ko-fi."
4030
4031msgctxt "#90817"
4032msgid "Scan the QR code or open this link:"
4033msgstr "Digitalize o código QR ou abra este link:"
4034
4035msgctxt "#90818"
4036msgid "Jacktook was updated with new improvements. Donations are optional, but they help keep development active. Would you like to see the support link?"
4037msgstr "O Jacktook foi atualizado com novas melhorias. As doações são opcionais, mas ajudam a manter o desenvolvimento ativo. Quer ver o link de apoio?"
4038
4039msgctxt "#90300"
4040msgid "External Scraper"
4041msgstr "Scraper Externo"
4042
4043msgctxt "#90301"
4044msgid "Enable external scraper module integration."
4045msgstr "Ativar a integração do módulo scraper externo."
4046
4047msgctxt "#90302"
4048msgid "Choose External Scraper Module"
4049msgstr "Escolher Módulo Scraper Externo"
4050
4051msgctxt "#90303"
4052msgid "Select an external scraper addon module to use for torrent search."
4053msgstr "Selecione um módulo addon scraper externo para pesquisar torrents."
4054
4055msgctxt "#90304"
4056msgid "Choose External Scraper"
4057msgstr "Escolher Scraper Externo"
4058
4059msgctxt "#90305"
4060msgid "Selected Module"
4061msgstr "Módulo Selecionado"
4062
4063msgctxt "#90306"
4064msgid "Open External Scraper Settings"
4065msgstr "Abrir Definições do Scraper Externo"
4066
4067msgctxt "#90307"
4068msgid "Open the settings for the selected external scraper module."
4069msgstr "Abrir as definições do módulo scraper externo selecionado."
4070
4071msgctxt "#90382"
4072msgid "Please set a valid Trakt Client ID Key"
4073msgstr "Defina uma chave de ID de cliente do Trakt válida"
4074
4075msgctxt "#90383"
4076msgid "Please set a valid Trakt Client Secret Key"
4077msgstr "Defina uma chave secreta de cliente do Trakt válida"
4078
4079msgctxt "#90384"
4080msgid "Trakt Error Authorizing"
4081msgstr "Erro ao autorizar o Trakt"
4082
4083msgctxt "#90385"
4084msgid "Trakt Account Authorized"
4085msgstr "Conta do Trakt autorizada"
4086
4087msgctxt "#90386"
4088msgid "You are now logged out from Trakt.tv"
4089msgstr "Terminou sessão no Trakt.tv"
4090
4091msgctxt "#90387"
4092msgid "Failed to mark as watched"
4093msgstr "Falha ao marcar como visto"
4094
4095msgctxt "#90388"
4096msgid "Marked as watched"
4097msgstr "Marcado como visto"
4098
4099msgctxt "#90389"
4100msgid "No results found"
4101msgstr "Nenhum resultado encontrado"
4102
4103msgctxt "#90390"
4104msgid "Invalid mode"
4105msgstr "Modo inválido"
4106
4107msgctxt "#90391"
4108msgid "No recommendations found"
4109msgstr "Nenhuma recomendação encontrada"
4110
4111msgctxt "#90392"
4112msgid "No similar items found"
4113msgstr "Nenhum item semelhante encontrado"
4114
4115msgctxt "#90393"
4116msgid "TMDB metadata not found"
4117msgstr "Metadados do TMDB não encontrados"
4118
4119msgctxt "#90394"
4120msgid "No TV shows found"
4121msgstr "Nenhuma série encontrada"
4122
4123msgctxt "#90395"
4124msgid "No shows airing today"
4125msgstr "Nenhuma série em exibição hoje"
4126
4127msgctxt "#90396"
4128msgid "Invalid collection query"
4129msgstr "Consulta de coleção inválida"
4130
4131msgctxt "#90397"
4132msgid "No keywords found"
4133msgstr "Nenhuma palavra-chave encontrada"
4134
4135msgctxt "#90398"
4136msgid "No keyword ID provided"
4137msgstr "Nenhum ID de palavra-chave fornecido"
4138
4139msgctxt "#90399"
4140msgid "No results found for this keyword"
4141msgstr "Nenhum resultado encontrado para esta palavra-chave"
4142
4143msgctxt "#90400"
4144msgid "No TMDB ID found"
4145msgstr "Nenhum ID do TMDB encontrado"
4146
4147msgctxt "#90401"
4148msgid "Unsupported media type or missing external_ids"
4149msgstr "Tipo de media não suportado ou external_ids em falta"
4150
4151msgctxt "#90402"
4152msgid "Refreshed Metadata"
4153msgstr "Metadados atualizados"
4154
4155msgctxt "#90403"
4156msgid "No Stremio addons selected"
4157msgstr "Nenhum addon do Stremio selecionado"
4158
4159msgctxt "#90404"
4160msgid "No suitable source found for auto play."
4161msgstr "Nenhuma fonte adequada encontrada para reprodução automática."
4162
4163msgctxt "#90405"
4164msgid "No sources found with the preferred quality."
4165msgstr "Nenhuma fonte encontrada com a qualidade preferida."
4166
4167msgctxt "#90406"
4168msgid "No languages found"
4169msgstr "Nenhum idioma encontrado"
4170
4171msgctxt "#90407"
4172msgid "Failed to resolve playback source"
4173msgstr "Falha ao resolver a fonte de reprodução"
4174
4175msgctxt "#90408"
4176msgid "Trakt not authorized. Please authenticate."
4177msgstr "Trakt não autorizado. Autentique-se."
4178
4179msgctxt "#90409"
4180msgid "Added to Trakt Watchlist"
4181msgstr "Adicionado à lista de acompanhamento do Trakt"
4182
4183msgctxt "#90428"
4184msgid "Database read error. File may be corrupted."
4185msgstr "Erro ao ler a base de dados. O ficheiro pode estar corrompido."
4186
4187msgctxt "#90429"
4188msgid "Failed to delete data. Database error."
4189msgstr "Falha ao eliminar dados. Erro da base de dados."
4190
4191msgctxt "#90430"
4192msgid "Failed to clear cache. Database in use."
4193msgstr "Falha ao limpar a cache. Base de dados em uso."
4194
4195msgctxt "#90458"
4196msgid "OpenSubtitles Stremio: No results found."
4197msgstr "OpenSubtitles Stremio: nenhum resultado encontrado."
4198
4199msgctxt "#90460"
4200msgid "Subtitle Manager: No subtitles available for this video."
4201msgstr "Gestor de legendas: não há legendas disponíveis para este vídeo."
4202
4203msgctxt "#90468"
4204msgid "Search menu"
4205msgstr "Menu de pesquisa"
4206
4207msgctxt "#90469"
4208msgid "Continue watching menu"
4209msgstr "Menu Continuar a ver"
4210
4211msgctxt "#90470"
4212msgid "TV Shows Items"
4213msgstr "Itens de séries"
4214
4215msgctxt "#90471"
4216msgid "Movies Items"
4217msgstr "Itens de filmes"
4218
4219msgctxt "#90472"
4220msgid "Anime menu"
4221msgstr "Menu de anime"
4222
4223msgctxt "#90473"
4224msgid "TV Menu"
4225msgstr "Menu de TV"
4226
4227msgctxt "#90474"
4228msgid "Library menu"
4229msgstr "Menu da biblioteca"
4230
4231msgctxt "#90475"
4232msgid "Torrents Management"
4233msgstr "Gestão de torrents"
4234
4235msgctxt "#90476"
4236msgid "Telegram menu"
4237msgstr "Menu do Telegram"
4238
4239msgctxt "#90477"
4240msgid "Cloud Storage"
4241msgstr "Armazenamento na nuvem"
4242
4243msgctxt "#90478"
4244msgid "Downloads Management"
4245msgstr "Gestão de transferências"
4246
4247msgctxt "#90479"
4248msgid "Settings Management"
4249msgstr "Gestão de definições"
4250
4251msgctxt "#90480"
4252msgid "History Menu"
4253msgstr "Menu de histórico"
4254
4255msgctxt "#90481"
4256msgid "Files History"
4257msgstr "Histórico de ficheiros"
4258
4259msgctxt "#90482"
4260msgid "Titles History"
4261msgstr "Histórico de títulos"
4262
4263msgctxt "#90483"
4264msgid "Titles Calendar"
4265msgstr "Calendário de títulos"
4266
4267msgctxt "#90484"
4268msgid "My Shows"
4269msgstr "As minhas séries"
4270
4271msgctxt "#90485"
4272msgid "My Movies"
4273msgstr "Os meus filmes"
4274
4275msgctxt "#90486"
4276msgid "Upcoming Episodes"
4277msgstr "Próximos episódios"
4278
4279msgctxt "#90487"
4280msgid "Discovery List"
4281msgstr "Lista de descoberta"
4282
4283msgctxt "#90488"
4284msgid "Library List"
4285msgstr "Lista da biblioteca"
4286
4287msgctxt "#90489"
4288msgid "Trakt Recommendations"
4289msgstr "Recomendações do Trakt"
4290
4291msgctxt "#90490"
4292msgid "Trending Lists"
4293msgstr "Listas em tendência"
4294
4295msgctxt "#90491"
4296msgid "Popular Lists"
4297msgstr "Listas populares"
4298
4299msgctxt "#90492"
4300msgid "Search Lists"
4301msgstr "Pesquisar listas"
4302
4303msgctxt "#90493"
4304msgid "Recent Activity"
4305msgstr "Atividade recente"
4306
4307msgctxt "#90494"
4308msgid "Downloads List"
4309msgstr "Lista de transferências"
4310
4311msgctxt "#90495"
4312msgid "Download New"
4313msgstr "Nova transferência"
4314
4315msgctxt "#90496"
4316msgid "Cartoons Popular"
4317msgstr "Desenhos animados populares"
4318
4319msgctxt "#90497"
4320msgid "Animation Popular"
4321msgstr "Animação popular"
4322
4323msgctxt "#90500"
4324msgid "Translating subtitle..."
4325msgstr "A traduzir legenda..."
4326
4327msgctxt "#90501"
4328msgid "Translation done."
4329msgstr "Tradução concluída."
4330
4331msgctxt "#90502"
4332msgid "Searching subtitles..."
4333msgstr "A pesquisar legendas..."
4334
4335msgctxt "#90503"
4336msgid "No subtitles found"
4337msgstr "Nenhuma legenda encontrada"
4338
4339msgctxt "#90504"
4340msgid "Failed to authenticate with DeepL. Please check your API key."
4341msgstr "Falha ao autenticar com o DeepL. Verifique a sua chave API."
4342
4343msgctxt "#90505"
4344msgid "DeepL API key is missing or invalid."
4345msgstr "A chave API do DeepL está em falta ou é inválida."
4346
4347msgctxt "#90712"
4348msgid "Views"
4349msgstr "Vistas"
4350
4351msgctxt "#90713"
4352msgid "Set Main View"
4353msgstr "Definir vista principal"
4354
4355msgctxt "#90714"
4356msgid "Set Movies View"
4357msgstr "Definir vista de filmes"
4358
4359msgctxt "#90715"
4360msgid "Set TV Shows View"
4361msgstr "Definir vista de séries"
4362
4363msgctxt "#90716"
4364msgid "Set Seasons View"
4365msgstr "Definir vista de temporadas"
4366
4367msgctxt "#90717"
4368msgid "Set Episodes View"
4369msgstr "Definir vista de episódios"
4370
4371msgctxt "#90718"
4372msgid "Set Library View"
4373msgstr "Definir vista da biblioteca"
4374
4375msgctxt "#90719"
4376msgid "Set History View"
4377msgstr "Definir vista do histórico"
4378
4379msgctxt "#90720"
4380msgid "Set Downloads View"
4381msgstr "Definir vista de transferências"
4382
4383msgctxt "#90721"
4384msgid "Save Current View"
4385msgstr "Guardar vista atual"
4386
4387msgctxt "#90722"
4388msgid "View saved"
4389msgstr "Vista guardada"
4390
4391msgctxt "#90723"
4392msgid "Could not detect the current view"
4393msgstr "Não foi possível detetar a vista atual"
4394
4395msgctxt "#90724"
4396msgid "Saved views reset"
4397msgstr "Vistas guardadas repostas"
4398
4399msgctxt "#90725"
4400msgid "Reset Saved Views"
4401msgstr "Repor vistas guardadas"
4402
4403msgctxt "#90726"
4404msgid "Open this screen, change the Kodi view manually, then select Save Current View."
4405msgstr "Abra este ecrã, altere manualmente a vista do Kodi e depois selecione Guardar vista atual."
4406
4407msgctxt "#90730"
4408msgid "Search with title + year"
4409msgstr "Pesquisar com título + ano"
4410
4411msgctxt "#90731"
4412msgid "Search with original title"
4413msgstr "Pesquisar com título original"
4414
4415msgctxt "#90732"
4416msgid "Search with original title + year"
4417msgstr "Pesquisar com título original + ano"
4418
4419msgctxt "#90733"
4420msgid "Search modes"
4421msgstr "Modos de pesquisa"
4422
4423msgctxt "#90734"
4424msgid "Enter year"
4425msgstr "Introduza o ano"
4426
4427msgctxt "#90735"
4428msgid "Invalid year"
4429msgstr "Ano inválido"
4430
4431msgctxt "#90736"
4432msgid "Edit title"
4433msgstr "Editar título"
4434
4435msgctxt "#90737"
4436msgid "Invalid season"
4437msgstr "Temporada inválida"
4438
4439msgctxt "#90738"
4440msgid "Episode"
4441msgstr "Episódio"
4442
4443msgctxt "#90739"
4444msgid "Invalid episode"
4445msgstr "Episódio inválido"
4446
4447msgctxt "#90819"
4448msgid "Rename Stremio add-on"
4449msgstr "Renomear add-on do Stremio"
4450
4451msgctxt "#90824"
4452msgid "Rename Stremio catalog"
4453msgstr "Renomear catálogo do Stremio"
4454
4455msgctxt "#90827"
4456msgid "Set a Kodi-only display alias for a Stremio add-on."
4457msgstr "Definir um alias de apresentação apenas no Kodi para um add-on do Stremio."
4458
4459msgctxt "#90828"
4460msgid "Set a Kodi-only display alias for a Stremio catalog."
4461msgstr "Definir um alias de apresentação apenas no Kodi para um catálogo do Stremio."
4462
4463msgctxt "#90832"
4464msgid "Stremio: Addons Management"
4465msgstr "Stremio: Gestão de complementos"
4466
4467msgctxt "#90833"
4468msgid "Stremio: Catalogs & Visibility"
4469msgstr "Stremio: Catálogos e visibilidade"
4470
4471msgctxt "#90834"
4472msgid "Stremio: Rename & Customization"
4473msgstr "Stremio: Renomear e personalização"
4474
4475msgctxt "#90835"
4476msgid "Stremio: Search & Behavior"
4477msgstr "Stremio: Pesquisa e comportamento"
plugin.video.jacktook/resources/language/Romanian/strings.po +4488 -0
……@@ -0,0 +1,4488 @@
1# Kodi Media Center language file
2# Addon Name: Jacktook
3# Addon id: plugin.video.jacktook
4# Addon Provider: Sam-Max
5#
6msgid ""
7msgstr ""
8"Report-Msgid-Bugs-To: \n"
9"POT-Creation-Date: 2023-09-19 19:53+0200\n"
10"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
11"Language: ro\n"
12"MIME-Version: 1.0\n"
13"Content-Type: text/plain; charset=UTF-8\n"
14"Content-Transfer-Encoding: 8bit\n"
15"Plural-Forms: nplurals=2; plural=(n != 1);\n"
16
17# Settings
18msgctxt "#30000"
19msgid "Players"
20msgstr "Playere"
21
22msgctxt "#30001"
23msgid "Clients"
24msgstr "Clienți"
25
26msgctxt "#30002"
27msgid "Torrent Client"
28msgstr "Client torrent"
29
30msgctxt "#30020"
31msgid "Indexers"
32msgstr "Indexatori"
33
34msgctxt "#30021"
35msgid "Indexer"
36msgstr "Indexator"
37
38msgctxt "#30022"
39msgid "Jackett Configuration:"
40msgstr "Configurează Jackett:"
41
42msgctxt "#30023"
43msgid "Client Key"
44msgstr "Cheie client"
45
46msgctxt "#30025"
47msgid "Host"
48msgstr "Host"
49
50msgctxt "#30026"
51msgid "Description length"
52msgstr "Lungime descriere"
53
54msgctxt "#30027"
55msgid "Insecured connection"
56msgstr "Conexiune nesecurizată"
57
58msgctxt "#30028"
59msgid "Sort by"
60msgstr "Sortează după"
61
62msgctxt "#30029"
63msgid "Total Results"
64msgstr "Rezultate totale"
65
66msgctxt "#30030"
67msgid "Timeout"
68msgstr "Timp de expirare"
69
70msgctxt "#30031"
71msgid "Timeout"
72msgstr "Timp de expirare"
73
74msgctxt "#30050"
75msgid "Prowlarr Configuration:"
76msgstr "Configurează Prowlarr:"
77
78msgctxt "#30051"
79msgid "Client Key"
80msgstr "Cheie client"
81
82msgctxt "#30052"
83msgid "Host"
84msgstr "Host"
85
86msgctxt "#30053"
87msgid "Indexers-Ids"
88msgstr "ID-uri indexatori"
89
90msgctxt "#30054"
91msgid "Anime IndexersIds"
92msgstr "ID-uri indexatori anime"
93
94msgctxt "#30100"
95msgid "TMDB"
96msgstr "TMDB"
97
98msgctxt "#30102"
99msgid "TMDB Configuration"
100msgstr "Configurează TMDB"
101
102msgctxt "#30103"
103msgid "Client Key"
104msgstr "Cheie client"
105
106msgctxt "#30104"
107msgid "Clear tmdb cache"
108msgstr "Șterge cache TMDB"
109
110msgctxt "#30105"
111msgid "Cache expiration hours"
112msgstr "Expirare cache (ore)"
113
114msgctxt "#30106"
115msgid "Clear Cache"
116msgstr "Șterge cache"
117
118msgctxt "#30107"
119msgid "Enable Cache"
120msgstr "Activează cache"
121
122msgctxt "#30140"
123msgid "AniList"
124msgstr "AniList"
125
126msgctxt "#30142"
127msgid "AniList Configuration:"
128msgstr "Configurează AniList:"
129
130msgctxt "#30143"
131msgid "Client ID"
132msgstr "ID client"
133
134msgctxt "#30144"
135msgid "Client Secret"
136msgstr "Secret client"
137
138msgctxt "#30150"
139msgid "Services"
140msgstr "Servicii"
141
142msgctxt "#30152"
143msgid "Real Debrid Configuration"
144msgstr "Configurează Real Debrid"
145
146msgctxt "#30154"
147msgid "Token"
148msgstr "Token"
149
150msgctxt "#30200"
151msgid "Fanart.TV"
152msgstr "Fanart.TV"
153
154msgctxt "#30202"
155msgid "Fanart.TV Configuration:"
156msgstr "Configurează Fanart.TV:"
157
158msgctxt "#30203"
159msgid "Client ID"
160msgstr "ID client"
161
162msgctxt "#30221"
163msgid "No Client Key provided for"
164msgstr "Nu ai furnizat cheia client pentru"
165
166msgctxt "#30223"
167msgid "You need to configure"
168msgstr "Trebuie să configurezi"
169
170msgctxt "#30228"
171msgid "The request to Torrentio failed."
172msgstr "Cererea către Torrentio a eșuat."
173
174msgctxt "#30229"
175msgid "The request to Jackett failed."
176msgstr "Cererea către Jackett a eșuat."
177
178msgctxt "#30230"
179msgid "The request to Prowlarr failed."
180msgstr "Cererea către Prowlarr a eșuat."
181
182msgctxt "#30231"
183msgid "The request to Elhosted failed."
184msgstr "Cererea către Elhosted a eșuat."
185
186msgctxt "#30232"
187msgid "The request to Jackgram failed."
188msgstr "Cererea către Jackgram a eșuat."
189
190msgctxt "#30233"
191msgid "The request to Mediafusion failed."
192msgstr "Cererea către Mediafusion a eșuat."
193
194msgctxt "#30234"
195msgid "The request to Peerflix failed."
196msgstr "Cererea către Peerflix a eșuat."
197
198msgctxt "#30235"
199msgid "Stremio"
200msgstr "Stremio"
201
202msgctxt "#30236"
203msgid "Stremio add-ons"
204msgstr "Add-on-uri Stremio"
205
206msgctxt "#30237"
207msgid "Import your config"
208msgstr "Importă configurația"
209
210msgctxt "#30238"
211msgid "Manage add-ons"
212msgstr "Gestionează add-on-urile"
213
214msgctxt "#30240"
215msgid "TMDB cache cleared"
216msgstr "Cache TMDB șters"
217
218msgctxt "#30241"
219msgid "Search on TMDB:"
220msgstr "Caută pe TMDB:"
221
222msgctxt "#30242"
223msgid "Search on AniList:"
224msgstr "Caută pe AniList:"
225
226msgctxt "#30243"
227msgid "Search for a keyword:"
228msgstr "Caută după cuvânt cheie:"
229
230msgctxt "#30244"
231msgid "Cache cleared"
232msgstr "Cache șters"
233
234msgctxt "#30249"
235msgid "Addon Jacktook Burst not found - (script.jacktook.burst)"
236msgstr "Nu s-a găsit add-on-ul Jacktook Burst - (script.jacktook.burst)"
237
238msgctxt "#30250"
239msgid "Addon Torrest not found - (plugin.video.torrest)"
240msgstr "Nu s-a găsit add-on-ul Torrest - (plugin.video.torrest)"
241
242msgctxt "#30251"
243msgid "No sources found to play"
244msgstr "Nu s-au găsit surse pentru redare"
245
246msgctxt "#30252"
247msgid "Addon Elementum not found - (plugin.video.elementum)"
248msgstr "Nu s-a găsit add-on-ul Elementum - (plugin.video.elementum)"
249
250msgctxt "#30253"
251msgid "Addon Jacktorr not found - (plugin.video.jacktorr)"
252msgstr "Nu s-a găsit add-on-ul Jacktorr - (plugin.video.jacktorr)"
253
254msgctxt "#30254"
255msgid "Do you want to install Elementum?"
256msgstr "Vrei să instalezi Elementum?"
257
258msgctxt "#30255"
259msgid "Do you want to install Jacktorr?"
260msgstr "Vrei să instalezi Jacktorr?"
261
262msgctxt "#30256"
263msgid "Do you want to install Torrest?"
264msgstr "Vrei să instalezi Torrest?"
265
266msgctxt "#30257"
267msgid "Do you want to install Jacktook Burst?"
268msgstr "Vrei să instalezi Jacktook Burst?"
269
270msgctxt "#30650"
271msgid "Queued"
272msgstr "În așteptare"
273
274msgctxt "#30651"
275msgid "Checking"
276msgstr "Se verifică"
277
278msgctxt "#30652"
279msgid "Finding"
280msgstr "Se caută"
281
282msgctxt "#30653"
283msgid "Downloading"
284msgstr "Se descarcă"
285
286msgctxt "#30654"
287msgid "Finished"
288msgstr "Finalizat"
289
290msgctxt "#30655"
291msgid "Seeding"
292msgstr "Se distribuie"
293
294msgctxt "#30656"
295msgid "Allocating"
296msgstr "Se alocă"
297
298msgctxt "#30657"
299msgid "Checking resume data"
300msgstr "Se verifică datele de reluare"
301
302msgctxt "#30658"
303msgid "Paused"
304msgstr "În pauză"
305
306msgctxt "#30659"
307msgid "Buffering"
308msgstr "Se încarcă"
309
310msgctxt "#30660"
311msgid "Unknown"
312msgstr "Necunoscut"
313
314msgctxt "#30700"
315msgid "Play Torrent"
316msgstr "Redă torrent"
317
318msgctxt "#30701"
319msgid "Stop downloading"
320msgstr "Oprește descărcarea"
321
322msgctxt "#30702"
323msgid "Downloads"
324msgstr "Descărcări"
325
326msgctxt "#30703"
327msgid "Resume"
328msgstr "Reia"
329
330msgctxt "#30704"
331msgid "Pause"
332msgstr "Pauză"
333
334msgctxt "#30705"
335msgid "Remove torrent"
336msgstr "Șterge torrentul"
337
338msgctxt "#30706"
339msgid "Remove torrent and files"
340msgstr "Șterge torrentul și fișierele"
341
342msgctxt "#30707"
343msgid "Torrent status"
344msgstr "Stare torrent"
345
346msgctxt "#30708"
347msgid "Stop"
348msgstr "Oprește"
349
350msgctxt "#30709"
351msgid "Drop torrent"
352msgstr "Renunță la torrent"
353
354msgctxt "#30710"
355msgid "Include Season Packs"
356msgstr "Include seturi de sezoane"
357
358msgctxt "#30711"
359msgid "Torrent Settings"
360msgstr "Setări torrent"
361
362msgctxt "#30712"
363msgid "General"
364msgstr "General"
365
366msgctxt "#30713"
367msgid "Enable"
368msgstr "Activează"
369
370msgctxt "#30714"
371msgid "Jacktorr"
372msgstr "Jacktorr"
373
374msgctxt "#30715"
375msgid "Torrest"
376msgstr "Torrest"
377
378msgctxt "#30716"
379msgid "Elementum"
380msgstr "Elementum"
381
382msgctxt "#30717"
383msgid "General"
384msgstr "General"
385
386msgctxt "#30718"
387msgid "Auto-Play"
388msgstr "Redare automată"
389
390msgctxt "#30719"
391msgid "Enable Next Episode Dialog"
392msgstr "Activează dialogul pentru episodul următor"
393
394msgctxt "#30720"
395msgid "Seconds left before show Next Episode Dialog"
396msgstr "Secunde rămase înainte de afișarea dialogului episodului următor"
397
398msgctxt "#30721"
399msgid "Cache Enabled"
400msgstr "Cache activat"
401
402msgctxt "#30722"
403msgid "Cache Expiration (hours)"
404msgstr "Expirare cache (ore)"
405
406msgctxt "#30723"
407msgid "Cache"
408msgstr "Cache"
409
410msgctxt "#30724"
411msgid "Update"
412msgstr "Actualizează"
413
414msgctxt "#30725"
415msgid "Indexers Configuration"
416msgstr "Configurare indexatori"
417
418msgctxt "#30726"
419msgid "Sort"
420msgstr "Sortează"
421
422msgctxt "#30727"
423msgid "Quality"
424msgstr "Calitate"
425
426msgctxt "#30938"
427msgid "Silent Resume"
428msgstr "Reluare silențioasă"
429
430msgctxt "#30939"
431msgid "Auto-resume from last position without asking"
432msgstr "Reia automat de la ultima poziție fără confirmare"
433
434msgctxt "#30940"
435msgid "Use Percentage Trigger"
436msgstr "Folosește declanșare procentuală"
437
438msgctxt "#30941"
439msgid "Show Dialog at Playback Percentage"
440msgstr "Afișează dialogul la procentul de redare"
441
442msgctxt "#30942"
443msgid "Trigger the next-episode dialog based on percentage of video played instead of seconds remaining."
444msgstr "Declanșează dialogul episodului următor pe baza procentului redat din video, în locul secundelor rămase."
445
446msgctxt "#30943"
447msgid "The percentage at which the next-episode dialog appears (e.g. 95 means 95% of the video)."
448msgstr "Procentul la care apare dialogul episodului următor (de ex. 95 înseamnă 95% din video)."
449
450msgctxt "#30944"
451msgid "Auto-Play Timeout"
452msgstr "Timeout redare automată"
453
454msgctxt "#30945"
455msgid "Seconds before auto-playing next episode. 0 = disabled, dialog waits for user input."
456msgstr "Secunde înainte de redarea automată a episodului următor. 0 = dezactivat, dialogul așteaptă intervenția utilizatorului."
457
458
459msgctxt "#30946"
460msgid "Offcloud Configuration"
461msgstr "Configurare Offcloud"
462
463msgctxt "#30947"
464msgid "Enable Offcloud cached torrent and cloud transfer support."
465msgstr "Activează suportul pentru torrente cache și transferuri în cloud Offcloud."
466
467msgctxt "#30948"
468msgid "Your Offcloud account username/email."
469msgstr "Numele de utilizator sau emailul tău Offcloud."
470
471msgctxt "#30949"
472msgid "Offcloud API key from Account API Key settings."
473msgstr "Cheia API Offcloud din setările Contului → Cheie API."
474
475msgctxt "#30950"
476msgid "View Offcloud account information."
477msgstr "Vezi informațiile contului Offcloud."
478
479msgctxt "#30951"
480msgid "Authorize add-on to access Offcloud account."
481msgstr "Autorizează addon-ul să acceseze contul Offcloud."
482
483msgctxt "#30952"
484msgid "Revoke Offcloud account access."
485msgstr "Revocă accesul contului Offcloud."
486msgctxt "#30728"
487msgid "Seeds"
488msgstr "Seederi"
489
490msgctxt "#30729"
491msgid "Size"
492msgstr "Mărime"
493
494msgctxt "#30730"
495msgid "Cached"
496msgstr "În cache"
497
498msgctxt "#30731"
499msgid "Date"
500msgstr "Dată"
501
502msgctxt "#30732"
503msgid "None"
504msgstr "Niciunul"
505
506msgctxt "#30733"
507msgid "Torrentio Configuration"
508msgstr "Configurează Torrentio"
509
510msgctxt "#30734"
511msgid "Enable"
512msgstr "Activează"
513
514msgctxt "#30735"
515msgid "Still Watching? Threshold"
516msgstr "Prag Încă vizionezi?"
517
518msgctxt "#30736"
519msgid "Subtitle Language"
520msgstr "Limbă subtitrări"
521
522msgctxt "#30737"
523msgid "GB"
524msgstr "GB"
525
526msgctxt "#30738"
527msgid "JP"
528msgstr "JP"
529
530msgctxt "#30739"
531msgid "RU"
532msgstr "RU"
533
534msgctxt "#30740"
535msgid "IT"
536msgstr "IT"
537
538msgctxt "#30741"
539msgid "PT"
540msgstr "PT"
541
542msgctxt "#30742"
543msgid "ES"
544msgstr "ES"
545
546msgctxt "#30743"
547msgid "MX"
548msgstr "MX"
549
550msgctxt "#30744"
551msgid "KR"
552msgstr "KR"
553
554msgctxt "#30745"
555msgid "CN"
556msgstr "CN"
557
558msgctxt "#30746"
559msgid "TW"
560msgstr "TW"
561
562msgctxt "#30747"
563msgid "FR"
564msgstr "FR"
565
566msgctxt "#30748"
567msgid "DE"
568msgstr "DE"
569
570msgctxt "#30749"
571msgid "NL"
572msgstr "NL"
573
574msgctxt "#30750"
575msgid "IN"
576msgstr "IN"
577
578msgctxt "#30751"
579msgid "PL"
580msgstr "PL"
581
582msgctxt "#30752"
583msgid "LT"
584msgstr "LT"
585
586msgctxt "#30753"
587msgid "CZ"
588msgstr "CZ"
589
590msgctxt "#30754"
591msgid "SK"
592msgstr "SK"
593
594msgctxt "#30755"
595msgid "SI"
596msgstr "SI"
597
598msgctxt "#30756"
599msgid "HU"
600msgstr "HU"
601
602msgctxt "#30757"
603msgid "RO"
604msgstr "RO"
605
606msgctxt "#30758"
607msgid "HR"
608msgstr "HR"
609
610msgctxt "#30759"
611msgid "UA"
612msgstr "UA"
613
614msgctxt "#30760"
615msgid "GR"
616msgstr "GR"
617
618msgctxt "#30761"
619msgid "DK"
620msgstr "DK"
621
622msgctxt "#30762"
623msgid "FI"
624msgstr "FI"
625
626msgctxt "#30763"
627msgid "SE"
628msgstr "SE"
629
630msgctxt "#30764"
631msgid "NO"
632msgstr "NO"
633
634msgctxt "#30765"
635msgid "TR"
636msgstr "TR"
637
638msgctxt "#30766"
639msgid "Torrentio Providers"
640msgstr "Provideri Torrentio"
641
642msgctxt "#30767"
643msgid "Jackgram Configuration"
644msgstr "Configurează Jackgram"
645
646msgctxt "#30768"
647msgid "Host"
648msgstr "Host"
649
650msgctxt "#30769"
651msgid "Elfhosted/KnightCrawler Configuration"
652msgstr "Configurează Elfhosted/KnightCrawler"
653
654msgctxt "#30770"
655msgid "Language"
656msgstr "Limbă"
657
658msgctxt "#30771"
659msgid "Zilean Configuration"
660msgstr "Configurează Zilean"
661
662msgctxt "#30772"
663msgid "Zilean Host"
664msgstr "Host Zilean"
665
666msgctxt "#30773"
667msgid "Number of consecutive auto-played episodes before asking 'Still Watching?'. Set to 0 to disable."
668msgstr "Numărul de episoade redate automat consecutiv înainte de întrebarea 'Încă vizionezi?'. Setează la 0 pentru dezactivare."
669
670msgctxt "#30774"
671msgid "Zilean Timeout"
672msgstr "Timp de expirare Zilean"
673
674msgctxt "#30775"
675msgid "Jackett Configuration"
676msgstr "Configurează Jackett"
677
678msgctxt "#30776"
679msgid "Prowlarr Configuration"
680msgstr "Configurează Prowlarr"
681
682msgctxt "#30777"
683msgid "MediaFusion configuration"
684msgstr "Configurează MediaFusion"
685
686msgctxt "#30779"
687msgid "Manifest Url"
688msgstr "URL manifest"
689
690msgctxt "#30780"
691msgid "Jacktook Burst Configuration"
692msgstr "Configurează Jacktook Burst"
693
694msgctxt "#30781"
695msgid "Burst Configuration"
696msgstr "Configurează Burst"
697
698msgctxt "#30782"
699msgid "User"
700msgstr "Utilizator"
701
702msgctxt "#30783"
703msgid "Authorize"
704msgstr "Autorizează"
705
706msgctxt "#30784"
707msgid "Remove Authorization"
708msgstr "Elimină autorizarea"
709
710msgctxt "#30785"
711msgid "Premiumize Configuration"
712msgstr "Configurează Premiumize"
713
714msgctxt "#30786"
715msgid "Enabled"
716msgstr "Activat"
717
718msgctxt "#30787"
719msgid "Token"
720msgstr "Token"
721
722msgctxt "#30788"
723msgid "TorBox Configuration"
724msgstr "Configurează TorBox"
725
726msgctxt "#30789"
727msgid "Debrider Configuration"
728msgstr "Configurează Debrider"
729
730msgctxt "#30790"
731msgid "Uncached"
732msgstr "Fără cache"
733
734msgctxt "#30791"
735msgid "Show uncached torrents"
736msgstr "Afișează torrente fără cache"
737
738msgctxt "#30792"
739msgid "Use Kodi Language"
740msgstr "Folosește limba Kodi"
741
742msgctxt "#30793"
743msgid "Trakt"
744msgstr "Trakt"
745
746msgctxt "#30794"
747msgid "Trakt Configuration"
748msgstr "Configurează Trakt"
749
750msgctxt "#30795"
751msgid "Expires"
752msgstr "Expiră"
753
754msgctxt "#30796"
755msgid "Refresh"
756msgstr "Reîmprospătează"
757
758msgctxt "#30797"
759msgid "Token"
760msgstr "Token"
761
762msgctxt "#30798"
763msgid "Trakt Client"
764msgstr "Client Trakt"
765
766msgctxt "#30800"
767msgid "Trakt Secret"
768msgstr "Secret Trakt"
769
770msgctxt "#30802"
771msgid "Log out"
772msgstr "Deconectează-te"
773
774msgctxt "#30804"
775msgid "Menus"
776msgstr "Meniuri"
777
778msgctxt "#30805"
779msgid "Show Telegram Menu"
780msgstr "Afișează meniul Telegram"
781
782msgctxt "#30806"
783msgid "Clear cache when update"
784msgstr "Șterge cache la actualizare"
785
786msgctxt "#30807"
787msgid "Peerfix Configuration"
788msgstr "Configurează Peerfix"
789
790msgctxt "#30894"
791msgid "Show Changelog"
792msgstr "Afișează jurnalul modificărilor"
793
794msgctxt "#30895"
795msgid "View the latest changes and updates to the addon."
796msgstr "Vezi ultimele modificări și actualizări ale add-on-ului."
797
798msgctxt "#30896"
799msgid "Calendar"
800msgstr "Calendar"
801
802msgctxt "#30897"
803msgid "Use broadcast day adjustment"
804msgstr "Folosește ajustarea zilei de difuzare"
805
806msgctxt "#30898"
807msgid "Adjust broadcast day based on manual offset"
808msgstr "Ajustează ziua de difuzare pe baza unui offset manual"
809
810msgctxt "#30899"
811msgid "Broadcast Offset (Hours)"
812msgstr "Offset difuzare (ore)"
813
814msgctxt "#30900"
815msgid "Manual hour adjustment for broadcast day simulation"
816msgstr "Ajustare manuală a orei pentru simularea zilei de difuzare"
817
818msgctxt "#30905"
819msgid "Trakt Calendar Previous Days"
820msgstr "Zile anterioare în calendarul Trakt"
821
822msgctxt "#30906"
823msgid "Number of days in the past to show in the Trakt calendar."
824msgstr "Numărul de zile din trecut afișate în calendarul Trakt."
825
826msgctxt "#30907"
827msgid "Trakt Calendar Future Days"
828msgstr "Zile viitoare în calendarul Trakt"
829
830msgctxt "#30908"
831msgid "Number of days in the future to show in the Trakt calendar."
832msgstr "Numărul de zile din viitor afișate în calendarul Trakt."
833
834msgctxt "#30909"
835msgid "Show All Shows in Calendar"
836msgstr "Afișează toate serialele în calendar"
837
838msgctxt "#30918"
839msgid "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead."
840msgstr "{provider} nu poate reda direct arhive (.rar/.zip). Alege o sursă cu fișiere video."
841
842msgctxt "#30910"
843msgid "Show all airing shows instead of just your watchlist/collection."
844msgstr "Afișează toate serialele difuzate, nu doar lista ta/colecția."
845
846msgctxt "#30911"
847msgid "Your Torbox account username/email."
848msgstr "Numele de utilizator/email cont Torbox."
849
850msgctxt "#30912"
851msgid "Authorize add-on to access Torbox account."
852msgstr "Autorizează add-on-ul să acceseze contul Torbox."
853
854msgctxt "#30913"
855msgid "Revoke Torbox account access."
856msgstr "Revocă accesul la contul Torbox."
857
858msgctxt "#30914"
859msgid "Trakt Sync Interval (minutes)"
860msgstr "Interval sincronizare Trakt (minute)"
861
862msgctxt "#30915"
863msgid "How often Jacktook checks Trakt for remote activity changes while Kodi is running."
864msgstr "Cât de des verifică Jacktook Trakt pentru modificări de activitate de la distanță în timp ce Kodi rulează."
865
866msgctxt "#30916"
867msgid "Account Info"
868msgstr "Informații cont"
869
870msgctxt "#30917"
871msgid "View your Trakt account profile and library stats."
872msgstr "Vezi profilul contului Trakt și statisticile bibliotecii."
873
874msgctxt "#30924"
875msgid "Trakt List Name"
876msgstr "Nume listă Trakt"
877
878msgctxt "#30925"
879msgid "Trakt List Description"
880msgstr "Descriere listă Trakt"
881
882msgctxt "#30926"
883msgid "Create Trakt List"
884msgstr "Creează listă Trakt"
885
886msgctxt "#30927"
887msgid "Choose Trakt List"
888msgstr "Alege lista Trakt"
889
890msgctxt "#30936"
891msgid "Enable or disable Trakt scrobbling and resume playback from the last tracked position."
892msgstr "Activează sau dezactivează scrobbling-ul Trakt și reluarea de la ultima poziție."
893
894msgctxt "#30937"
895msgid "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view."
896msgstr "Șterge datele Trakt din cache și forțează actualizarea la următoarea deschidere."
897
898msgctxt "#40000"
899msgid "Arabic (United Arab Emirates)"
900msgstr "Arabă (Emiratele Arabe Unite)"
901
902msgctxt "#40001"
903msgid "Arabic (Saudi Arabia)"
904msgstr "Arabă (Arabia Saudită)"
905
906msgctxt "#40002"
907msgid "Belarusian (Belarus)"
908msgstr "Belarusă (Belarus)"
909
910msgctxt "#40003"
911msgid "Bulgarian (Bulgaria)"
912msgstr "Bulgară (Bulgaria)"
913
914msgctxt "#40004"
915msgid "Bengali (Bangladesh)"
916msgstr "Bengaleză (Bangladesh)"
917
918msgctxt "#40005"
919msgid "Catalan (Spain)"
920msgstr "Catalană (Spania)"
921
922msgctxt "#40006"
923msgid "Chamorro (Guam)"
924msgstr "Chamorro (Guam)"
925
926msgctxt "#40007"
927msgid "Czech (Czech Republic)"
928msgstr "Cehă (Cehia)"
929
930msgctxt "#40008"
931msgid "Danish (Denmark)"
932msgstr "Daneză (Danemarca)"
933
934msgctxt "#40009"
935msgid "German (Austria)"
936msgstr "Germană (Austria)"
937
938msgctxt "#40010"
939msgid "German (Switzerland)"
940msgstr "Germană (Elveția)"
941
942msgctxt "#40011"
943msgid "German (Germany)"
944msgstr "Germană (Germania)"
945
946msgctxt "#40012"
947msgid "Greek (Greece)"
948msgstr "Greacă (Grecia)"
949
950msgctxt "#40013"
951msgid "English (Australia)"
952msgstr "Engleză (Australia)"
953
954msgctxt "#40014"
955msgid "English (Canada)"
956msgstr "Engleză (Canada)"
957
958msgctxt "#40015"
959msgid "English (United Kingdom)"
960msgstr "Engleză (Regatul Unit)"
961
962msgctxt "#40016"
963msgid "English (Ireland)"
964msgstr "Engleză (Irlanda)"
965
966msgctxt "#40017"
967msgid "English (New Zealand)"
968msgstr "Engleză (Noua Zeelandă)"
969
970msgctxt "#40018"
971msgid "English (United States of America)"
972msgstr "Engleză (Statele Unite ale Americii)"
973
974msgctxt "#40019"
975msgid "Esperanto (None)"
976msgstr "Esperanto"
977
978msgctxt "#40020"
979msgid "Spanish (Spain)"
980msgstr "Spaniolă (Spania)"
981
982msgctxt "#40021"
983msgid "Spanish (Mexico)"
984msgstr "Spaniolă (Mexic)"
985
986msgctxt "#40022"
987msgid "Estonian (Estonia)"
988msgstr "Estoniană (Estonia)"
989
990msgctxt "#40023"
991msgid "Basque (Spain)"
992msgstr "Bască (Spania)"
993
994msgctxt "#40024"
995msgid "Persian (Iran)"
996msgstr "Persană (Iran)"
997
998msgctxt "#40025"
999msgid "Finnish (Finland)"
1000msgstr "Finlandeză (Finlanda)"
1001
1002msgctxt "#40026"
1003msgid "French (Canada)"
1004msgstr "Franceză (Canada)"
1005
1006msgctxt "#40027"
1007msgid "French (France)"
1008msgstr "Franceză (Franța)"
1009
1010msgctxt "#40028"
1011msgid "Galician (Spain)"
1012msgstr "Galiciană (Spania)"
1013
1014msgctxt "#40029"
1015msgid "Hebrew (Israel)"
1016msgstr "Ebraică (Israel)"
1017
1018msgctxt "#40030"
1019msgid "Hindi (India)"
1020msgstr "Hindi (India)"
1021
1022msgctxt "#40031"
1023msgid "Hungarian (Hungary)"
1024msgstr "Maghiară (Ungaria)"
1025
1026msgctxt "#40032"
1027msgid "Indonesian (Indonesia)"
1028msgstr "Indoneziană (Indonezia)"
1029
1030msgctxt "#40033"
1031msgid "Italian (Italy)"
1032msgstr "Italiană (Italia)"
1033
1034msgctxt "#40034"
1035msgid "Japanese (Japan)"
1036msgstr "Japoneză (Japonia)"
1037
1038msgctxt "#40035"
1039msgid "Georgian (Georgia)"
1040msgstr "Georgiană (Georgia)"
1041
1042msgctxt "#40036"
1043msgid "Kazakh (Kazakhstan)"
1044msgstr "Kazahă (Kazahstan)"
1045
1046msgctxt "#40037"
1047msgid "Kannada (India)"
1048msgstr "Kannada (India)"
1049
1050msgctxt "#40038"
1051msgid "Korean (South Korea)"
1052msgstr "Coreeană (Coreea de Sud)"
1053
1054msgctxt "#40039"
1055msgid "Lithuanian (Lithuania)"
1056msgstr "Lituaniană (Lituania)"
1057
1058msgctxt "#40040"
1059msgid "Latvian (Latvia)"
1060msgstr "Letonă (Letonia)"
1061
1062msgctxt "#40041"
1063msgid "Malayalam (India)"
1064msgstr "Malayalam (India)"
1065
1066msgctxt "#40042"
1067msgid "Malay (Malaysia)"
1068msgstr "Malaieză (Malaysia)"
1069
1070msgctxt "#40043"
1071msgid "Malay (Singapore)"
1072msgstr "Malaieză (Singapore)"
1073
1074msgctxt "#40044"
1075msgid "Norwegian Bokmål (Norway)"
1076msgstr "Norvegiană Bokmål (Norvegia)"
1077
1078msgctxt "#40045"
1079msgid "Dutch (Netherlands)"
1080msgstr "Olandeză (Țările de Jos)"
1081
1082msgctxt "#40046"
1083msgid "Norwegian (Norway)"
1084msgstr "Norvegiană (Norvegia)"
1085
1086msgctxt "#40047"
1087msgid "Polish (Poland)"
1088msgstr "Poloneză (Polonia)"
1089
1090msgctxt "#40048"
1091msgid "Portuguese (Brazil)"
1092msgstr "Portugheză (Brazilia)"
1093
1094msgctxt "#40049"
1095msgid "Portuguese (Portugal)"
1096msgstr "Portugheză (Portugalia)"
1097
1098msgctxt "#40050"
1099msgid "Romanian (Romania)"
1100msgstr "Română (România)"
1101
1102msgctxt "#40051"
1103msgid "Russian (Russia)"
1104msgstr "Rusă (Rusia)"
1105
1106msgctxt "#40052"
1107msgid "Sinhalese (Sri Lanka)"
1108msgstr "Sinhaleză (Sri Lanka)"
1109
1110msgctxt "#40053"
1111msgid "Slovak (Slovakia)"
1112msgstr "Slovacă (Slovacia)"
1113
1114msgctxt "#40054"
1115msgid "Slovenian (Slovenia)"
1116msgstr "Slovenă (Slovenia)"
1117
1118msgctxt "#40055"
1119msgid "Serbian (Serbia)"
1120msgstr "Sârbă (Serbia)"
1121
1122msgctxt "#40056"
1123msgid "Swedish (Sweden)"
1124msgstr "Suedeză (Suedia)"
1125
1126msgctxt "#40057"
1127msgid "Tamil (India)"
1128msgstr "Tamilă (India)"
1129
1130msgctxt "#40058"
1131msgid "Telugu (India)"
1132msgstr "Telugu (India)"
1133
1134msgctxt "#40059"
1135msgid "Thai (Thailand)"
1136msgstr "Thailandeză (Thailanda)"
1137
1138msgctxt "#40060"
1139msgid "Tagalog (Philippines)"
1140msgstr "Tagalog (Filipine)"
1141
1142msgctxt "#40061"
1143msgid "Turkish (Turkey)"
1144msgstr "Turcă (Turcia)"
1145
1146msgctxt "#40062"
1147msgid "Ukrainian (Ukraine)"
1148msgstr "Ucraineană (Ucraina)"
1149
1150msgctxt "#40063"
1151msgid "Vietnamese (Vietnam)"
1152msgstr "Vietnameză (Vietnam)"
1153
1154msgctxt "#40064"
1155msgid "Mandarin (China)"
1156msgstr "Mandarină (China)"
1157
1158msgctxt "#40065"
1159msgid "Mandarin (Hong Kong)"
1160msgstr "Mandarină (Hong Kong)"
1161
1162msgctxt "#40066"
1163msgid "Mandarin (Taiwan)"
1164msgstr "Mandarină (Taiwan)"
1165
1166msgctxt "#40067"
1167msgid "Zulu (South Africa)"
1168msgstr "Zulu (Africa de Sud)"
1169
1170msgctxt "#42418"
1171msgid "Language and country"
1172msgstr "Limbă și țară"
1173
1174msgctxt "#42419"
1175msgid "The language and country settings used when looking up data from TMDb"
1176msgstr "Setările de limbă și țară folosite pentru căutarea datelor din TMDb"
1177
1178msgctxt "#42420"
1179msgid "EN"
1180msgstr "EN"
1181
1182msgctxt "#30801"
1183msgid "Zilean server address (include port if required)."
1184msgstr "Adresa serverului Zilean (include portul dacă este necesar)."
1185
1186msgctxt "#30803"
1187msgid "Display torrents not yet cached on your debrid service."
1188msgstr "Afișează torrentele care nu sunt încă în cache în serviciul tău de debrid."
1189
1190msgctxt "#30808"
1191msgid "Fanart.tv Client Key (for artwork)."
1192msgstr "Cheie client Fanart.tv (pentru imagini)."
1193
1194msgctxt "#30809"
1195msgid "Improve performance by caching metadata."
1196msgstr "Îmbunătățește performanța prin cache pentru metadate."
1197
1198msgctxt "#30810"
1199msgid "Seconds before Jackett requests time out (e.g., 30)."
1200msgstr "Secunde până la expirarea cererilor Jackett (ex.: 30)."
1201
1202msgctxt "#30811"
1203msgid "Trakt client secret from OAuth application settings."
1204msgstr "Secret client Trakt din setările aplicației OAuth."
1205
1206msgctxt "#30812"
1207msgid "Limit TV show results to specific seasons/episodes."
1208msgstr "Limitează rezultatele serialelor la anumite sezoane/episoade."
1209
1210msgctxt "#30813"
1211msgid "Hours before cached data is automatically refreshed (e.g., 24)."
1212msgstr "Ore până la reîmprospătarea automată a cache-ului (ex.: 24)."
1213
1214msgctxt "#30814"
1215msgid "Premiumize Client Token (get from account page)."
1216msgstr "Token client Premiumize (ia-l din pagina contului)."
1217
1218msgctxt "#30815"
1219msgid "Jackett server URL (e.g., http://localhost)."
1220msgstr "URL server Jackett (ex.: http://localhost)."
1221
1222msgctxt "#30816"
1223msgid "Display Telegram integration options in UI."
1224msgstr "Afișează opțiunile de integrare Telegram în interfață."
1225
1226msgctxt "#30817"
1227msgid "Your Real-Debrid account username/email."
1228msgstr "Numele de utilizator/email cont Real-Debrid."
1229
1230msgctxt "#30818"
1231msgid "Trakt client ID from https://trakt.tv/oauth/apps."
1232msgstr "ID client Trakt din https://trakt.tv/oauth/apps."
1233
1234msgctxt "#30819"
1235msgid "Connect Trakt.tv account to sync watch progress."
1236msgstr "Conectează contul Trakt.tv pentru sincronizarea progresului."
1237
1238msgctxt "#30820"
1239msgid "Seconds before Zilean requests time out (e.g., 30)."
1240msgstr "Secunde până la expirarea cererilor Zilean (ex.: 30)."
1241
1242msgctxt "#30821"
1243msgid "Your Jackett Client Key (found in dashboard)."
1244msgstr "Cheie client Jackett (din dashboard)."
1245
1246msgctxt "#30822"
1247msgid "Sort results by criteria (e.g., 'date', 'title', 'size')."
1248msgstr "Sortează rezultatele după criterii (ex: „dată”, „titlu”, „mărime”)."
1249
1250msgctxt "#30823"
1251msgid "Configure Burst mode settings and providers."
1252msgstr "Configurează modul Burst și furnizorii."
1253
1254msgctxt "#30824"
1255msgid "Enable Premiumize.me cloud service."
1256msgstr "Activează serviciul cloud Premiumize.me."
1257
1258msgctxt "#30825"
1259msgid "Truncate descriptions after [X] characters (0 for full text)."
1260msgstr "Trunchiază descrierile după [X] caractere (0 pentru text complet)."
1261
1262msgctxt "#30826"
1263msgid "Enable Prowlarr indexer management."
1264msgstr "Activează gestionarea indexatorilor Prowlarr."
1265
1266msgctxt "#30827"
1267msgid "Jackgram server URL (e.g., http://localhost:9117)."
1268msgstr "URL server Jackgram (ex.: http://localhost:9117)."
1269
1270msgctxt "#30828"
1271msgid "Debrider Client Key for authentication."
1272msgstr "Cheie client Debrider pentru autentificare."
1273
1274msgctxt "#30829"
1275msgid "Delay (in seconds) before next episode dialog appears (e.g., 10)."
1276msgstr "Întârziere (în secunde) înainte ca dialogul pentru episodul următor să apară (ex: 10)"
1277
1278msgctxt "#30830"
1279msgid "Specify the torrent client."
1280msgstr "Specifică clientul torrent."
1281
1282msgctxt "#30831"
1283msgid "Enable Debrider cached torrent support."
1284msgstr "Activează suportul pentru torrente în cache Debrider."
1285
1286msgctxt "#30832"
1287msgid "Torbox Client Token (found in account settings)."
1288msgstr "Token client Torbox (din setările contului)."
1289
1290msgctxt "#30833"
1291msgid "Trakt refresh token (auto-managed)."
1292msgstr "Token refresh Trakt (gestionat automat)."
1293
1294msgctxt "#30834"
1295msgid "Show confirmation dialog before playing next episode."
1296msgstr "Afișează confirmare înainte de episodul următor."
1297
1298msgctxt "#30835"
1299msgid "Authorize add-on to access Real-Debrid account."
1300msgstr "Autorizează add-on-ul să acceseze contul Real-Debrid."
1301
1302msgctxt "#30836"
1303msgid "Enable torrent-based media content"
1304msgstr "Activează conținut media bazat pe torrent"
1305
1306msgctxt "#30837"
1307msgid "Indicates if Real-Debrid account is linked."
1308msgstr "Indică dacă contul Real-Debrid este conectat."
1309
1310msgctxt "#30838"
1311msgid "Automatically play next media item when current playback ends."
1312msgstr "Redă automat următorul element media când se termină redarea curentă."
1313
1314msgctxt "#30839"
1315msgid "Check for and install the latest add-on updates."
1316msgstr "Verifică și instalează ultimele actualizări."
1317
1318msgctxt "#30840"
1319msgid "Comma-separated Prowlarr indexer IDs to use (e.g., 1,5,7)."
1320msgstr "ID-uri indexatori Prowlarr separate prin virgulă (ex.: 1,5,7)."
1321
1322msgctxt "#30841"
1323msgid "Timeout (seconds) for Prowlarr requests (e.g., 20)."
1324msgstr "Timeout (secunde) pentru cereri Prowlarr (ex.: 20)."
1325
1326msgctxt "#30842"
1327msgid "Trakt authorization expiration date (read-only)."
1328msgstr "Data expirării autorizării Trakt (doar citire)."
1329
1330msgctxt "#30843"
1331msgid "Enable Torbox.de cloud torrent integration."
1332msgstr "Activează integrarea cloud pentru torrent Torbox.de."
1333
1334msgctxt "#30844"
1335msgid "Trakt Access Token (auto-filled after auth)."
1336msgstr "Token de acces Trakt (completat automat după autentificare)."
1337
1338msgctxt "#30845"
1339msgid "Link Premiumize.me account to add-on."
1340msgstr "Conectează contul Premiumize.me."
1341
1342msgctxt "#30846"
1343msgid "Clear cached data automatically after updating the add-on."
1344msgstr "Șterge automat datele din cache după actualizarea add-on-ului."
1345
1346msgctxt "#30847"
1347msgid "Click to delete all cached data immediately."
1348msgstr "Apasă pentru a șterge imediat tot cache-ul."
1349
1350msgctxt "#30848"
1351msgid "Real-Debrid Client Token (auto-filled after auth)."
1352msgstr "Token client Real-Debrid (completat automat după autentificare)."
1353
1354msgctxt "#30849"
1355msgid "Enable Jackett torrent proxy support."
1356msgstr "Activează proxy torrent Jackett."
1357
1358msgctxt "#30850"
1359msgid "Enable Jackgram integration for enhanced search."
1360msgstr "Activează integrarea Jackgram."
1361
1362msgctxt "#30851"
1363msgid "Prowlarr instance URL (e.g., http://localhost)."
1364msgstr "URL instanță Prowlarr (ex.: http://localhost)."
1365
1366msgctxt "#30852"
1367msgid "Maximum number of search results to display (e.g., 50)."
1368msgstr "Număr maxim de rezultate afișate (ex.: 50)."
1369
1370msgctxt "#30853"
1371msgid "Revoke Real-Debrid account access."
1372msgstr "Revocă accesul Real-Debrid."
1373
1374msgctxt "#30854"
1375msgid "Prowlarr Client Key (found in Settings > General)."
1376msgstr "Cheie client Prowlarr (din Settings > General)."
1377
1378msgctxt "#30855"
1379msgid "Trakt.tv username (auto-populated after auth)."
1380msgstr "Nume utilizator Trakt.tv (completat automat după autentificare)."
1381
1382msgctxt "#30856"
1383msgid "Disconnect Trakt.tv account from add-on."
1384msgstr "Deconectează contul Trakt.tv."
1385
1386msgctxt "#30857"
1387msgid "Enable Zilean integration for metadata fetching."
1388msgstr "Activează integrarea Zilean pentru metadate."
1389
1390msgctxt "#30858"
1391msgid "Your Client Key (required for metadata)"
1392msgstr "Cheia ta client (necesară pentru metadate)"
1393
1394msgctxt "#30859"
1395msgid "Enable Burst mode for accelerated searches."
1396msgstr "Activează modul Burst pentru căutări rapide."
1397
1398msgctxt "#30860"
1399msgid "Others"
1400msgstr "Altele"
1401
1402msgctxt "#30861"
1403msgid "Filters"
1404msgstr "Filtre"
1405
1406msgctxt "#30862"
1407msgid "Torrent (P2P) Configuration"
1408msgstr "Configurează torrent (P2P)"
1409
1410msgctxt "#30863"
1411msgid "Specify the torrent client"
1412msgstr "Specifică clientul torrent"
1413
1414msgctxt "#30864"
1415msgid "Sources"
1416msgstr "Surse"
1417
1418msgctxt "#30865"
1419msgid "General Filters"
1420msgstr "Filtre generale"
1421
1422msgctxt "#30866"
1423msgid "Sign in to your Stremio account to access add-ons and personalized features."
1424msgstr "Autentifică-te în contul Stremio pentru add-on-uri și funcții personalizate."
1425
1426msgctxt "#30867"
1427msgid "Sign out of your Stremio account. This will disconnect your account but won't delete your data."
1428msgstr "Deconectează-te din contul tău Stremio. Acest lucru va deconecta contul, dar nu îți va șterge datele."
1429
1430msgctxt "#30868"
1431msgid "Log Out of Stremio"
1432msgstr "Deconectare din Stremio"
1433
1434msgctxt "#30869"
1435msgid "Enable Stremio add-ons integration."
1436msgstr "Activează integrarea add-on-urilor Stremio."
1437
1438msgctxt "#30870"
1439msgid "Select Stream Addons"
1440msgstr "Selectează add-on-uri de streaming"
1441
1442msgctxt "#30871"
1443msgid "Log In to Stremio"
1444msgstr "Autentificare în Stremio"
1445
1446msgctxt "#30872"
1447msgid "Enable or disable Stremio add-ons."
1448msgstr "Activează sau dezactivează add-on-urile Stremio."
1449
1450msgctxt "#30873"
1451msgid "Select language for player subtitle selection"
1452msgstr "Alege limba subtitrărilor"
1453
1454msgctxt "#30874"
1455msgid "Update Addons"
1456msgstr "Actualizează add-on-urile"
1457
1458msgctxt "#30875"
1459msgid "Select Catalogs Addons"
1460msgstr "Selectează add-on-uri de cataloage"
1461
1462msgctxt "#30876"
1463msgid "Download directory"
1464msgstr "Director descărcări"
1465
1466msgctxt "#30877"
1467msgid "Enable Trakt.tv Scrobbling"
1468msgstr "Activează scrobbling Trakt.tv"
1469
1470msgctxt "#30878"
1471msgid "Auto select subtitle when playing"
1472msgstr "Selectează automat subtitrarea"
1473
1474msgctxt "#30879"
1475msgid "Auto-play quality"
1476msgstr "Calitate redare automată"
1477
1478msgctxt "#30880"
1479msgid "1080p"
1480msgstr "1080p"
1481
1482msgctxt "#30881"
1483msgid "720p"
1484msgstr "720p"
1485
1486msgctxt "#30882"
1487msgid "4k"
1488msgstr "4K"
1489
1490msgctxt "#30883"
1491msgid "480p"
1492msgstr "480p"
1493
1494msgctxt "#30884"
1495msgid "Subtitles Configuration"
1496msgstr "Configurează subtitrările"
1497
1498msgctxt "#30885"
1499msgid "Addon Host"
1500msgstr "Host add-on"
1501
1502msgctxt "#30886"
1503msgid "Subtitles"
1504msgstr "Subtitrări"
1505
1506msgctxt "#30887"
1507msgid "Primary Language"
1508msgstr "Limbă principală"
1509
1510msgctxt "#30888"
1511msgid "Secondary Language"
1512msgstr "Limbă secundară"
1513
1514msgctxt "#30889"
1515msgid "Deepl Enabled"
1516msgstr "Deepl activat"
1517
1518msgctxt "#30890"
1519msgid "Deepl Client Key"
1520msgstr "Cheie client Deepl"
1521
1522msgctxt "#30891"
1523msgid "Translator Configuration"
1524msgstr "Configurează traducătorul"
1525
1526msgctxt "#30892"
1527msgid "Deepl Target Language"
1528msgstr "Limbă țintă Deepl"
1529
1530msgctxt "#30893"
1531msgid "Stremio Subtitle Configuration"
1532msgstr "Configurează subtitrările Stremio"
1533
1534msgctxt "#90001"
1535msgid "Add Custom Addon"
1536msgstr "Adaugă add-on personalizat"
1537
1538msgctxt "#90002"
1539msgid "Add a custom Stremio addon by entering its URL"
1540msgstr "Adaugă un add-on Stremio introducând URL-ul"
1541
1542msgctxt "#90003"
1543msgid "Auto-select audio when playing"
1544msgstr "Selectează automat audio"
1545
1546msgctxt "#90004"
1547msgid "Audio Language"
1548msgstr "Limbă audio"
1549
1550msgctxt "#90005"
1551msgid "Select audio language for playing the video"
1552msgstr "Alege limba audio pentru redare"
1553
1554msgctxt "#90006"
1555msgid "Search"
1556msgstr "Caută"
1557
1558msgctxt "#90007"
1559msgid "TV Shows"
1560msgstr "Seriale"
1561
1562msgctxt "#90008"
1563msgid "Movies"
1564msgstr "Filme"
1565
1566msgctxt "#90009"
1567msgid "Anime"
1568msgstr "Anime"
1569
1570msgctxt "#90010"
1571msgid "Live TV"
1572msgstr "TV Live"
1573
1574msgctxt "#90011"
1575msgid "Direct Search"
1576msgstr "Căutare directă"
1577
1578msgctxt "#90012"
1579msgid "Torrents"
1580msgstr "Torrente"
1581
1582msgctxt "#90013"
1583msgid "Telegram"
1584msgstr "Telegram"
1585
1586msgctxt "#90014"
1587msgid "Cloud"
1588msgstr "Cloud"
1589
1590msgctxt "#90015"
1591msgid "Downloads"
1592msgstr "Descărcări"
1593
1594msgctxt "#90016"
1595msgid "Settings"
1596msgstr "Setări"
1597
1598msgctxt "#90017"
1599msgid "History"
1600msgstr "Istoric"
1601
1602msgctxt "#90018"
1603msgid "Donate"
1604msgstr "Donează"
1605
1606msgctxt "#90019"
1607msgid "Recently Watched Files"
1608msgstr "Fișiere vizionate recent"
1609
1610msgctxt "#90020"
1611msgid "Recently Watched Titles"
1612msgstr "Titluri vizionate recent"
1613
1614msgctxt "#90021"
1615msgid "TV Calendar"
1616msgstr "Calendar TV"
1617
1618
1619msgctxt "#90022"
1620msgid "Jacktook is free and built with care in spare time. If you enjoy the addon, consider supporting its development. Donations are optional and help keep updates coming."
1621msgstr "Jacktook este gratuit și construit cu grijă în timpul liber. Dacă îți place addonul, ia în considerare susținerea dezvoltării. Donațiile sunt opționale și ajută la menținerea actualizărilor."
1622
1623msgctxt "#90023"
1624msgid "Support Jacktook"
1625msgstr "Susține Jacktook"
1626
1627msgctxt "#90024"
1628msgid "Trending Recent"
1629msgstr "Tendințe recente"
1630
1631msgctxt "#90025"
1632msgid "Genres"
1633msgstr "Genuri"
1634
1635msgctxt "#90026"
1636msgid "Calendar"
1637msgstr "Calendar"
1638
1639msgctxt "#90027"
1640msgid "Years"
1641msgstr "Ani"
1642
1643msgctxt "#90028"
1644msgid "Trending"
1645msgstr "Tendințe"
1646
1647msgctxt "#90029"
1648msgid "Most Watched"
1649msgstr "Cele mai vizionate"
1650
1651msgctxt "#90030"
1652msgid "Most Favorited"
1653msgstr "Cele mai apreciate"
1654
1655msgctxt "#90031"
1656msgid "Popular Lists"
1657msgstr "Liste populare"
1658
1659msgctxt "#90032"
1660msgid "Trending Lists"
1661msgstr "Liste în tendințe"
1662
1663msgctxt "#90033"
1664msgid "Recomendations"
1665msgstr "Recomandări"
1666
1667msgctxt "#90034"
1668msgid "Watched History"
1669msgstr "Istoric vizionări"
1670
1671msgctxt "#90035"
1672msgid "Watchlist"
1673msgstr "Listă de urmărire"
1674
1675msgctxt "#90036"
1676msgid "Top 10 BoxOffice"
1677msgstr "Top 10 Box Office"
1678
1679msgctxt "#90037"
1680msgid "Popular"
1681msgstr "Populare"
1682
1683msgctxt "#90038"
1684msgid "Popular Recent"
1685msgstr "Populare recent"
1686
1687msgctxt "#90039"
1688msgid "Airing"
1689msgstr "În difuzare"
1690
1691msgctxt "#90040"
1692msgid "Years"
1693msgstr "Ani"
1694
1695msgctxt "#90041"
1696msgid "Genres"
1697msgstr "Genuri"
1698
1699msgctxt "#90042"
1700msgid "Top Rated"
1701msgstr "Cele mai bine evaluate"
1702
1703msgctxt "#90043"
1704msgid "Most Watched"
1705msgstr "Cele mai vizionate"
1706
1707msgctxt "#90044"
1708msgid "Torrentio Configuration"
1709msgstr "Configurare Torrentio"
1710
1711msgctxt "#90045"
1712msgid "Enable Torrentio Settings"
1713msgstr "Activează setările Torrentio"
1714
1715msgctxt "#90046"
1716msgid "Enable or disable the Torrentio Configuration"
1717msgstr "Activează sau dezactivează configurarea Torrentio"
1718
1719msgctxt "#90047"
1720msgid "Select Torrentio Providers"
1721msgstr "Selectează furnizorii Torrentio"
1722
1723msgctxt "#90048"
1724msgid "Choose which providers to include in the stream request."
1725msgstr "Alege ce furnizori să incluzi în cererea de stream"
1726
1727# Context menus
1728
1729msgctxt "#90049"
1730msgid "Scrape again"
1731msgstr "Caută din nou"
1732
1733msgctxt "#90050"
1734msgid "Recommendations"
1735msgstr "Recomandări"
1736
1737msgctxt "#90051"
1738msgid "Similar"
1739msgstr "Similare"
1740
1741msgctxt "#90052"
1742msgid "Thread Number"
1743msgstr "Număr thread"
1744
1745msgctxt "#90053"
1746msgid "Select the number of threads (1-30)"
1747msgstr "Selectează numărul de thread-uri (1-30)"
1748
1749msgctxt "#90054"
1750msgid "Kodi logs"
1751msgstr "Log-uri Kodi"
1752
1753msgctxt "#90055"
1754msgid "Kodi old logs"
1755msgstr "Log-uri vechi Kodi"
1756
1757msgctxt "#90056"
1758msgid "Displays Kodi logs to help diagnose issues."
1759msgstr "Afișează log-urile Kodi pentru a ajuta la diagnosticarea problemelor"
1760
1761msgctxt "#90057"
1762msgid "Logs"
1763msgstr "Log-uri"
1764
1765msgctxt "#90058"
1766msgid "Filter by Size"
1767msgstr "Filtrează după mărime"
1768
1769msgctxt "#90059"
1770msgid "Enable Size Filter"
1771msgstr "Activează filtrul de mărime"
1772
1773msgctxt "#90060"
1774msgid "Minimum Size (MB)"
1775msgstr "Dimensiune mărime (MB)"
1776
1777msgctxt "#90061"
1778msgid "Maximum Size (MB)"
1779msgstr "Mărime maximă (MB)"
1780
1781msgctxt "#90062"
1782msgid "Enable Cached Search"
1783msgstr "Activează căutarea în cache"
1784
1785msgctxt "#90063"
1786msgid "Only show cached streams from external services"
1787msgstr "Afișează doar stream-uri din cache de la servicii externe"
1788
1789msgctxt "#90064"
1790msgid "Popular"
1791msgstr "Populare"
1792
1793msgctxt "#90065"
1794msgid "Languages"
1795msgstr "Limbi"
1796
1797msgctxt "#90066"
1798msgid "Networks"
1799msgstr "Rețele"
1800
1801msgctxt "#90067"
1802msgid "Collections"
1803msgstr "Colecții"
1804
1805msgctxt "#90068"
1806msgid "Search for collections:"
1807msgstr "Caută colecții"
1808
1809msgctxt "#90069"
1810msgid "Main Menu"
1811msgstr "Meniu principal"
1812
1813msgctxt "#90070"
1814msgid "Last Titles"
1815msgstr "Ultimele titluri"
1816
1817msgctxt "#90071"
1818msgid "Last Files"
1819msgstr "Ultimele fișiere"
1820
1821msgctxt "#90072"
1822msgid "Trakt Lists"
1823msgstr "Liste Trakt"
1824
1825msgctxt "#90073"
1826msgid "Metadata"
1827msgstr "Metadate"
1828
1829msgctxt "#90074"
1830msgid "Top Lists"
1831msgstr "Top liste"
1832
1833msgctxt "#90075"
1834msgid "User Lists"
1835msgstr "Liste utilizator"
1836
1837msgctxt "#90076"
1838msgid "Search Lists"
1839msgstr "Caută liste"
1840
1841msgctxt "#90077"
1842msgid "MDbList Configuration"
1843msgstr "Configurare MDbList"
1844
1845msgctxt "#90078"
1846msgid "People"
1847msgstr "Persoane"
1848
1849msgctxt "#90079"
1850msgid "Popular People"
1851msgstr "Persoane populare"
1852
1853msgctxt "#90080"
1854msgid "Latest People"
1855msgstr "Persoane recente"
1856
1857msgctxt "#90081"
1858msgid "Search People"
1859msgstr "Caută persoane"
1860
1861msgctxt "#90082"
1862msgid "Download subtitles"
1863msgstr "Descarcă subtitrări"
1864
1865msgctxt "#90083"
1866msgid "Download video"
1867msgstr "Descarcă video"
1868
1869msgctxt "#90084"
1870msgid "Browse into"
1871msgstr "Răsfoiește în"
1872
1873msgctxt "#90085"
1874msgid "Include TV show Specials"
1875msgstr "Include episoade speciale"
1876
1877msgctxt "#90086"
1878msgid "Airing Today"
1879msgstr "Difuzate azi"
1880
1881msgctxt "#90099"
1882msgid "Filter by Source Type"
1883msgstr "Filtrează după tipul sursei"
1884
1885# BluRay/UHD
1886msgctxt "#90100"
1887msgid "Include BluRay/UHD sources"
1888msgstr "Include surse BluRay/UHD"
1889
1890msgctxt "#90105"
1891msgid "Includes releases such as: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX"
1892msgstr "Include versiuni precum: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX"
1893
1894# WEB/HD
1895msgctxt "#90101"
1896msgid "Include WEB/HD sources"
1897msgstr "Include surse WEB/HD"
1898
1899msgctxt "#90106"
1900msgid "Includes releases such as: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux"
1901msgstr "Include versiuni precum: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux"
1902
1903# DVD/TV/SAT
1904msgctxt "#90102"
1905msgid "Include DVD/TV/SAT sources"
1906msgstr "Include surse DVD/TV/SAT"
1907
1908msgctxt "#90107"
1909msgid "Includes releases such as: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV"
1910msgstr "Include versiuni precum: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV"
1911
1912# CAM/Screener
1913msgctxt "#90103"
1914msgid "Include CAM/Screener sources"
1915msgstr "Include surse CAM/Screener"
1916
1917msgctxt "#90108"
1918msgid "Includes releases such as: CAM, TeleSync, TeleCine, SCR"
1919msgstr "Include versiuni precum: CAM, TeleSync, TeleCine, SCR"
1920
1921# Unknown
1922msgctxt "#90104"
1923msgid "Include Unknown sources"
1924msgstr "Include surse necunoscute"
1925
1926msgctxt "#90109"
1927msgid "Includes uncategorized or unidentified sources"
1928msgstr "Include surse necategorizate sau neidentificate"
1929
1930msgctxt "#90110"
1931msgid "Do you want to clear your watched history?."
1932msgstr "Vrei să îți ștergi istoricul de vizionări?"
1933
1934msgctxt "#90111"
1935msgid "Do you want to clear your history?."
1936msgstr "Vrei să îți ștergi istoricul?"
1937
1938msgctxt "#90112"
1939msgid "Clear History"
1940msgstr "Șterge istoricul"
1941
1942msgctxt "#90113"
1943msgid "Click to delete all watched history immediately."
1944msgstr "Apasă pentru a șterge imediat tot istoricul de vizionări"
1945
1946msgctxt "#90114"
1947msgid "History cleared"
1948msgstr "Istoric șters"
1949
1950msgctxt "#90115"
1951msgid "Source Select"
1952msgstr "Selectare sursă"
1953
1954msgctxt "#90116"
1955msgid "Open Settings"
1956msgstr "Deschide setările"
1957
1958msgctxt "#90117"
1959msgid "Select Torrentio Providers"
1960msgstr "Selectează providerii Torrentio"
1961
1962msgctxt "#90118"
1963msgid "Port"
1964msgstr "Port"
1965
1966msgctxt "#90119"
1967msgid "Jackett port (default:9117)"
1968msgstr "Port Jackett (implicit: 9117)"
1969
1970msgctxt "#90120"
1971msgid "Prowlarr port (default:9696)"
1972msgstr "Port Prowlarr (implicit: 9696)"
1973
1974msgctxt "#90121"
1975msgid "All Debrid Configuration"
1976msgstr "Configurare All Debrid"
1977
1978msgctxt "#90122"
1979msgid "Auto download subtitles"
1980msgstr "Descarcă automat subtitrări"
1981
1982msgctxt "#90123"
1983msgid "Stremio Timeout"
1984msgstr "Timeout Stremio"
1985
1986msgctxt "#90124"
1987msgid "Webdav Client Configuration"
1988msgstr "Configurare client WebDAV"
1989
1990msgctxt "#90126"
1991msgid "Host"
1992msgstr "Host"
1993
1994msgctxt "#90127"
1995msgid "Username"
1996msgstr "Utilizator"
1997
1998msgctxt "#90128"
1999msgid "Password"
2000msgstr "Parolă"
2001
2002msgctxt "#90129"
2003msgid "Test Connection"
2004msgstr "Testează conexiunea"
2005
2006msgctxt "#90131"
2007msgid "Webdav"
2008msgstr "WebDAV"
2009
2010msgctxt "#90132"
2011msgid "Remote Path"
2012msgstr "Cale remote"
2013
2014msgctxt "#90133"
2015msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)."
2016msgstr "Numele hostului sau adresa IP a serverului WebDAV (ex: http://mywebdav.com sau https://192.168.1.100)"
2017
2018msgctxt "#90134"
2019msgid "The username required for authenticating with your WebDAV server"
2020msgstr "Numele de utilizator necesar pentru autentificarea pe serverul WebDAV."
2021
2022msgctxt "#90135"
2023msgid "The password for the specified username to authenticate with your WebDAV server"
2024msgstr "Parola pentru numele de utilizator specificat, pentru autentificarea pe serverul WebDAV"
2025
2026msgctxt "#90136"
2027msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)."
2028msgstr "Calea pe serverul WebDAV unde se află fișierele media (ex: /movies)"
2029
2030msgctxt "#90137"
2031msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials."
2032msgstr "Apasă pentru a verifica dacă clientul WebDAV se poate conecta cu succes la serverul configurat folosind datele de autentificare furnizate."
2033
2034msgctxt "#90138"
2035msgid "Remove Custom Addon"
2036msgstr "Elimină add-on-ul personalizat"
2037
2038msgctxt "#90139"
2039msgid "Playback"
2040msgstr "Redare"
2041
2042msgctxt "#90140"
2043msgid "Super quick play"
2044msgstr "Redare foarte rapidă"
2045
2046msgctxt "#90141"
2047msgid "Re-ask to play last played video"
2048msgstr "Întreabă din nou dacă vrei să redai ultimul video"
2049
2050msgctxt "#90142"
2051msgid "Replay Dialog"
2052msgstr "Dialog reluare"
2053
2054msgctxt "#90143"
2055msgid "Do you want to play last watched?"
2056msgstr "Vrei să redai ultimul videoclip vizionat?"
2057
2058msgctxt "#90144"
2059msgid "Could not resolve playback URL"
2060msgstr "Nu s-a putut obține adresa de redare"
2061
2062msgctxt "#90171"
2063msgid "Autoscrape Next Episode"
2064msgstr "Autoscrape următorul episod"
2065
2066msgctxt "#90172"
2067msgid "Pre-resolve the next episode's playback URL in the background while watching."
2068msgstr "Pre-rezolvă în fundal URL-ul de redare al următorului episod în timp ce te uiți."
2069
2070msgctxt "#90173"
2071msgid "Autoscrape Threshold"
2072msgstr "Prag Autoscrape"
2073
2074msgctxt "#90174"
2075msgid "Percentage of playback to trigger background pre-resolution."
2076msgstr "Procentul de redare la care se declanșează pre-rezoluția în fundal."
2077
2078msgctxt "#90175"
2079msgid "Autoscrape Episode Count"
2080msgstr "Număr episoade Autoscrape"
2081
2082msgctxt "#90176"
2083msgid "Number of upcoming episodes to pre-resolve."
2084msgstr "Numărul de episoade următoare care se pre-rezolvă."
2085
2086msgctxt "#90177"
2087msgid "Autoscrape Cache TTL"
2088msgstr "TTL cache Autoscrape"
2089
2090msgctxt "#90178"
2091msgid "Hours to keep resolved playback data in cache."
2092msgstr "Numărul de ore în care datele de redare rezolvate sunt păstrate în cache."
2093
2094msgctxt "#30901"
2095msgid "Show Only Stremio Catalogs"
2096msgstr "Afișează doar cataloage Stremio"
2097
2098msgctxt "#30902"
2099msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus."
2100msgstr "Ascunde elementele integrate (TMDb, Trakt etc.) și afișează doar cataloagele Stremio în meniuri."
2101
2102msgctxt "#30903"
2103msgid "Select Live TV Addons"
2104msgstr "Selectează add-on-uri TV Live"
2105
2106msgctxt "#30904"
2107msgid "Choose specific Stremio addons for Live TV content"
2108msgstr "Alege add-on-uri Stremio specifice pentru conținut TV Live"
2109
2110msgctxt "#30920"
2111msgid "Clear Trakt Cache"
2112msgstr "Șterge cache Trakt"
2113
2114msgctxt "#30921"
2115msgid "Clear TMDB Cache"
2116msgstr "Șterge cache TMDB"
2117
2118msgctxt "#30922"
2119msgid "Clear Providers Cache"
2120msgstr "Șterge cache provideri"
2121
2122msgctxt "#30923"
2123msgid "Clear Settings Cache"
2124msgstr "Șterge cache setări"
2125
2126msgctxt "#90149"
2127msgid "Maintenance"
2128msgstr "Mentenanță"
2129
2130msgctxt "#90150"
2131msgid "Stremio: General"
2132msgstr "Stremio: General"
2133
2134msgctxt "#90151"
2135msgid "Stremio: Account"
2136msgstr "Stremio: Cont"
2137
2138msgctxt "#90152"
2139msgid "Stremio: Addons & Catalogs"
2140msgstr "Stremio: Add-on-uri și cataloage"
2141
2142msgctxt "#90153"
2143msgid "Stremio: Torrentio"
2144msgstr "Stremio: Torrentio"
2145
2146msgctxt "#90154"
2147msgid "Trending Recent"
2148msgstr "Tendințe recente"
2149
2150msgctxt "#90155"
2151msgid "Stremio: Filters"
2152msgstr "Stremio: Filtre"
2153
2154msgctxt "#32300"
2155msgid "Filter by Quality"
2156msgstr "Filtrează după calitate"
2157
2158msgctxt "#32301"
2159msgid "Filter by Provider"
2160msgstr "Filtrează după provider"
2161
2162msgctxt "#32302"
2163msgid "Filter by Source"
2164msgstr "Filtrează după sursă"
2165
2166msgctxt "#32303"
2167msgid "Filter by Language"
2168msgstr "Filtrează după limbă"
2169
2170msgctxt "#32304"
2171msgid "Filter by Torrents"
2172msgstr "Filtrează după torrente"
2173
2174msgctxt "#32305"
2175msgid "Filter by Debrid"
2176msgstr "Filtrează după Debrid"
2177
2178msgctxt "#32306"
2179msgid "Reset Filter"
2180msgstr "Resetează filtrul"
2181
2182msgctxt "#30930"
2183msgid "Ping Addons"
2184msgstr "Testează add-on-uri"
2185
2186msgctxt "#30931"
2187msgid "Check availability of addons and show only reachable ones"
2188msgstr "Verifică disponibilitatea add-on-urilor și afișează doar cele accesibile"
2189
2190msgctxt "#90164"
2191msgid "Skip Intro / Recap"
2192msgstr "Omite intro / recapitulare"
2193
2194msgctxt "#90165"
2195msgid "Enable Skip Intro"
2196msgstr "Activează Omite Intro"
2197
2198msgctxt "#90166"
2199msgid "Detect intros and recaps using IntroDB and offer to skip them during playback"
2200msgstr "Detectează intro-urile și recapitulările folosind IntroDB și oferă opțiunea de omitere în timpul redării"
2201
2202msgctxt "#90167"
2203msgid "Auto-skip"
2204msgstr "Omite automat"
2205
2206msgctxt "#90168"
2207msgid "Automatically skip intros and recaps without showing the button"
2208msgstr "Omite automat intro-urile și rezumatele, fără afișarea butonului."
2209
2210msgctxt "#90160"
2211msgid "Skip Intro"
2212msgstr "Omite intro"
2213
2214msgctxt "#90161"
2215msgid "Skip Recap"
2216msgstr "Omite recapitulare"
2217
2218msgctxt "#90162"
2219msgid "Manage from Phone"
2220msgstr "Gestionează de pe telefon"
2221
2222msgctxt "#90163"
2223msgid "Start a local web server and show a QR code to manage Stremio sources from your phone"
2224msgstr "Pornește un server web local și afișează un cod QR pentru a gestiona sursele Stremio de pe telefon"
2225
2226msgctxt "#90200"
2227msgid "Continue Watching"
2228msgstr "Continuă vizionarea"
2229
2230msgctxt "#90201"
2231msgid "Library"
2232msgstr "Bibliotecă"
2233
2234msgctxt "#90202"
2235msgid "My Shows"
2236msgstr "Serialele mele"
2237
2238msgctxt "#90203"
2239msgid "My Movies"
2240msgstr "Filmele mele"
2241
2242msgctxt "#90204"
2243msgid "Remove from Library"
2244msgstr "Elimină din bibliotecă"
2245
2246msgctxt "#90205"
2247msgid "Add to Library"
2248msgstr "Adaugă în bibliotecă"
2249
2250msgctxt "#90206"
2251msgid "Remove"
2252msgstr "Elimină"
2253
2254msgctxt "#90207"
2255msgid "Search Movies & TV"
2256msgstr "Caută filme și TV"
2257
2258msgctxt "#90208"
2259msgid "Recent Searches"
2260msgstr "Căutări recente"
2261
2262msgctxt "#90209"
2263msgid "Recent Direct Searches"
2264msgstr "Căutări directe recente"
2265
2266msgctxt "#90210"
2267msgid "Clear All Search History"
2268msgstr "Șterge tot istoricul de căutări"
2269
2270msgctxt "#90211"
2271msgid "Bypass Stremio Addons Native Sorting"
2272msgstr "Ocolește sortarea nativă a add-on-urilor Stremio"
2273
2274msgctxt "#90212"
2275msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds."
2276msgstr "Dacă este activat, rezultatele din add-on-urile specificate nu vor fi reordonate sau filtrate de pragurile native de calitate și mărime ale Jacktook."
2277
2278msgctxt "#90213"
2279msgid "Select Bypassed Addons"
2280msgstr "Selectează add-on-uri excluse"
2281
2282msgctxt "#90214"
2283msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering."
2284msgstr "Alege ce add-on-uri Stremio ocolesc filtrarea de mărime și calitate a Jacktook."
2285
2286msgctxt "#90215"
2287msgid "Downgrade"
2288msgstr "Retrogradează"
2289
2290msgctxt "#90671"
2291msgid "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails."
2292msgstr "Instalează o versiune mai veche Jacktook. Versiunea curentă este salvată și restaurată automat dacă retrogradarea eșuează."
2293
2294msgctxt "#30932"
2295msgid "Update Action"
2296msgstr "Acțiune actualizare"
2297
2298msgctxt "#30933"
2299msgid "Choose how to handle background update checks."
2300msgstr "Alege cum se gestionează verificările de actualizare în fundal."
2301
2302msgctxt "#30934"
2303msgid "Ask"
2304msgstr "Întreabă"
2305
2306msgctxt "#30935"
2307msgid "Notify"
2308msgstr "Notifică"
2309
2310msgctxt "#90216"
2311msgid "Volume Check Enabled"
2312msgstr "Verificare volum activată"
2313
2314msgctxt "#90217"
2315msgid "Automatically limits volume if it is above the threshold before playback."
2316msgstr "Limitează automat volumul dacă este peste prag înainte de redare."
2317
2318msgctxt "#90218"
2319msgid "Volume Threshold"
2320msgstr "Prag volum"
2321
2322msgctxt "#90219"
2323msgid "The volume percentage above which the warning triggers."
2324msgstr "Procentul de volum peste care se declanșează avertizarea."
2325
2326msgctxt "#90220"
2327msgid "High Volume Warning"
2328msgstr "Avertizare volum ridicat"
2329
2330msgctxt "#90221"
2331msgid "High volume detected. Adjusting to %s%% for safety."
2332msgstr "Volum ridicat detectat. Se ajustează la %s%% pentru siguranță."
2333
2334msgctxt "#90222"
2335msgid "Fanart.tv Configuration"
2336msgstr "Configurare Fanart.tv"
2337
2338msgctxt "#90223"
2339msgid "Client Key"
2340msgstr "Cheie client"
2341
2342msgctxt "#90224"
2343msgid "Your personal Fanart.tv client key for higher quality assets."
2344msgstr "Cheia ta personală Fanart.tv pentru resurse de calitate mai bună."
2345
2346msgctxt "#90225"
2347msgid "Debrid Notifications"
2348msgstr "Notificări Debrid"
2349
2350msgctxt "#90226"
2351msgid "Debrid Expiration Notification"
2352msgstr "Notificare expirare Debrid"
2353
2354msgctxt "#90227"
2355msgid "Enable or disable notifications when your Debrid subscription is about to expire."
2356msgstr "Activează sau dezactivează notificările când abonamentul tău Debrid este pe cale să expire."
2357
2358msgctxt "#90228"
2359msgid "Expiration Threshold (Days)"
2360msgstr "Prag expirare (zile)"
2361
2362msgctxt "#90229"
2363msgid "How many days before expiration to start notifying."
2364msgstr "Cu câte zile înainte de expirare să înceapă notificările."
2365
2366msgctxt "#90230"
2367msgid "%s expires in %d days."
2368msgstr "%s expiră în %d zile."
2369
2370msgctxt "#90231"
2371msgid "%s expires today!"
2372msgstr "%s expiră astăzi!"
2373
2374msgctxt "#90232"
2375msgid "Easynews"
2376msgstr "Easynews"
2377
2378msgctxt "#90233"
2379msgid "Enable EasyNews as a direct stream provider."
2380msgstr "Activează EasyNews ca provider direct de stream."
2381
2382msgctxt "#90234"
2383msgid "Your EasyNews username."
2384msgstr "Numele tău de utilizator EasyNews."
2385
2386msgctxt "#90235"
2387msgid "Your EasyNews password."
2388msgstr "Parola ta EasyNews."
2389
2390msgctxt "#90236"
2391msgid "Request timeout in seconds."
2392msgstr "Timp de așteptare cerere în secunde."
2393
2394msgctxt "#90237"
2395msgid "Account Information"
2396msgstr "Informații cont"
2397
2398msgctxt "#90238"
2399msgid "Search Dialog Style"
2400msgstr "Stil dialog căutare"
2401
2402msgctxt "#90239"
2403msgid "Background"
2404msgstr "Fundal"
2405
2406msgctxt "#90240"
2407msgid "Detailed Status"
2408msgstr "Stare detaliată"
2409
2410msgctxt "#90241"
2411msgid "Search Status"
2412msgstr "Stare căutare"
2413
2414msgctxt "#90242"
2415msgid "Cancel"
2416msgstr "Anulează"
2417
2418msgctxt "#90243"
2419msgid "Results found: "
2420msgstr "Rezultate găsite: "
2421
2422msgctxt "#90244"
2423msgid "Pending"
2424msgstr "În așteptare"
2425
2426msgctxt "#90245"
2427msgid "In Progress"
2428msgstr "În desfășurare"
2429
2430msgctxt "#90246"
2431msgid "Completed"
2432msgstr "Finalizat"
2433
2434msgctxt "#90247"
2435msgid "Failed"
2436msgstr "Eșuat"
2437
2438msgctxt "#90248"
2439msgid "Cancelled"
2440msgstr "Anulat"
2441
2442msgctxt "#90249"
2443msgid "Your personal Jackgram API token for authentication."
2444msgstr "Tokenul tău personal Jackgram pentru autentificare."
2445
2446msgctxt "#90250"
2447msgid "Subtitles Found"
2448msgstr "Subtitrări găsite"
2449
2450msgctxt "#90251"
2451msgid "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?"
2452msgstr "Există deja subtitrări descărcate pentru acest video.\nVrei să folosești subtitrările existente?"
2453
2454msgctxt "#90252"
2455msgid "No subtitles found"
2456msgstr "Nu s-au găsit subtitrări"
2457
2458msgctxt "#90253"
2459msgid "No subtitles selected"
2460msgstr "Nu au fost selectate subtitrări"
2461
2462msgctxt "#90254"
2463msgid "Translate Subtitles"
2464msgstr "Tradu subtitrările"
2465
2466msgctxt "#90255"
2467msgid "Do you want to also translate the selected subtitles?"
2468msgstr "Vrei să traduci și subtitrările selectate?"
2469
2470msgctxt "#90256"
2471msgid "Select Subtitle to Download"
2472msgstr "Selectează subtitrarea de descărcat"
2473
2474msgctxt "#90257"
2475msgid "Subtitles Loaded"
2476msgstr "Subtitrări încărcate"
2477
2478msgctxt "#90258"
2479msgid "Backup"
2480msgstr "Backup"
2481
2482msgctxt "#90259"
2483msgid "Export Backup"
2484msgstr "Exportă backup"
2485
2486msgctxt "#90260"
2487msgid "Export addon settings and custom Stremio addons to a JSON backup file."
2488msgstr "Exportă setările add-on-ului și add-on-urile Stremio personalizate într-un fișier JSON de backup."
2489
2490msgctxt "#90261"
2491msgid "Restore Backup"
2492msgstr "Restaurează backup"
2493
2494msgctxt "#90262"
2495msgid "Restore addon settings and replace custom Stremio addons from a JSON backup file."
2496msgstr "Restaurează setările add-on-ului și înlocuiește add-on-urile Stremio personalizate dintr-un fișier backup JSON."
2497
2498msgctxt "#90263"
2499msgid "Remove Sensitive Data"
2500msgstr "Elimină date sensibile"
2501
2502msgctxt "#90264"
2503msgid "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups."
2504msgstr "Elimină cheile API, parolele, numele de utilizator, email-urile, starea de autentificare și URL-urile add-on-urilor Stremio din backup-uri exportate."
2505
2506msgctxt "#90265"
2507msgid "Settings backup exported"
2508msgstr "Backup setări exportat"
2509
2510msgctxt "#90266"
2511msgid "Settings backup restored"
2512msgstr "Backup setări restaurat"
2513
2514msgctxt "#90267"
2515msgid "Settings Backup Failed"
2516msgstr "Backup setări eșuat"
2517
2518msgctxt "#90268"
2519msgid "Select export folder"
2520msgstr "Selectează folderul de export"
2521
2522msgctxt "#90269"
2523msgid "Select backup file"
2524msgstr "Selectează fișierul backup"
2525
2526msgctxt "#90270"
2527msgid "Backup file is invalid."
2528msgstr "Fișierul backup este invalid."
2529
2530msgctxt "#90271"
2531msgid "No export folder selected."
2532msgstr "Nu a fost selectat niciun folder de export."
2533
2534msgctxt "#90272"
2535msgid "No backup file selected."
2536msgstr "Nu a fost selectat niciun fișier backup."
2537
2538msgctxt "#90273"
2539msgid "Unable to export settings backup."
2540msgstr "Nu s-a putut exporta backup-ul setărilor."
2541
2542msgctxt "#90274"
2543msgid "Unable to restore settings backup."
2544msgstr "Nu s-a putut restaura backup-ul setărilor."
2545
2546msgctxt "#90275"
2547msgid "Restore Backup"
2548msgstr "Restaurează backup"
2549
2550msgctxt "#90276"
2551msgid "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?"
2552msgstr "Aceasta va suprascrie setările curente ale add-on-ului și va înlocui add-on-urile tale Stremio cu cele din backup. Continui?"
2553
2554msgctxt "#90277"
2555msgid "Restore cancelled"
2556msgstr "Restaurare anulată"
2557
2558msgctxt "#90278"
2559msgid "Reset All Settings"
2560msgstr "Resetează toate setările"
2561
2562msgctxt "#90279"
2563msgid "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?"
2564msgstr "Aceasta va reseta toate setările add-on-ului la valorile implicite și va elimina add-on-urile tale Stremio personalizate. Continui?"
2565
2566msgctxt "#90280"
2567msgid "Reset cancelled"
2568msgstr "Resetare anulată"
2569
2570msgctxt "#90281"
2571msgid "All addon settings reset"
2572msgstr "Toate setările add-on-ului au fost resetate"
2573
2574msgctxt "#90282"
2575msgid "Reset Failed"
2576msgstr "Resetarea a eșuat"
2577
2578msgctxt "#90283"
2579msgid "Unable to reset addon settings."
2580msgstr "Nu se pot reseta setările add-on-ului."
2581
2582msgctxt "#90284"
2583msgid "Reset every addon setting back to its default value and clear custom Stremio addons."
2584msgstr "Resetează fiecare setare a add-on-ului la valoarea implicită și șterge add-on-urile Stremio personalizate."
2585
2586msgctxt "#90285"
2587msgid "Factory Reset"
2588msgstr "Resetare la setările din fabrică"
2589
2590msgctxt "#90286"
2591msgid "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?"
2592msgstr "Aceasta va reseta setările, va șterge cache-ul, va elimina add-on-urile Stremio personalizate și va șterge datele locale de istoric/bază de date ale add-on-ului. Continui?"
2593
2594msgctxt "#90287"
2595msgid "Factory reset cancelled"
2596msgstr "Resetarea la setările din fabrică a fost anulată"
2597
2598msgctxt "#90288"
2599msgid "Factory reset completed"
2600msgstr "Resetarea la setările din fabrică a fost finalizată"
2601
2602msgctxt "#90289"
2603msgid "Factory Reset Failed"
2604msgstr "Resetarea la setările din fabrică a eșuat"
2605
2606msgctxt "#90290"
2607msgid "Unable to complete factory reset."
2608msgstr "Nu se poate finaliza resetarea la setările din fabrică."
2609
2610msgctxt "#90291"
2611msgid "Reset settings to defaults, clear caches, and wipe local addon history/database data."
2612msgstr "Resetează setările la valorile implicite, golește cache-ul și șterge istoricul add-on-ului și datele din baza de date."
2613
2614msgctxt "#90292"
2615msgid "Trakt Library"
2616msgstr "Biblioteca Trakt"
2617
2618msgctxt "#90293"
2619msgid "Trakt Discovery"
2620msgstr "Descoperă Trakt"
2621
2622msgctxt "#90294"
2623msgid "Collection"
2624msgstr "Colecție"
2625
2626msgctxt "#90295"
2627msgid "Favorites"
2628msgstr "Favorite"
2629
2630msgctxt "#90296"
2631msgid "Up Next"
2632msgstr "Urmează"
2633
2634msgctxt "#90297"
2635msgid "My Lists"
2636msgstr "Listele mele"
2637
2638msgctxt "#90298"
2639msgid "Liked Lists"
2640msgstr "Liste apreciate"
2641
2642msgctxt "#90299"
2643msgid "No metadata found for this torrent"
2644msgstr "Nu au fost găsite metadate pentru acest torrent"
2645
2646msgctxt "#90331"
2647msgid "Stremio Addons"
2648msgstr "Add-on-uri Stremio"
2649
2650msgctxt "#90332"
2651msgid "No addons available yet. Use Log In to Stremio or Add Custom Addon first."
2652msgstr "Nu există încă add-on-uri disponibile. Autentifică-te în Stremio sau adaugă mai întâi un addon personalizat."
2653
2654msgctxt "#90333"
2655msgid "Stremio Stream Addons"
2656msgstr "Add-on-uri stream Stremio"
2657
2658msgctxt "#90334"
2659msgid "Stremio Catalog Addons"
2660msgstr "Add-on-uri catalog Stremio"
2661
2662msgctxt "#90335"
2663msgid "Stremio Live TV Addons"
2664msgstr "Add-on-uri TV Live Stremio"
2665
2666msgctxt "#90336"
2667msgid "Refresh addons imported from your Stremio account. Custom addons remain unchanged."
2668msgstr "Actualizează add-on-urile importate din contul tău Stremio. Add-on-urile personalizate rămân neschimbate."
2669
2670msgctxt "#90337"
2671msgid "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first."
2672msgstr "Alege ce add-on-uri de stream Stremio importate sau personalizate să folosești. Dacă nu sunt disponibile, autentifică-te sau adaugă un addon personalizat."
2673
2674msgctxt "#90338"
2675msgid "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first."
2676msgstr "Alege ce add-on-uri catalog Stremio importate sau personalizate să folosești. Dacă nu sunt disponibile, autentifică-te sau adaugă un addon personalizat."
2677
2678msgctxt "#90339"
2679msgid "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first."
2680msgstr "Alege ce add-on-uri TV Live Stremio importate sau personalizate să folosești. Dacă nu sunt disponibile, autentifică-te sau adaugă un addon personalizat."
2681
2682msgctxt "#90340"
2683msgid "Remove custom Stremio addons you previously added manually."
2684msgstr "Elimină add-on-urile Stremio personalizate pe care le-ai adăugat manual anterior."
2685
2686msgctxt "#90341"
2687msgid "Choose Torrent Client"
2688msgstr "Alege clientul torrent"
2689
2690msgctxt "#90342"
2691msgid "Clear Database Cache"
2692msgstr "Șterge cache-ul bazei de date"
2693
2694msgctxt "#90343"
2695msgid "Clear Stremio Cache"
2696msgstr "Șterge cache-ul Stremio"
2697
2698msgctxt "#90344"
2699msgid "Clear Debrid Cache"
2700msgstr "Șterge cache-ul Debrid"
2701
2702msgctxt "#90345"
2703msgid "Clear MDBList Cache"
2704msgstr "Șterge cache-ul MDBList"
2705
2706msgctxt "#90346"
2707msgid "Restore backup from"
2708msgstr "Restaurează backup din"
2709
2710msgctxt "#90347"
2711msgid "File"
2712msgstr "Fișier"
2713
2714msgctxt "#90348"
2715msgid "URL"
2716msgstr "URL"
2717
2718msgctxt "#90349"
2719msgid "Enter backup URL"
2720msgstr "Introdu URL-ul de backup"
2721
2722msgctxt "#90350"
2723msgid "No backup URL provided."
2724msgstr "Nu a fost furnizat niciun URL de backup."
2725
2726msgctxt "#90351"
2727msgid "Unable to download settings backup."
2728msgstr "Nu se poate descărca backup-ul setărilor."
2729
2730msgctxt "#90352"
2731msgid "Source Select Style"
2732msgstr "Stil selecție sursă"
2733
2734msgctxt "#90353"
2735msgid "Modern"
2736msgstr "Modern"
2737
2738msgctxt "#90354"
2739msgid "OLED"
2740msgstr "OLED"
2741
2742msgctxt "#90355"
2743msgid "Cyberpunk"
2744msgstr "Cyberpunk"
2745
2746msgctxt "#90356"
2747msgid "Forest"
2748msgstr "Pădure"
2749
2750msgctxt "#90357"
2751msgid "Gold"
2752msgstr "Aur"
2753
2754msgctxt "#90358"
2755msgid "No playable results found"
2756msgstr "Nu s-au găsit rezultate care pot fi redate"
2757
2758msgctxt "#90359"
2759msgid "Add to TorrServer"
2760msgstr "Adaugă în TorrServer"
2761
2762msgctxt "#90360"
2763msgid "Added to TorrServer"
2764msgstr "Adăugat în TorrServer"
2765
2766msgctxt "#90361"
2767msgid "Could not resolve torrent details"
2768msgstr "Nu s-au putut obține detaliile torrentului"
2769
2770msgctxt "#90362"
2771msgid "Added to %s cloud"
2772msgstr "Adăugat în cloud-ul %s"
2773
2774msgctxt "#90363"
2775msgid "Choose Debrid Service"
2776msgstr "Alege serviciul Debrid"
2777
2778msgctxt "#90364"
2779msgid "No supported debrid service is enabled"
2780msgstr "Niciun serviciu debrid compatibil nu este activat"
2781
2782msgctxt "#90365"
2783msgid "Download to Debrid"
2784msgstr "Descarcă în Debrid"
2785
2786msgctxt "#90366"
2787msgid "Include Unknown quality"
2788msgstr "Include calitate necunoscută"
2789
2790msgctxt "#90367"
2791msgid "Includes releases where video quality could not be identified"
2792msgstr "Include versiuni în care calitatea video nu a putut fi identificată"
2793
2794msgctxt "#90368"
2795msgid "Search by Keyword"
2796msgstr "Caută după cuvânt cheie"
2797
2798msgctxt "#90369"
2799msgid "Latest Movies"
2800msgstr "Cele mai recente filme"
2801
2802msgctxt "#90370"
2803msgid "Latest Series"
2804msgstr "Cele mai recente seriale"
2805
2806msgctxt "#90371"
2807msgid "Raw Files"
2808msgstr "Fișiere brute"
2809
2810msgctxt "#90372"
2811msgid "MDblist"
2812msgstr "MDblist"
2813
2814msgctxt "#90373"
2815msgid "Active Downloads"
2816msgstr "Descărcări active"
2817
2818msgctxt "#90374"
2819msgid "Cancel Download"
2820msgstr "Anulează descărcarea"
2821
2822msgctxt "#90375"
2823msgid "Delete File"
2824msgstr "Șterge fișierul"
2825
2826msgctxt "#90376"
2827msgid "Search Collections"
2828msgstr "Caută colecții"
2829
2830msgctxt "#90377"
2831msgid "Popular Collections"
2832msgstr "Colecții populare"
2833
2834msgctxt "#90378"
2835msgid "Top Rated Collections"
2836msgstr "Colecții cu rating mare"
2837
2838msgctxt "#90379"
2839msgid "Recommendations"
2840msgstr "Recomandări"
2841
2842msgctxt "#90380"
2843msgid "Similar"
2844msgstr "Similare"
2845
2846msgctxt "#90381"
2847msgid "Keyword: %s"
2848msgstr "Cuvânt cheie: %s"
2849
2850msgctxt "#90382"
2851msgid "Please set a valid Trakt Client ID Key"
2852msgstr "Setează un Client ID valid pentru Trakt"
2853
2854msgctxt "#90383"
2855msgid "Please set a valid Trakt Client Secret Key"
2856msgstr "Setează o cheie secretă validă pentru Trakt"
2857
2858msgctxt "#90384"
2859msgid "Trakt Error Authorizing"
2860msgstr "Eroare la autorizarea Trakt"
2861
2862msgctxt "#90385"
2863msgid "Trakt Account Authorized"
2864msgstr "Contul Trakt a fost autorizat"
2865
2866msgctxt "#90386"
2867msgid "You are now logged out from Trakt.tv"
2868msgstr "Ești deconectat de la Trakt.tv"
2869
2870msgctxt "#90387"
2871msgid "Failed to mark as watched"
2872msgstr "Marcarea ca vizionat a eșuat"
2873
2874msgctxt "#90388"
2875msgid "Marked as watched"
2876msgstr "Marcat ca vizionat"
2877
2878msgctxt "#90389"
2879msgid "No results found"
2880msgstr "Nu au fost găsite rezultate"
2881
2882msgctxt "#90390"
2883msgid "Invalid mode"
2884msgstr "Mod invalid"
2885
2886msgctxt "#90391"
2887msgid "No recommendations found"
2888msgstr "Nu au fost găsite recomandări"
2889
2890msgctxt "#90392"
2891msgid "No similar items found"
2892msgstr "Nu au fost găsite elemente similare"
2893
2894msgctxt "#90393"
2895msgid "TMDB metadata not found"
2896msgstr "Metadate TMDB negăsite"
2897
2898msgctxt "#90394"
2899msgid "No TV shows found"
2900msgstr "Nu au fost găsite seriale"
2901
2902msgctxt "#90395"
2903msgid "No shows airing today"
2904msgstr "Nu sunt seriale difuzate astăzi"
2905
2906msgctxt "#90396"
2907msgid "Invalid collection query"
2908msgstr "Interogare de colecție invalidă"
2909
2910msgctxt "#90397"
2911msgid "No keywords found"
2912msgstr "Nu au fost găsite cuvinte cheie"
2913
2914msgctxt "#90398"
2915msgid "No keyword ID provided"
2916msgstr "Nu a fost furnizat ID de cuvânt cheie"
2917
2918msgctxt "#90399"
2919msgid "No results found for this keyword"
2920msgstr "Nu au fost găsite rezultate pentru acest cuvânt cheie"
2921
2922msgctxt "#90400"
2923msgid "No TMDB ID found"
2924msgstr "Nu a fost găsit ID TMDB"
2925
2926msgctxt "#90401"
2927msgid "Unsupported media type or missing external_ids"
2928msgstr "Tip media nesuportat sau lipsesc external_ids"
2929
2930msgctxt "#90402"
2931msgid "Refreshed Metadata"
2932msgstr "Metadate actualizate"
2933
2934msgctxt "#90403"
2935msgid "No Stremio addons selected"
2936msgstr "Nu au fost selectate add-on-uri Stremio"
2937
2938msgctxt "#90404"
2939msgid "No suitable source found for auto play."
2940msgstr "Nu a fost găsită nicio sursă potrivită pentru redare automată."
2941
2942msgctxt "#90405"
2943msgid "No sources found with the preferred quality."
2944msgstr "Nu au fost găsite surse cu calitatea preferată."
2945
2946msgctxt "#90406"
2947msgid "No languages found"
2948msgstr "Nu au fost găsite limbi"
2949
2950msgctxt "#90407"
2951msgid "Failed to resolve playback source"
2952msgstr "Nu s-a putut găsi sursa de redare"
2953
2954msgctxt "#90408"
2955msgid "Trakt not authorized. Please authenticate."
2956msgstr "Trakt nu este autorizat. Te rog autentifică-te."
2957
2958msgctxt "#90409"
2959msgid "Added to Trakt Watchlist"
2960msgstr "Adăugat în Watchlist Trakt"
2961
2962msgctxt "#90410"
2963msgid "Error modifying Trakt Watchlist"
2964msgstr "Eroare la modificarea Watchlist-ului Trakt"
2965
2966msgctxt "#90411"
2967msgid "Marked as Watched on Trakt"
2968msgstr "Marcat ca vizionat pe Trakt"
2969
2970msgctxt "#90412"
2971msgid "Error modifying Trakt Watched status"
2972msgstr "Eroare la modificarea statusului „vizionat” pe Trakt"
2973
2974msgctxt "#90413"
2975msgid "Play"
2976msgstr "Redare"
2977
2978msgctxt "#90414"
2979msgid "Trailer"
2980msgstr "Trailer"
2981
2982msgctxt "#90415"
2983msgid "Watched"
2984msgstr "Vizionat"
2985
2986msgctxt "#90416"
2987msgid "Refresh"
2988msgstr "Reîmprospătează"
2989
2990msgctxt "#90418"
2991msgid "No credits found for this ID"
2992msgstr "Nu au fost găsite credite pentru acest ID"
2993
2994msgctxt "#90419"
2995msgid "Credited People"
2996msgstr "Persoane creditate"
2997
2998msgctxt "#90420"
2999msgid "Person Details"
3000msgstr "Detalii persoană"
3001
3002msgctxt "#90421"
3003msgid "Person not found"
3004msgstr "Persoană negăsită"
3005
3006msgctxt "#90422"
3007msgid "Jackgram not activated"
3008msgstr "Jackgram nu este activat"
3009
3010msgctxt "#90423"
3011msgid "Not yet implemented"
3012msgstr "Încă neimplementat"
3013
3014msgctxt "#90424"
3015msgid "Unsupported debrid type"
3016msgstr "Tip debrid nesuportat"
3017
3018msgctxt "#90425"
3019msgid "Easynews credentials required"
3020msgstr "Sunt necesare credențiale Easynews"
3021
3022msgctxt "#90426"
3023msgid "No Corrupt or Missing Databases"
3024msgstr "Nu există baze de date corupte sau lipsă"
3025
3026msgctxt "#90427"
3027msgid "Otaku (script.otaku.mappings) not found"
3028msgstr "Otaku (script.otaku.mappings) nu a fost găsit"
3029
3030msgctxt "#90428"
3031msgid "Database read error. File may be corrupted."
3032msgstr "Eroare la citirea bazei de date. Fișierul poate fi corupt."
3033
3034msgctxt "#90429"
3035msgid "Failed to delete data. Database error."
3036msgstr "Ștergerea datelor a eșuat. Eroare de bază de date."
3037
3038msgctxt "#90430"
3039msgid "Failed to clear cache. Database in use."
3040msgstr "Ștergerea cache-ului a eșuat. Baza de date este în uz."
3041
3042msgctxt "#90431"
3043msgid "No items found in this list"
3044msgstr "Nu au fost găsite elemente în această listă"
3045
3046msgctxt "#90432"
3047msgid "Databases Rebuilt"
3048msgstr "Bazele de date au fost reconstruite"
3049
3050msgctxt "#90433"
3051msgid "[B]Following Databases Rebuilt:[/B][CR][CR]%s"
3052msgstr "[B]Următoarele baze de date au fost reconstruite:[/B][CR][CR]%s"
3053
3054msgctxt "#90434"
3055msgid "Added to Trakt watchlist"
3056msgstr "Adăugat în watchlist Trakt"
3057
3058msgctxt "#90435"
3059msgid "Failed to add to Trakt watchlist"
3060msgstr "Adăugarea în watchlist Trakt a eșuat"
3061
3062msgctxt "#90436"
3063msgid "Removed from Trakt watchlist"
3064msgstr "Eliminat din lista de vizionare Trakt"
3065
3066msgctxt "#90437"
3067msgid "Failed to remove from Trakt watchlist"
3068msgstr "Nu s-a putut elimina din lista de vizionare Trakt"
3069
3070msgctxt "#90438"
3071msgid "Failed to mark as watched on Trakt"
3072msgstr "Nu s-a putut marca drept vizionat pe Trakt"
3073
3074msgctxt "#90439"
3075msgid "Marked as unwatched on Trakt"
3076msgstr "Marcat ca nevizionat pe Trakt"
3077
3078msgctxt "#90440"
3079msgid "Failed to mark as unwatched on Trakt"
3080msgstr "Nu s-a putut marca ca nevizionat pe Trakt"
3081
3082msgctxt "#90441"
3083msgid "Added to Trakt collection"
3084msgstr "Adăugat la colecția Trakt"
3085
3086msgctxt "#90442"
3087msgid "Removed from Trakt collection"
3088msgstr "Eliminat din colecția Trakt"
3089
3090msgctxt "#90443"
3091msgid "Failed to add to Trakt collection"
3092msgstr "Nu s-a putut adăuga la colecția Trakt"
3093
3094msgctxt "#90444"
3095msgid "Failed to remove from Trakt collection"
3096msgstr "Nu s-a putut elimina din colecția Trakt"
3097
3098msgctxt "#90445"
3099msgid "Added to Trakt favorites"
3100msgstr "Adăugat la favorite Trakt"
3101
3102msgctxt "#90446"
3103msgid "Removed from Trakt favorites"
3104msgstr "Eliminat din favorite Trakt"
3105
3106msgctxt "#90447"
3107msgid "Failed to add to Trakt favorites"
3108msgstr "Nu s-a putut adăuga la favorite Trakt"
3109
3110msgctxt "#90448"
3111msgid "Failed to remove from Trakt favorites"
3112msgstr "Nu s-a putut elimina din favorite Trakt"
3113
3114msgctxt "#90449"
3115msgid "Created Trakt list"
3116msgstr "Listă Trakt creată"
3117
3118msgctxt "#90450"
3119msgid "Failed to create Trakt list"
3120msgstr "Nu s-a putut crea lista Trakt"
3121
3122msgctxt "#90451"
3123msgid "Deleted Trakt list"
3124msgstr "Listă Trakt ștearsă"
3125
3126msgctxt "#90452"
3127msgid "Failed to delete Trakt list"
3128msgstr "Nu s-a putut șterge lista Trakt"
3129
3130msgctxt "#90453"
3131msgid "Liked Trakt list"
3132msgstr "Lista Trakt apreciată"
3133
3134msgctxt "#90454"
3135msgid "Failed to like Trakt list"
3136msgstr "Nu s-a putut aprecia lista Trakt"
3137
3138msgctxt "#90455"
3139msgid "Unliked Trakt list"
3140msgstr "Aprecierea pentru lista Trakt a fost eliminată"
3141
3142msgctxt "#90456"
3143msgid "Failed to unlike Trakt list"
3144msgstr "Nu s-a putut elimina aprecierea pentru lista Trakt"
3145
3146msgctxt "#90457"
3147msgid "Added to Trakt list"
3148msgstr "Adăugat la lista Trakt"
3149
3150msgctxt "#90458"
3151msgid "OpenSubtitles Stremio: No results found."
3152msgstr "OpenSubtitles Stremio: Nu s-au găsit rezultate."
3153
3154msgctxt "#90459"
3155msgid "Failed to add to Trakt list"
3156msgstr "Nu s-a putut adăuga la lista Trakt"
3157
3158msgctxt "#90460"
3159msgid "Subtitle Manager: No subtitles available for this video."
3160msgstr "Manager subtitrări: Nu sunt subtitrări disponibile pentru acest video."
3161
3162msgctxt "#90461"
3163msgid "No Trakt lists found"
3164msgstr "Nu s-au găsit liste Trakt"
3165
3166msgctxt "#90462"
3167msgid "Item not found in Trakt collection"
3168msgstr "Elementul nu a fost găsit în colecția Trakt"
3169
3170msgctxt "#90463"
3171msgid "Trakt could not match this item for collection"
3172msgstr "Trakt nu a putut asocia acest element pentru colecție"
3173
3174msgctxt "#90464"
3175msgid "No Trakt collection changes were applied"
3176msgstr "Nu au fost aplicate modificări în colecția Trakt"
3177
3178msgctxt "#90465"
3179msgid "Could not build Trakt collection payload for show"
3180msgstr "Nu s-a putut construi payload-ul colecției Trakt pentru serial"
3181
3182msgctxt "#90466"
3183msgid "Delete this Trakt list?"
3184msgstr "Ștergeți această listă Trakt?"
3185
3186msgctxt "#90468"
3187msgid "Search menu"
3188msgstr "Meniu de căutare"
3189
3190msgctxt "#90469"
3191msgid "Continue watching menu"
3192msgstr "Meniu „Continuă vizionarea”"
3193
3194msgctxt "#90470"
3195msgid "TV Shows Items"
3196msgstr "Elemente seriale TV"
3197
3198msgctxt "#90471"
3199msgid "Movies Items"
3200msgstr "Elemente filme"
3201
3202msgctxt "#90472"
3203msgid "Anime menu"
3204msgstr "Meniu anime"
3205
3206msgctxt "#90473"
3207msgid "TV Menu"
3208msgstr "Meniu TV"
3209
3210msgctxt "#90474"
3211msgid "Library menu"
3212msgstr "Meniu bibliotecă"
3213
3214msgctxt "#90475"
3215msgid "Torrents Management"
3216msgstr "Gestionare torrente"
3217
3218msgctxt "#90476"
3219msgid "Telegram menu"
3220msgstr "Meniu Telegram"
3221
3222msgctxt "#90477"
3223msgid "Cloud Storage"
3224msgstr "Stocare în cloud"
3225
3226msgctxt "#90478"
3227msgid "Downloads Management"
3228msgstr "Gestionare descărcări"
3229
3230msgctxt "#90479"
3231msgid "Settings Management"
3232msgstr "Gestionare setări"
3233
3234msgctxt "#90480"
3235msgid "History Menu"
3236msgstr "Meniu istoric"
3237
3238msgctxt "#90481"
3239msgid "Files History"
3240msgstr "Istoric fișiere"
3241
3242msgctxt "#90482"
3243msgid "Titles History"
3244msgstr "Istoric titluri"
3245
3246msgctxt "#90483"
3247msgid "Titles Calendar"
3248msgstr "Calendar titluri"
3249
3250msgctxt "#90484"
3251msgid "My Shows"
3252msgstr "Serialele mele"
3253
3254msgctxt "#90485"
3255msgid "My Movies"
3256msgstr "Filmele mele"
3257
3258msgctxt "#90486"
3259msgid "Upcoming Episodes"
3260msgstr "Episoade viitoare"
3261
3262msgctxt "#90487"
3263msgid "Discovery List"
3264msgstr "Listă discovery"
3265
3266msgctxt "#90488"
3267msgid "Library List"
3268msgstr "Listă bibliotecă"
3269
3270msgctxt "#90489"
3271msgid "Trakt Recommendations"
3272msgstr "Recomandări Trakt"
3273
3274msgctxt "#90490"
3275msgid "Trending Lists"
3276msgstr "Liste în tendințe"
3277
3278msgctxt "#90491"
3279msgid "Popular Lists"
3280msgstr "Liste populare"
3281
3282msgctxt "#90492"
3283msgid "Search Lists"
3284msgstr "Căutare liste"
3285
3286msgctxt "#90493"
3287msgid "Recent Activity"
3288msgstr "Activitate recentă"
3289
3290msgctxt "#90494"
3291msgid "Downloads List"
3292msgstr "Listă descărcări"
3293
3294msgctxt "#90495"
3295msgid "Download New"
3296msgstr "Descarcă nou"
3297
3298msgctxt "#90496"
3299msgid "Cartoons Popular"
3300msgstr "Desene animate populare"
3301
3302msgctxt "#90497"
3303msgid "Animation Popular"
3304msgstr "Animații populare"
3305
3306msgctxt "#90500"
3307msgid "Translating subtitle..."
3308msgstr "Se traduce subtitrarea..."
3309
3310msgctxt "#90501"
3311msgid "Translation done."
3312msgstr "Traducere finalizată."
3313
3314msgctxt "#90502"
3315msgid "Searching subtitles..."
3316msgstr "Se caută subtitrări..."
3317
3318msgctxt "#90503"
3319msgid "No subtitles found"
3320msgstr "Nu s-au găsit subtitrări"
3321
3322msgctxt "#90504"
3323msgid "Failed to authenticate with DeepL. Please check your API key."
3324msgstr "Nu s-a putut autentifica cu DeepL. Verificați cheia API."
3325
3326msgctxt "#90505"
3327msgid "DeepL API key is missing or invalid."
3328msgstr "Cheia API DeepL lipsește sau este invalidă."
3329
3330msgctxt "#90510"
3331msgid "No meta available"
3332msgstr "Nu sunt metadate disponibile"
3333
3334msgctxt "#90511"
3335msgid "No content available for"
3336msgstr "Nu există conținut disponibil pentru"
3337
3338msgctxt "#90512"
3339msgid "Season"
3340msgstr "Sezon"
3341
3342msgctxt "#90513"
3343msgid "No episodes available"
3344msgstr "Nu sunt episoade disponibile"
3345
3346msgctxt "#90514"
3347msgid "No videos available"
3348msgstr "Nu sunt videoclipuri disponibile"
3349
3350msgctxt "#90515"
3351msgid "Next Page"
3352msgstr "Pagina următoare"
3353
3354msgctxt "#90516"
3355msgid "No metas available"
3356msgstr "Nu sunt metadate disponibile"
3357
3358msgctxt "#90517"
3359msgid "No seasons available"
3360msgstr "Nu sunt sezoane disponibile"
3361
3362msgctxt "#90518"
3363msgid "Invalid query"
3364msgstr "Interogare invalidă"
3365
3366msgctxt "#90519"
3367msgid "No lists found"
3368msgstr "Nu s-au găsit liste"
3369
3370msgctxt "#90520"
3371msgid "No user lists found"
3372msgstr "Nu s-au găsit liste de la utilizatori"
3373
3374msgctxt "#90521"
3375msgid "No top lists found"
3376msgstr "Nu s-au găsit liste de top"
3377
3378msgctxt "#90522"
3379msgid "Custom Addon"
3380msgstr "Addon personalizat"
3381
3382msgctxt "#90523"
3383msgid "Enter the custom Stremio addon URL"
3384msgstr "Introduceți URL-ul add-on-ului Stremio personalizat"
3385
3386msgctxt "#90524"
3387msgid "No URL provided."
3388msgstr "Nu a fost furnizat niciun URL."
3389
3390msgctxt "#90525"
3391msgid "Failed to fetch manifest: %s"
3392msgstr "Nu s-a putut prelua manifestul: %s"
3393
3394msgctxt "#90526"
3395msgid "Manifest missing 'id' or 'name'."
3396msgstr "Manifestul nu conține 'id' sau 'nume'."
3397
3398msgctxt "#90527"
3399msgid "Custom Stremio addon added successfully!"
3400msgstr "Addon Stremio personalizat adăugat cu succes!"
3401
3402msgctxt "#90528"
3403msgid "Addon does not provide 'stream' or 'catalog' resources."
3404msgstr "Add-on-ul nu oferă resurse 'stream' sau 'catalog'."
3405
3406msgctxt "#90529"
3407msgid "This addon is already added to your list."
3408msgstr "Acest addon este deja adăugat în listă."
3409
3410msgctxt "#90530"
3411msgid "Failed to add custom addon: %s"
3412msgstr "Nu s-a putut adăuga add-on-ul personalizat: %s"
3413
3414msgctxt "#90531"
3415msgid "Remove Addon"
3416msgstr "Elimină addon"
3417
3418msgctxt "#90532"
3419msgid "No custom Stremio addons to remove."
3420msgstr "Nu există add-on-uri Stremio personalizate de eliminat."
3421
3422msgctxt "#90533"
3423msgid "Remove Stremio Addons"
3424msgstr "Elimină add-on-uri Stremio"
3425
3426msgctxt "#90534"
3427msgid "Selected addon(s) removed."
3428msgstr "Add-on-ul/urile selectat(e) au fost eliminate."
3429
3430msgctxt "#90535"
3431msgid "Untitled List"
3432msgstr "Listă fără titlu"
3433
3434msgctxt "#90536"
3435msgid "Removed from Trakt list"
3436msgstr "Eliminat din lista Trakt"
3437
3438msgctxt "#90537"
3439msgid "Failed to remove from Trakt list"
3440msgstr "Nu s-a putut elimina din lista Trakt"
3441
3442msgctxt "#90538"
3443msgid "Resume from %.1f%%"
3444msgstr "Reia de la %.1f%%"
3445
3446msgctxt "#90539"
3447msgid "Start from Beginning"
3448msgstr "Începe de la început"
3449
3450msgctxt "#90540"
3451msgid "Authorize Debrid Services"
3452msgstr "Autorizează serviciile Debrid"
3453
3454msgctxt "#90541"
3455msgid "Navigate to: [B]%s[/B]"
3456msgstr "Accesați: [B]%s[/B]"
3457
3458msgctxt "#90542"
3459msgid "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]"
3460msgstr "Introduceți codul: [COLOR orangered][B]%s[/B][/COLOR]"
3461
3462msgctxt "#90543"
3463msgid "Premiumize Auth"
3464msgstr "Autentificare Premiumize"
3465
3466msgctxt "#90544"
3467msgid "Success"
3468msgstr "Succes"
3469
3470msgctxt "#90545"
3471msgid "Authentication completed."
3472msgstr "Autentificare finalizată."
3473
3474msgctxt "#90546"
3475msgid "Error:"
3476msgstr "Eroare:"
3477
3478msgctxt "#90547"
3479msgid "Error: %s"
3480msgstr "Eroare: %s"
3481
3482msgctxt "#90548"
3483msgid "Auth Error"
3484msgstr "Eroare de autentificare"
3485
3486msgctxt "#90549"
3487msgid "Torbox authentication successful."
3488msgstr "Autentificare Torbox reușită."
3489
3490msgctxt "#90554"
3491msgid "Please wait..."
3492msgstr "Vă rugăm așteptați..."
3493
3494msgctxt "#90555"
3495msgid "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3496msgstr "Accesați:\n[COLOR cyan]%s[/COLOR]\nIntroduceți codul: [COLOR seagreen][B]%s[/B][/COLOR]"
3497
3498msgctxt "#90556"
3499msgid "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3500msgstr "Accesați:\n[COLOR cyan]%s[/COLOR]\n\nIntroduceți codul: [COLOR seagreen][B]%s[/B][/COLOR]"
3501
3502msgctxt "#90557"
3503msgid "Pastebin Link:\n[COLOR cyan]%s[/COLOR]"
3504msgstr "Link Pastebin:\n[COLOR cyan]%s[/COLOR]"
3505
3506msgctxt "#90670"
3507msgid "Local URL:\n[COLOR cyan]%s[/COLOR]"
3508msgstr "URL local:\n[COLOR cyan]%s[/COLOR]"
3509
3510msgctxt "#90558"
3511msgid "QR Code Authentication"
3512msgstr "Autentificare cod QR"
3513
3514msgctxt "#90559"
3515msgid "Kodi 20 or above required. Please update Kodi to use this addon."
3516msgstr "Este necesar Kodi 20 sau mai nou. Actualizați Kodi pentru a folosi acest addon."
3517
3518msgctxt "#90560"
3519msgid "Failed to start web server. Port may be in use."
3520msgstr "Nu s-a putut porni serverul web. Portul poate fi ocupat."
3521
3522msgctxt "#90561"
3523msgid "Authentification Removed."
3524msgstr "Autentificare eliminată."
3525
3526msgctxt "#90562"
3527msgid "WebDAV Error"
3528msgstr "Eroare WebDAV"
3529
3530msgctxt "#90563"
3531msgid "Trakt Authorization"
3532msgstr "Autorizare Trakt"
3533
3534msgctxt "#90564"
3535msgid "Do you want to continue transfer in background?"
3536msgstr "Doriți să continuați transferul în fundal?"
3537
3538msgctxt "#90565"
3539msgid "Cloud Transfer"
3540msgstr "Transfer în cloud"
3541
3542msgctxt "#90566"
3543msgid "Kodi Logs"
3544msgstr "Log-uri Kodi"
3545
3546msgctxt "#90567"
3547msgid "Show Logs"
3548msgstr "Afișează log-urile"
3549
3550msgctxt "#90568"
3551msgid "Export to paste.kodi.tv"
3552msgstr "Exportă pe paste.kodi.tv"
3553
3554msgctxt "#90569"
3555msgid "Kodi Logs Exported"
3556msgstr "Log-urile Kodi au fost exportate"
3557
3558msgctxt "#90570"
3559msgid "Providers Selection Dialog"
3560msgstr "Dialog selectare provideri"
3561
3562msgctxt "#90571"
3563msgid "Your current Providers are: \n%s\n\nDo you want to change?"
3564msgstr "Providerii actuali sunt: \n%s\n\nDoriți să îi schimbați?"
3565
3566msgctxt "#90572"
3567msgid "Select Providers"
3568msgstr "Selectează provideri"
3569
3570msgctxt "#90573"
3571msgid "Selection Dialog"
3572msgstr "Dialog de selecție"
3573
3574msgctxt "#90574"
3575msgid "Successfully selected: %s"
3576msgstr "Selectat cu succes: %s"
3577
3578msgctxt "#90575"
3579msgid "Selection"
3580msgstr "Selecție"
3581
3582msgctxt "#90576"
3583msgid "No providers selected"
3584msgstr "Niciun provider selectat"
3585
3586msgctxt "#90577"
3587msgid "Changelog"
3588msgstr "Listă modificări"
3589
3590msgctxt "#90578"
3591msgid "[B]Unable to check for updates[/B]"
3592msgstr "[B]Nu s-au putut verifica actualizările[/B]"
3593
3594msgctxt "#90579"
3595msgid "[B]No update available[/B]"
3596msgstr "[B]Nu există actualizări disponibile[/B]"
3597
3598msgctxt "#90580"
3599msgid "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]"
3600msgstr "Instalat: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]"
3601
3602msgctxt "#90581"
3603msgid "[B]Update available. Do you want to update?[/B]"
3604msgstr "[B]Actualizare disponibilă. Doriți să actualizați?[/B]"
3605
3606msgctxt "#90582"
3607msgid "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]"
3608msgstr "[B]Actualizare disponibilă (v%s).[/B] [I]Verificați setările pentru actualizare.[/I]"
3609
3610msgctxt "#90583"
3611msgid "Error fetching versions: %s"
3612msgstr "Eroare la obținerea versiunilor: %s"
3613
3614msgctxt "#90584"
3615msgid "No older/different versions found."
3616msgstr "Nu au fost găsite versiuni mai vechi/diferite."
3617
3618msgctxt "#90585"
3619msgid "Select Version to Downgrade"
3620msgstr "Selectează versiunea pentru downgrade"
3621
3622msgctxt "#90586"
3623msgid "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?"
3624msgstr "Sigur doriți să faceți downgrade/reinstalare la versiunea [B]%s[/B]?"
3625
3626msgctxt "#90587"
3627msgid "[B]Downloading update...[/B]"
3628msgstr "[B]Se descarcă actualizarea...[/B]"
3629
3630msgctxt "#90588"
3631msgid "Error: Unable to download update."
3632msgstr "Eroare: Nu s-a putut descărca actualizarea."
3633
3634msgctxt "#90589"
3635msgid "Error saving update file: %s"
3636msgstr "Eroare la salvarea fișierului de actualizare: %s"
3637
3638msgctxt "#90590"
3639msgid "Error extracting update. Please install manually."
3640msgstr "Eroare la extragerea actualizării. Vă rugăm instalați manual."
3641
3642msgctxt "#90591"
3643msgid "Do you want to view the changelog for the new version?"
3644msgstr "Doriți să vedeți modificările pentru noua versiune?"
3645
3646msgctxt "#90592"
3647msgid "New Release v%s - Changelog"
3648msgstr "Versiune nouă v%s - listă de modificări"
3649
3650msgctxt "#90593"
3651msgid "[B]Update complete.[/B]"
3652msgstr "[B]Actualizare finalizată.[/B]"
3653
3654msgctxt "#90594"
3655msgid "Real Debrid Auth"
3656msgstr "Autentificare Real Debrid"
3657
3658msgctxt "#90595"
3659msgid "Torbox Auth"
3660msgstr "Autentificare Torbox"
3661
3662msgctxt "#90596"
3663msgid "Debrider Auth"
3664msgstr "Autentificare Debrider"
3665
3666msgctxt "#90597"
3667msgid "Download Error"
3668msgstr "Eroare de descărcare"
3669
3670msgctxt "#90598"
3671msgid "Failed to download text: %s"
3672msgstr "Nu s-a putut descărca textul: %s"
3673
3674msgctxt "#90599"
3675msgid "General Error"
3676msgstr "Eroare generală"
3677
3678msgctxt "#90600"
3679msgid "Could not display text file."
3680msgstr "Nu s-a putut afișa fișierul text."
3681
3682msgctxt "#90601"
3683msgid "Failed to export logs."
3684msgstr "Nu s-au putut exporta log-urile."
3685
3686msgctxt "#90602"
3687msgid "Error reading log: %s"
3688msgstr "Eroare la citirea log-ului: %s"
3689
3690msgctxt "#90603"
3691msgid "Kodi log file not found."
3692msgstr "Fișierul log Kodi nu a fost găsit."
3693
3694msgctxt "#90604"
3695msgid "%s Auth"
3696msgstr "Autentificare %s"
3697
3698msgctxt "#90605"
3699msgid "Ok"
3700msgstr "Ok"
3701
3702msgctxt "#90606"
3703msgid "No"
3704msgstr "Nu"
3705
3706msgctxt "#90607"
3707msgid "Pinging addons for availability..."
3708msgstr "Se verifică disponibilitatea add-on-urilor..."
3709
3710msgctxt "#90608"
3711msgid "Pinging addons... %d/%d"
3712msgstr "Se verifică add-on-urile... %d/%d"
3713
3714msgctxt "#90609"
3715msgid "Ping Results"
3716msgstr "Rezultate ping"
3717
3718msgctxt "#90610"
3719msgid "Reachable: %d\nUnreachable: %d"
3720msgstr "Disponibile: %d\nIndisponibile: %d"
3721
3722msgctxt "#90611"
3723msgid "Select Category to Filter"
3724msgstr "Selectează categoria pentru filtrare"
3725
3726msgctxt "#90612"
3727msgid "Stremio Add-ons Import"
3728msgstr "Import add-on-uri Stremio"
3729
3730msgctxt "#90613"
3731msgid "To import your add-ons, please log in with your Stremio email and password."
3732msgstr "Pentru a importa add-on-urile, autentificați-vă cu email-ul și parola Stremio."
3733
3734msgctxt "#90614"
3735msgid "Enter your Email"
3736msgstr "Introduceți email-ul"
3737
3738msgctxt "#90615"
3739msgid "Enter your Password"
3740msgstr "Introduceți parola"
3741
3742msgctxt "#90616"
3743msgid "Login Failed"
3744msgstr "Autentificare eșuată"
3745
3746msgctxt "#90617"
3747msgid "Failed to login: %s"
3748msgstr "Autentificare eșuată: %s"
3749
3750msgctxt "#90618"
3751msgid "Add-ons Import Failed"
3752msgstr "Importul add-on-urilor a eșuat"
3753
3754msgctxt "#90619"
3755msgid "Please try again later and report the issue if the problem persists. For more details, check the log file."
3756msgstr "Încercați din nou mai târziu și raportați problema dacă persistă. Pentru detalii, consultați fișierul log."
3757
3758msgctxt "#90620"
3759msgid "Addons Imported"
3760msgstr "Add-on-uri importate"
3761
3762msgctxt "#90621"
3763msgid "Successfully imported addons from your account."
3764msgstr "Add-on-urile au fost importate cu succes din contul dvs."
3765
3766msgctxt "#90622"
3767msgid "Update Stremio Addons"
3768msgstr "Actualizează add-on-urile Stremio"
3769
3770msgctxt "#90623"
3771msgid "Do you want to update the Addons from your account?"
3772msgstr "Doriți să actualizați add-on-urile din contul dvs?"
3773
3774msgctxt "#90624"
3775msgid "Log Out from Stremio"
3776msgstr "Deconectare din Stremio"
3777
3778msgctxt "#90625"
3779msgid "Are you sure you want to log out? You can continue using Stremio without logging in, but your settings will be reset to the default configuration."
3780msgstr "Sigur doriți să vă deconectați? Puteți continua să folosiți Stremio fără autentificare, dar setările vor fi resetate la configurația implicită."
3781
3782msgctxt "#90626"
3783msgid "Log Out"
3784msgstr "Deconectare"
3785
3786msgctxt "#90627"
3787msgid "Use Existing"
3788msgstr "Folosește cel existent"
3789
3790msgctxt "#90628"
3791msgid "Download New"
3792msgstr "Descarcă nou"
3793
3794msgctxt "#90629"
3795msgid "Playback Failed"
3796msgstr "Redarea a eșuat"
3797
3798msgctxt "#90630"
3799msgid "The video could not be played.\nDo you want to retry?"
3800msgstr "Videoclipul nu a putut fi redat.\nDoriți să reîncercați?"
3801
3802msgctxt "#90631"
3803msgid "Retry"
3804msgstr "Reîncearcă"
3805
3806msgctxt "#90632"
3807msgid "Information"
3808msgstr "Informații"
3809
3810msgctxt "#90633"
3811msgid "Review"
3812msgstr "Recenzie"
3813
3814msgctxt "#90634"
3815msgid "Comment"
3816msgstr "Comentariu"
3817
3818msgctxt "#90635"
3819msgid "Trivia"
3820msgstr "Curiozități"
3821
3822msgctxt "#90636"
3823msgid "Goof"
3824msgstr "Gafă"
3825
3826msgctxt "#90637"
3827msgid "Parental Guide"
3828msgstr "Ghid parental"
3829
3830msgctxt "#90638"
3831msgid "Converting Magnet...\n\n%s"
3832msgstr "Se convertește magnetul...\n\n%s"
3833
3834msgctxt "#90639"
3835msgid "Real Debrid Error."
3836msgstr "Eroare Real Debrid."
3837
3838msgctxt "#90640"
3839msgid "File already cached"
3840msgstr "Fișier deja în cache"
3841
3842msgctxt "#90641"
3843msgid "Saving file to the Real Debrid Cloud"
3844msgstr "Se salvează fișierul în cloud-ul Real Debrid"
3845
3846msgctxt "#90642"
3847msgid "File cached"
3848msgstr "Fișier în cache"
3849
3850msgctxt "#90643"
3851msgid "Saving File to the Real Debrid Cloud...\n%s\n\n"
3852msgstr "Se salvează fișierul în cloud-ul Real Debrid...\n%s\n\n"
3853
3854msgctxt "#90644"
3855msgid "Downloading %s GB @ %s mbps from %s peers, %s %% completed"
3856msgstr "Se descarcă %s GB @ %s mbps de la %s peers, %s %% finalizat"
3857
3858msgctxt "#90645"
3859msgid "Trakt"
3860msgstr "Trakt"
3861
3862msgctxt "#90651"
3863msgid "Easynews Account Info"
3864msgstr "Informații cont Easynews"
3865
3866msgctxt "#90652"
3867msgid "DeepL API Error"
3868msgstr "Eroare API DeepL"
3869
3870msgctxt "#90653"
3871msgid "Download"
3872msgstr "Descărcare"
3873
3874msgctxt "#90654"
3875msgid "Failed to start download: %s"
3876msgstr "Nu s-a putut porni descărcarea: %s"
3877
3878msgctxt "#90655"
3879msgid "Modify Search"
3880msgstr "Modifică căutarea"
3881
3882msgctxt "#90656"
3883msgid "Torbox"
3884msgstr "Torbox"
3885
3886msgctxt "#90657"
3887msgid "Easy-Debrid"
3888msgstr "Easy-Debrid"
3889
3890msgctxt "#90658"
3891msgid "All-Debrid"
3892msgstr "All-Debrid"
3893
3894msgctxt "#90659"
3895msgid "Real-Debrid"
3896msgstr "Real-Debrid"
3897
3898msgctxt "#90660"
3899msgid "Getting results from providers..."
3900msgstr "Se obțin rezultate de la provideri..."
3901
3902msgctxt "#90661"
3903msgid "Jacktook [COLOR FFFF6B00]%s[/COLOR]"
3904msgstr "Jacktook [COLOR FFFF6B00]%s[/COLOR]"
3905
3906msgctxt "#90662"
3907msgid "Downloads Viewer"
3908msgstr "Vizualizator descărcări"
3909
3910msgctxt "#90663"
3911msgid "WebDAV Text Viewer"
3912msgstr "Vizualizator text WebDAV"
3913
3914msgctxt "#90664"
3915msgid "Selected Item"
3916msgstr "Element selectat"
3917
3918msgctxt "#90665"
3919msgid "Subtitle No. %d"
3920msgstr "Subtitrare nr. %d"
3921
3922msgctxt "#90666"
3923msgid "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?"
3924msgstr "Utilizare gratuită DeepL disponibilă!\nCaractere rămase în cota gratuită: %s\nCaractere de tradus: %s\n\nDoriți să continuați traducerea?"
3925
3926msgctxt "#90667"
3927msgid "Free DeepL Usage"
3928msgstr "Utilizare gratuită DeepL"
3929
3930msgctxt "#90668"
3931msgid "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?"
3932msgstr "Total caractere de tradus: %s\nCost estimat: $%.2f USD\n\nDoriți să continuați traducerea?"
3933
3934msgctxt "#90669"
3935msgid "Translation Cost"
3936msgstr "Cost traducere"
3937
3938msgctxt "#90672"
3939msgid "Play Trailer"
3940msgstr "Redă trailer"
3941
3942msgctxt "#90673"
3943msgid "Trailer unavailable"
3944msgstr "Trailer indisponibil"
3945
3946msgctxt "#90690"
3947msgid "Clear All Items"
3948msgstr "Șterge toate elementele"
3949
3950msgctxt "#90691"
3951msgid "Remove all items from this library?"
3952msgstr "Ștergeți toate elementele din această bibliotecă?"
3953
3954msgctxt "#90692"
3955msgid "Library cleared"
3956msgstr "Bibliotecă ștearsă"
3957
3958msgctxt "#90693"
3959msgid "No items to remove"
3960msgstr "Nu există elemente de șters"
3961
3962msgctxt "#90694"
3963msgid "Added to %s cloud but still downloading. Please wait before playing."
3964msgstr "Adăugat în cloud %s, dar încă se descarcă. Așteptați înainte de redare."
3965
3966msgctxt "#90695"
3967msgid "Torrent Not Ready"
3968msgstr "Torrentul nu este gata"
3969
3970msgctxt "#90696"
3971msgid "This torrent is still downloading in %s and is not ready yet."
3972msgstr "Acest torrent încă se descarcă în %s și nu este încă gata."
3973
3974msgctxt "#90697"
3975msgid "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?"
3976msgstr "Doriți să redați folosind clientul torrent (poate fi mai lent) sau să anulați și să așteptați ca debridul să finalizeze?"
3977
3978msgctxt "#90698"
3979msgid "Play with Torrent"
3980msgstr "Redă cu torrent"
3981
3982msgctxt "#90699"
3983msgid "Cancel & Wait"
3984msgstr "Anulează și așteaptă"
3985
3986msgctxt "#90712"
3987msgid "Views"
3988msgstr "Vizualizări"
3989
3990msgctxt "#90713"
3991msgid "Set Main View"
3992msgstr "Setează vizualizarea principală"
3993
3994msgctxt "#90714"
3995msgid "Set Movies View"
3996msgstr "Setează vizualizarea filmelor"
3997
3998msgctxt "#90715"
3999msgid "Set TV Shows View"
4000msgstr "Setează vizualizarea serialelor"
4001
4002msgctxt "#90716"
4003msgid "Set Seasons View"
4004msgstr "Setează vizualizarea sezonelor"
4005
4006msgctxt "#90717"
4007msgid "Set Episodes View"
4008msgstr "Setează vizualizarea episoadelor"
4009
4010msgctxt "#90718"
4011msgid "Set Library View"
4012msgstr "Setează vizualizarea bibliotecii"
4013
4014msgctxt "#90719"
4015msgid "Set History View"
4016msgstr "Setează vizualizarea istoricului"
4017
4018msgctxt "#90720"
4019msgid "Set Downloads View"
4020msgstr "Setează vizualizarea descărcărilor"
4021
4022msgctxt "#90721"
4023msgid "Save Current View"
4024msgstr "Salvează vizualizarea curentă"
4025
4026msgctxt "#90722"
4027msgid "View saved"
4028msgstr "Vizualizare salvată"
4029
4030msgctxt "#90723"
4031msgid "Could not detect the current view"
4032msgstr "Nu s-a putut detecta vizualizarea curentă"
4033
4034msgctxt "#90724"
4035msgid "Saved views reset"
4036msgstr "Vizualizările salvate au fost resetate"
4037
4038msgctxt "#90725"
4039msgid "Reset Saved Views"
4040msgstr "Resetează vizualizările salvate"
4041
4042msgctxt "#90726"
4043msgid "Open this screen, change the Kodi view manually, then select Save Current View."
4044msgstr "Deschideți acest ecran, schimbați manual vizualizarea Kodi, apoi selectați „Salvează vizualizarea curentă”."
4045
4046msgctxt "#90742"
4047msgid "Addon Settings"
4048msgstr "Setări add-on"
4049
4050msgctxt "#90743"
4051msgid "Set Views"
4052msgstr "Setează vizualizări"
4053
4054msgctxt "#90730"
4055msgid "Search with title + year"
4056msgstr "Căutare cu titlu + an"
4057
4058msgctxt "#90731"
4059msgid "Search with original title"
4060msgstr "Căutare cu titlu original"
4061
4062msgctxt "#90732"
4063msgid "Search with original title + year"
4064msgstr "Căutare cu titlul original + an"
4065
4066msgctxt "#90733"
4067msgid "Search modes"
4068msgstr "Moduri de căutare"
4069
4070msgctxt "#90734"
4071msgid "Enter year"
4072msgstr "Introduceți anul"
4073
4074msgctxt "#90735"
4075msgid "Invalid year"
4076msgstr "An invalid"
4077
4078msgctxt "#90736"
4079msgid "Edit title"
4080msgstr "Editează titlul"
4081
4082msgctxt "#90512"
4083msgid "Season"
4084msgstr "Sezon"
4085
4086msgctxt "#90737"
4087msgid "Invalid season"
4088msgstr "Sezon invalid"
4089
4090msgctxt "#90738"
4091msgid "Episode"
4092msgstr "Episod"
4093
4094msgctxt "#90739"
4095msgid "Invalid episode"
4096msgstr "Episod invalid"
4097
4098msgctxt "#90740"
4099msgid "Search with current title"
4100msgstr "Căutare cu titlul curent"
4101
4102msgctxt "#90741"
4103msgid "Search with original title"
4104msgstr "Căutare cu titlul original"
4105
4106msgctxt "#90773"
4107msgid "Search full show with title + year"
4108msgstr "Caută serial complet cu titlu + an"
4109
4110msgctxt "#90744"
4111msgid "Upload Subtitle"
4112msgstr "Încarcă subtitrare"
4113
4114msgctxt "#90745"
4115msgid "Select Subtitle File"
4116msgstr "Selectează fișierul de subtitrare"
4117
4118msgctxt "#90746"
4119msgid "Local File"
4120msgstr "Fișier local"
4121
4122msgctxt "#90747"
4123msgid "Upload from Browser"
4124msgstr "Încarcă din browser"
4125
4126msgctxt "#90748"
4127msgid "Upload subtitle from your browser"
4128msgstr "Încarcă subtitrarea din browserul tău"
4129
4130msgctxt "#90749"
4131msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file"
4132msgstr "Scanează codul QR sau deschide URL-ul (%s) în browser pentru a încărca un fișier de subtitrare"
4133
4134msgctxt "#90750"
4135msgid "Search Title Language"
4136msgstr "Limba titlului de căutare"
4137
4138msgctxt "#90751"
4139msgid "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches."
4140msgstr "Controlează modul în care interogările de titlu Easynews/Jackett/Prowlarr sunt prioritizate pentru căutările contextuale."
4141
4142msgctxt "#90752"
4143msgid "English first"
4144msgstr "Mai întâi în engleză"
4145
4146msgctxt "#90753"
4147msgid "English only"
4148msgstr "Numai engleză"
4149
4150msgctxt "#90754"
4151msgid "Localized first"
4152msgstr "Mai întâi versiunea locală"
4153
4154msgctxt "#90755"
4155msgid "Manage Sources"
4156msgstr "Gestionează sursele"
4157
4158msgctxt "#90756"
4159msgid "No sources available"
4160msgstr "Nu există surse disponibile"
4161
4162msgctxt "#90179"
4163msgid "Stinger Notifications"
4164msgstr "Notificări stinger"
4165
4166msgctxt "#90180"
4167msgid "Show a notification when a movie has post-credit scenes."
4168msgstr "Afișează o notificare când un film are scene post-credit."
4169
4170msgctxt "#90181"
4171msgid "Stinger Notification Time"
4172msgstr "Timp notificare stinger"
4173
4174msgctxt "#90182"
4175msgid "Seconds before the end to show the stinger notification."
4176msgstr "Secunde înainte de final pentru afișarea notificării stinger."
4177
4178msgctxt "#90183"
4179msgid "Stay tuned after the credits!"
4180msgstr "Rămâi după genericul de final!"
4181
4182msgctxt "#90757"
4183msgid "Image Resolution"
4184msgstr "Rezoluție imagine"
4185
4186msgctxt "#90758"
4187msgid "Low"
4188msgstr "Mică"
4189
4190msgctxt "#90759"
4191msgid "Medium"
4192msgstr "Medie"
4193
4194msgctxt "#90760"
4195msgid "High"
4196msgstr "Înaltă"
4197
4198msgctxt "#90761"
4199msgid "Original"
4200msgstr "Originală"
4201
4202msgctxt "#90762"
4203msgid "Enable RPDB Posters"
4204msgstr "Activează postere RPDB"
4205
4206msgctxt "#90763"
4207msgid "RPDB API Key"
4208msgstr "Cheie API RPDB"
4209
4210msgctxt "#90764"
4211msgid "Get your API key at ratingposterdb.com"
4212msgstr "Obține cheia API de la ratingposterdb.com"
4213
4214msgctxt "#90765"
4215msgid "Filter by Codecs"
4216msgstr "Filtrare după codecuri"
4217
4218msgctxt "#90766"
4219msgid "Filter by HDR"
4220msgstr "Filtrare după HDR"
4221
4222msgctxt "#90767"
4223msgid "Include HEVC / H.265"
4224msgstr "Include HEVC / H.265"
4225
4226msgctxt "#90768"
4227msgid "Include AV1"
4228msgstr "Include AV1"
4229
4230msgctxt "#90769"
4231msgid "Include H.264"
4232msgstr "Include H.264"
4233
4234msgctxt "#90770"
4235msgid "Include Dolby Vision"
4236msgstr "Include Dolby Vision"
4237
4238msgctxt "#90771"
4239msgid "Include HDR10 / HDR10+"
4240msgstr "Include HDR10 / HDR10+"
4241
4242msgctxt "#90772"
4243msgid "Include HDR (generic)"
4244msgstr "Include HDR (generic)"
4245
4246msgctxt "#90774"
4247msgid "Organize downloads by type"
4248msgstr "Organizează descărcările pe tip"
4249
4250msgctxt "#90775"
4251msgid "Movies folder name"
4252msgstr "Numele folderului pentru filme"
4253
4254msgctxt "#90776"
4255msgid "TV Shows folder name"
4256msgstr "Numele folderului pentru seriale"
4257
4258msgctxt "#90777"
4259msgid "Pause"
4260msgstr "Pauză"
4261
4262msgctxt "#90778"
4263msgid "Resume"
4264msgstr "Continuă"
4265
4266msgctxt "#90779"
4267msgid "Downloading"
4268msgstr "Se descarcă"
4269
4270msgctxt "#90780"
4271msgid "Paused"
4272msgstr "În pauză"
4273
4274msgctxt "#90781"
4275msgid "Completed"
4276msgstr "Finalizat"
4277
4278msgctxt "#90782"
4279msgid "Delete"
4280msgstr "Șterge"
4281
4282msgctxt "#90783"
4283msgid "Startup"
4284msgstr "Pornire"
4285
4286msgctxt "#90784"
4287msgid "Auto-start Jacktook on Kodi boot"
4288msgstr "Pornire automată Jacktook la boot-ul Kodi"
4289
4290msgctxt "#90785"
4291msgid "Automatically open Jacktook when Kodi starts"
4292msgstr "Deschide automat Jacktook când Kodi pornește"
4293
4294msgctxt "#90786"
4295msgid "Widgets"
4296msgstr "Widget-uri"
4297
4298msgctxt "#90787"
4299msgid "Widget refresh interval (minutes)"
4300msgstr "Interval de reîmprospătare widget (minute)"
4301
4302msgctxt "#90788"
4303msgid "Set to 0 to disable automatic widget refresh"
4304msgstr "Setați la 0 pentru a dezactiva reîmprospătarea automată"
4305
4306msgctxt "#90789"
4307msgid "Show refresh notification"
4308msgstr "Afișați notificarea de reîmprospătare"
4309
4310msgctxt "#90790"
4311msgid "Display a notification when widgets are refreshed"
4312msgstr "Afișați o notificare când widget-urile sunt reîmprospătate"
4313
4314msgctxt "#90791"
4315msgid "Download to Device"
4316msgstr "Descarcă pe dispozitiv"
4317
4318msgctxt "#90792"
4319msgid "Download Manager"
4320msgstr "Manager de descărcări"
4321
4322msgctxt "#90793"
4323msgid "No active downloads"
4324msgstr "Nicio descărcare activă"
4325
4326msgctxt "#90794"
4327msgid "Progress"
4328msgstr "Progres"
4329
4330msgctxt "#90795"
4331msgid "Speed"
4332msgstr "Viteză"
4333
4334msgctxt "#90796"
4335msgid "ETA"
4336msgstr "Timp rămas"
4337
4338msgctxt "#90797"
4339msgid "Size"
4340msgstr "Dimensiune"
4341
4342msgctxt "#90798"
4343msgid "Pause"
4344msgstr "Pauză"
4345
4346msgctxt "#90799"
4347msgid "Resume"
4348msgstr "Relua"
4349
4350msgctxt "#90800"
4351msgid "Cancel"
4352msgstr "Anulare"
4353
4354msgctxt "#90801"
4355msgid "Delete"
4356msgstr "Șterge"
4357
4358msgctxt "#90802"
4359msgid "Clear Completed"
4360msgstr "Șterge completate"
4361
4362msgctxt "#90803"
4363msgid "Download not yet completed"
4364msgstr "Descărcarea nu este încă finalizată"
4365
4366msgctxt "#90804"
4367msgid "File not found"
4368msgstr "Fișier negăsit"
4369
4370msgctxt "#90805"
4371msgid "Download Manager"
4372msgstr "Manager de descărcări"
4373
4374msgctxt "#90806"
4375msgid "My Downloads"
4376msgstr "Descărcările mele"
4377
4378msgctxt "#90807"
4379msgid "Connecting..."
4380msgstr "Se conectează..."
4381
4382msgctxt "#90808"
4383msgid "Close"
4384msgstr "Închide"
4385
4386msgctxt "#90809"
4387msgid "Delete download?"
4388msgstr "Ștergeți descărcarea?"
4389
4390msgctxt "#90810"
4391msgid "This will permanently delete the downloaded file. Are you sure?"
4392msgstr "Acest lucru va șterge permanent fișierul descărcat. Sunteți sigur?"
4393
4394msgctxt "#90811"
4395msgid "Completed"
4396msgstr "Finalizat"
4397
4398msgctxt "#90812"
4399msgid "Excluded TMDB Languages"
4400msgstr "Limbi excluse TMDB"
4401
4402msgctxt "#90813"
4403msgid "Select languages to exclude"
4404msgstr "Selectează limbile de exclus"
4405
4406msgctxt "#90814"
4407msgid "Choose original languages to hide from TMDB listings"
4408msgstr "Alege limbile originale de ascuns din listele TMDB"
4409
4410msgctxt "#90815"
4411msgid "Support Jacktook"
4412msgstr "Susține Jacktook"
4413
4414msgctxt "#90816"
4415msgid "Support Jacktook development with an optional Ko-fi donation."
4416msgstr "Susține dezvoltarea Jacktook cu o donație opțională pe Ko-fi."
4417
4418msgctxt "#90817"
4419msgid "Scan the QR code or open this link:"
4420msgstr "Scanează codul QR sau deschide acest link:"
4421
4422msgctxt "#90818"
4423msgid "Jacktook was updated with new improvements. Donations are optional, but they help keep development active. Would you like to see the support link?"
4424msgstr "Jacktook a fost actualizat cu îmbunătățiri noi. Donațiile sunt opționale, dar ajută la menținerea dezvoltării active. Vrei să vezi linkul de susținere?"
4425
4426msgctxt "#90819"
4427msgid "Rename Stremio add-on"
4428msgstr "Redenumește addonul Stremio"
4429
4430msgctxt "#90824"
4431msgid "Rename Stremio catalog"
4432msgstr "Redenumește catalogul Stremio"
4433
4434msgctxt "#90827"
4435msgid "Set a Kodi-only display alias for a Stremio add-on."
4436msgstr "Setează un alias de afișare doar în Kodi pentru un addon Stremio."
4437
4438msgctxt "#90828"
4439msgid "Set a Kodi-only display alias for a Stremio catalog."
4440msgstr "Setează un alias de afișare doar în Kodi pentru un catalog Stremio."
4441
4442msgctxt "#90832"
4443msgid "Stremio: Addons Management"
4444msgstr "Stremio: Gestionarea addonurilor"
4445
4446msgctxt "#90833"
4447msgid "Stremio: Catalogs & Visibility"
4448msgstr "Stremio: Cataloage și vizibilitate"
4449
4450msgctxt "#90834"
4451msgid "Stremio: Rename & Customization"
4452msgstr "Stremio: Redenumire și personalizare"
4453
4454msgctxt "#90835"
4455msgid "Stremio: Search & Behavior"
4456msgstr "Stremio: Căutare și comportament"
4457
4458msgctxt "#90300"
4459msgid "External Scraper"
4460msgstr "Magneto"
4461
4462msgctxt "#90301"
4463msgid "Enable external scraper module integration."
4464msgstr "Enable external scraper module integration."
4465
4466msgctxt "#90302"
4467msgid "Choose External Scraper Module"
4468msgstr "Alege modulul scraper extern"
4469
4470msgctxt "#90303"
4471msgid "Select an external scraper addon module to use for torrent search."
4472msgstr "Selectează un modul addon scraper extern de folosit pentru căutarea de torrente."
4473
4474msgctxt "#90304"
4475msgid "Choose External Scraper"
4476msgstr "Alege scraper extern"
4477
4478msgctxt "#90305"
4479msgid "Selected Module"
4480msgstr "Modul selectat"
4481
4482msgctxt "#90306"
4483msgid "Open External Scraper Settings"
4484msgstr "Deschide setările scraperului extern"
4485
4486msgctxt "#90307"
4487msgid "Open the settings for the selected external scraper module."
4488msgstr "Deschide setările pentru modulul scraper extern selectat."
plugin.video.jacktook/resources/language/Russian/strings.po +820 -16
……@@ -312,8 +312,8 @@ msgid "Unknown"
312312msgstr "Неизвестно"
313313
314314msgctxt "#30700"
315msgid "Buffer and play"
316msgstr "Буферизация и воспроизведение"
315msgid "Play Torrent"
316msgstr "Воспроизвести торрент"
317317
318318msgctxt "#30701"
319319msgid "Stop downloading"
……@@ -431,6 +431,58 @@ msgctxt "#30939"
431431msgid "Auto-resume from last position without asking"
432432msgstr "Автоматически возобновлять с последней позиции без вопросов"
433433
434msgctxt "#30940"
435msgid "Use Percentage Trigger"
436msgstr "Использовать срабатывание по проценту"
437
438msgctxt "#30941"
439msgid "Show Dialog at Playback Percentage"
440msgstr "Показывать диалог при проценте воспроизведения"
441
442msgctxt "#30942"
443msgid "Trigger the next-episode dialog based on percentage of video played instead of seconds remaining."
444msgstr "Показывать диалог следующего эпизода на основе процента просмотренного видео, а не оставшихся секунд."
445
446msgctxt "#30943"
447msgid "The percentage at which the next-episode dialog appears (e.g. 95 means 95% of the video)."
448msgstr "Процент, при котором появляется диалог следующего эпизода (например, 95 означает 95% видео)."
449
450msgctxt "#30944"
451msgid "Auto-Play Timeout"
452msgstr "Тайм-аут автовоспроизведения"
453
454msgctxt "#30945"
455msgid "Seconds before auto-playing next episode. 0 = disabled, dialog waits for user input."
456msgstr "Секунды до автоматического воспроизведения следующего эпизода. 0 = отключено, диалог ждёт ввода пользователя."
457
458
459msgctxt "#30946"
460msgid "Offcloud Configuration"
461msgstr "Настройка Offcloud"
462
463msgctxt "#30947"
464msgid "Enable Offcloud cached torrent and cloud transfer support."
465msgstr "Включить поддержку кэшированных торрентов и облачных загрузок Offcloud."
466
467msgctxt "#30948"
468msgid "Your Offcloud account username/email."
469msgstr "Ваше имя пользователя или email Offcloud."
470
471msgctxt "#30949"
472msgid "Offcloud API key from Account API Key settings."
473msgstr "API-ключ Offcloud из настроек аккаунта → API Key."
474
475msgctxt "#30950"
476msgid "View Offcloud account information."
477msgstr "Просмотреть информацию об аккаунте Offcloud."
478
479msgctxt "#30951"
480msgid "Authorize add-on to access Offcloud account."
481msgstr "Авторизовать дополнение для доступа к аккаунту Offcloud."
482
483msgctxt "#30952"
484msgid "Revoke Offcloud account access."
485msgstr "Отозвать доступ аккаунта Offcloud."
434486msgctxt "#30728"
435487msgid "Seeds"
436488msgstr "Сиды"
……@@ -459,6 +511,10 @@ msgctxt "#30734"
459511msgid "Enable"
460512msgstr "Включить"
461513
514msgctxt "#30735"
515msgid "Still Watching? Threshold"
516msgstr "Порог 'Ещё смотрите?'"
517
462518msgctxt "#30736"
463519msgid "Subtitle Language"
464520msgstr "Язык субтитров"
……@@ -607,6 +663,10 @@ msgctxt "#30772"
607663msgid "Zilean Host"
608664msgstr "Хост Zilean"
609665
666msgctxt "#30773"
667msgid "Number of consecutive auto-played episodes before asking 'Still Watching?'. Set to 0 to disable."
668msgstr "Количество подряд автоматически воспроизведённых эпизодов перед вопросом 'Ещё смотрите?'. Установите 0, чтобы отключить."
669
610670msgctxt "#30774"
611671msgid "Zilean Timeout"
612672msgstr "Тайм-аут Zilean"
……@@ -1556,8 +1616,8 @@ msgid "TV Calendar"
15561616msgstr "ТВ-календарь"
15571617
15581618msgctxt "#90022"
1559msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:"
1560msgstr "Если вам нравится использовать Jacktook и вы хотите поддержать его разработку, вы можете угостить меня кофе! Ваша поддержка помогает проекту оставаться активным и позволяет вносить будущие улучшения:"
1619msgid "Jacktook is free and built with care in spare time. If you enjoy the addon, consider supporting its development. Donations are optional and help keep updates coming."
1620msgstr "Jacktook бесплатен и создается с заботой в свободное время. Если вам нравится addon, рассмотрите возможность поддержки его разработки. Пожертвования необязательны и помогают выпускать обновления."
15611621
15621622msgctxt "#90023"
15631623msgid "Support Jacktook"
……@@ -1991,21 +2051,37 @@ msgctxt "#90144"
19912051msgid "Could not resolve playback URL"
19922052msgstr "Не удалось разрешить URL воспроизведения"
19932053
1994msgctxt "#90145"
1995msgid "Precaching Enabled"
1996msgstr "Предкэширование включено"
2054msgctxt "#90171"
2055msgid "Autoscrape Next Episode"
2056msgstr "Автопоиск следующего эпизода"
19972057
1998msgctxt "#90146"
1999msgid "Enable or disable precaching of next episodes."
2000msgstr "Включить или выключить предкэширование следующих серий."
2058msgctxt "#90172"
2059msgid "Pre-resolve the next episode's playback URL in the background while watching."
2060msgstr "Предварительно определять URL воспроизведения следующего эпизода в фоне во время просмотра."
20012061
2002msgctxt "#90147"
2003msgid "Precaching Episode Count"
2004msgstr "Количество предкэшируемых серий"
2062msgctxt "#90173"
2063msgid "Autoscrape Threshold"
2064msgstr "Порог автопоиска"
20052065
2006msgctxt "#90148"
2007msgid "Number of next episodes to precache."
2008msgstr "Количество следующих серий для предкэширования."
2066msgctxt "#90174"
2067msgid "Percentage of playback to trigger background pre-resolution."
2068msgstr "Процент воспроизведения, при котором запускается фоновое предварительное определение."
2069
2070msgctxt "#90175"
2071msgid "Autoscrape Episode Count"
2072msgstr "Количество эпизодов для автопоиска"
2073
2074msgctxt "#90176"
2075msgid "Number of upcoming episodes to pre-resolve."
2076msgstr "Количество следующих эпизодов для предварительного определения."
2077
2078msgctxt "#90177"
2079msgid "Autoscrape Cache TTL"
2080msgstr "TTL кэша автопоиска"
2081
2082msgctxt "#90178"
2083msgid "Hours to keep resolved playback data in cache."
2084msgstr "Сколько часов хранить в кэше определённые данные воспроизведения."
20092085
20102086msgctxt "#30901"
20112087msgid "Show Only Stremio Catalogs"
……@@ -2555,6 +2631,10 @@ msgctxt "#90298"
25552631msgid "Liked Lists"
25562632msgstr "Понравившиеся списки"
25572633
2634msgctxt "#90299"
2635msgid "No metadata found for this torrent"
2636msgstr "Метаданные для этого торрента не найдены"
2637
25582638msgctxt "#90331"
25592639msgid "Stremio Addons"
25602640msgstr "Аддоны Stremio"
……@@ -3651,6 +3731,18 @@ msgctxt "#90749"
36513731msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file"
36523732msgstr "Отсканируйте QR-код или откройте URL (%s) в браузере, чтобы загрузить файл субтитров"
36533733
3734msgctxt "#90740"
3735msgid "Search with current title"
3736msgstr "Искать с текущим названием"
3737
3738msgctxt "#90741"
3739msgid "Search with original title"
3740msgstr "Искать с оригинальным названием"
3741
3742msgctxt "#90773"
3743msgid "Search full show with title + year"
3744msgstr "Искать весь сериал по названию + году"
3745
36543746msgctxt "#90750"
36553747msgid "Search Title Language"
36563748msgstr "Язык заголовка поиска"
……@@ -3670,3 +3762,715 @@ msgstr "Только английский"
36703762msgctxt "#90754"
36713763msgid "Localized first"
36723764msgstr "Сначала локализованный"
3765
3766msgctxt "#90755"
3767msgid "Manage Sources"
3768msgstr "Управление источниками"
3769
3770msgctxt "#90756"
3771msgid "No sources available"
3772msgstr "Нет доступных источников"
3773
3774msgctxt "#90179"
3775msgid "Stinger Notifications"
3776msgstr "Уведомления о сценах после титров"
3777
3778msgctxt "#90180"
3779msgid "Show a notification when a movie has post-credit scenes."
3780msgstr "Показывать уведомление, если в фильме есть сцены после титров."
3781
3782msgctxt "#90181"
3783msgid "Stinger Notification Time"
3784msgstr "Время уведомления о сцене после титров"
3785
3786msgctxt "#90182"
3787msgid "Seconds before the end to show the stinger notification."
3788msgstr "За сколько секунд до конца показать уведомление о сцене после титров."
3789
3790msgctxt "#90183"
3791msgid "Stay tuned after the credits!"
3792msgstr "Не выключайте после титров!"
3793
3794msgctxt "#90757"
3795msgid "Image Resolution"
3796msgstr "Razresheniye izobrazheniya"
3797
3798msgctxt "#90758"
3799msgid "Low"
3800msgstr "Nizkoye"
3801
3802msgctxt "#90759"
3803msgid "Medium"
3804msgstr "Sredneye"
3805
3806msgctxt "#90760"
3807msgid "High"
3808msgstr "Vysokoye"
3809
3810msgctxt "#90761"
3811msgid "Original"
3812msgstr "Originalnoye"
3813
3814msgctxt "#90762"
3815msgid "Enable RPDB Posters"
3816msgstr "Vkluchit posteri RPDB"
3817
3818msgctxt "#90763"
3819msgid "RPDB API Key"
3820msgstr "API-klyuch RPDB"
3821
3822msgctxt "#90764"
3823msgid "Get your API key at ratingposterdb.com"
3824msgstr "Poluchite API-klyuch na ratingposterdb.com"
3825
3826msgctxt "#90765"
3827msgid "Filter by Codecs"
3828msgstr "Фильтр по кодекам"
3829
3830msgctxt "#90766"
3831msgid "Filter by HDR"
3832msgstr "Фильтр по HDR"
3833
3834msgctxt "#90767"
3835msgid "Include HEVC / H.265"
3836msgstr "Включать HEVC / H.265"
3837
3838msgctxt "#90768"
3839msgid "Include AV1"
3840msgstr "Включать AV1"
3841
3842msgctxt "#90769"
3843msgid "Include H.264"
3844msgstr "Включать H.264"
3845
3846msgctxt "#90770"
3847msgid "Include Dolby Vision"
3848msgstr "Включать Dolby Vision"
3849
3850msgctxt "#90771"
3851msgid "Include HDR10 / HDR10+"
3852msgstr "Включать HDR10 / HDR10+"
3853
3854msgctxt "#90772"
3855msgid "Include HDR (generic)"
3856msgstr "Включать HDR (общий)"
3857
3858msgctxt "#90774"
3859msgid "Organize downloads by type"
3860msgstr "Organizovat zagruzki po tipu"
3861
3862msgctxt "#90775"
3863msgid "Movies folder name"
3864msgstr "Imya papki filmov"
3865
3866msgctxt "#90776"
3867msgid "TV Shows folder name"
3868msgstr "Imya papki serialov"
3869
3870msgctxt "#90777"
3871msgid "Pause"
3872msgstr "Pauza"
3873
3874msgctxt "#90778"
3875msgid "Resume"
3876msgstr "Vozobnovit"
3877
3878msgctxt "#90779"
3879msgid "Downloading"
3880msgstr "Zagruzka"
3881
3882msgctxt "#90780"
3883msgid "Paused"
3884msgstr "Na pauze"
3885
3886msgctxt "#90781"
3887msgid "Completed"
3888msgstr "Zaversheno"
3889
3890msgctxt "#90782"
3891msgid "Delete"
3892msgstr "Udalit"
3893
3894msgctxt "#90783"
3895msgid "Startup"
3896msgstr "Zapusk"
3897
3898msgctxt "#90784"
3899msgid "Auto-start Jacktook on Kodi boot"
3900msgstr "Avtozapusk Jacktook pri starte Kodi"
3901
3902msgctxt "#90785"
3903msgid "Automatically open Jacktook when Kodi starts"
3904msgstr "Avtomaticheski otkryvat Jacktook pri zapuske Kodi"
3905
3906msgctxt "#90786"
3907msgid "Widgets"
3908msgstr "Vidzhety"
3909
3910msgctxt "#90787"
3911msgid "Widget refresh interval (minutes)"
3912msgstr "Interval obnovleniya vidzhetov (minuty)"
3913
3914msgctxt "#90788"
3915msgid "Set to 0 to disable automatic widget refresh"
3916msgstr "Ustanovite 0, chtoby otklyuchit avtoobnovleniye"
3917
3918msgctxt "#90789"
3919msgid "Show refresh notification"
3920msgstr "Pokazyvat uvedomleniye ob obnovlenii"
3921
3922msgctxt "#90790"
3923msgid "Display a notification when widgets are refreshed"
3924msgstr "Otobrazhat uvedomleniye pri obnovlenii vidzhetov"
3925
3926msgctxt "#90791"
3927msgid "Download to Device"
3928msgstr "Zagruzit na ustroystvo"
3929
3930msgctxt "#90792"
3931msgid "Download Manager"
3932msgstr "Menedzher zagruzok"
3933
3934msgctxt "#90793"
3935msgid "No active downloads"
3936msgstr "Net aktivnykh zagruzok"
3937
3938msgctxt "#90794"
3939msgid "Progress"
3940msgstr "Khod vypolneniya"
3941
3942msgctxt "#90795"
3943msgid "Speed"
3944msgstr "Skorost"
3945
3946msgctxt "#90796"
3947msgid "ETA"
3948msgstr "Ostalos"
3949
3950msgctxt "#90797"
3951msgid "Size"
3952msgstr "Razmer"
3953
3954msgctxt "#90798"
3955msgid "Pause"
3956msgstr "Pauza"
3957
3958msgctxt "#90799"
3959msgid "Resume"
3960msgstr "Vozobnovit"
3961
3962msgctxt "#90800"
3963msgid "Cancel"
3964msgstr "Otmena"
3965
3966msgctxt "#90801"
3967msgid "Delete"
3968msgstr "Udalit"
3969
3970msgctxt "#90802"
3971msgid "Clear Completed"
3972msgstr "Ochistit zavershennye"
3973
3974msgctxt "#90803"
3975msgid "Download not yet completed"
3976msgstr "Zagruzka yeshchyo ne zavershena"
3977
3978msgctxt "#90804"
3979msgid "File not found"
3980msgstr "Fayl ne nayden"
3981
3982msgctxt "#90805"
3983msgid "Download Manager"
3984msgstr "Menedzher zagruzok"
3985
3986msgctxt "#90806"
3987msgid "My Downloads"
3988msgstr "Moi zagruzki"
3989
3990msgctxt "#90807"
3991msgid "Connecting..."
3992msgstr "Podklyucheniye..."
3993
3994msgctxt "#90808"
3995msgid "Close"
3996msgstr "Zakryt"
3997
3998msgctxt "#90809"
3999msgid "Delete download?"
4000msgstr "Udalit zagruzku?"
4001
4002msgctxt "#90810"
4003msgid "This will permanently delete the downloaded file. Are you sure?"
4004msgstr "Zagruzhennyy fayl budet udalyon navsegda. Vy uvereny?"
4005
4006msgctxt "#90811"
4007msgid "Completed"
4008msgstr "Zaversheno"
4009
4010msgctxt "#90812"
4011msgid "Excluded TMDB Languages"
4012msgstr "Isklyuchennye yazyki TMDB"
4013
4014msgctxt "#90813"
4015msgid "Select languages to exclude"
4016msgstr "Vyberite yazyki dlya isklyucheniya"
4017
4018msgctxt "#90814"
4019msgid "Choose original languages to hide from TMDB listings"
4020msgstr "Vyberite original'nye yazyki dlya skrytiya iz spiskov TMDB"
4021
4022msgctxt "#90815"
4023msgid "Support Jacktook"
4024msgstr "Поддержать Jacktook"
4025
4026msgctxt "#90816"
4027msgid "Support Jacktook development with an optional Ko-fi donation."
4028msgstr "Поддержите разработку Jacktook необязательным пожертвованием на Ko-fi."
4029
4030msgctxt "#90817"
4031msgid "Scan the QR code or open this link:"
4032msgstr "Отсканируйте QR-код или откройте эту ссылку:"
4033
4034msgctxt "#90818"
4035msgid "Jacktook was updated with new improvements. Donations are optional, but they help keep development active. Would you like to see the support link?"
4036msgstr "Jacktook был обновлен с новыми улучшениями. Пожертвования необязательны, но помогают поддерживать разработку. Хотите увидеть ссылку поддержки?"
4037
4038msgctxt "#90300"
4039msgid "External Scraper"
4040msgstr "Magneto"
4041
4042msgctxt "#90301"
4043msgid "Enable external scraper module integration."
4044msgstr "Включить интеграцию внешнего модуля scraper."
4045
4046msgctxt "#90302"
4047msgid "Choose External Scraper Module"
4048msgstr "Выбрать внешний модуль scraper"
4049
4050msgctxt "#90303"
4051msgid "Select an external scraper addon module to use for torrent search."
4052msgstr "Выберите внешний модуль scraper addon для поиска торрентов."
4053
4054msgctxt "#90304"
4055msgid "Choose External Scraper"
4056msgstr "Выбрать внешний scraper"
4057
4058msgctxt "#90305"
4059msgid "Selected Module"
4060msgstr "Выбранный модуль"
4061
4062msgctxt "#90306"
4063msgid "Open External Scraper Settings"
4064msgstr "Открыть настройки внешнего scraper"
4065
4066msgctxt "#90307"
4067msgid "Open the settings for the selected external scraper module."
4068msgstr "Открыть настройки выбранного внешнего модуля scraper."
4069
4070msgctxt "#90382"
4071msgid "Please set a valid Trakt Client ID Key"
4072msgstr "Укажите действительный ключ Client ID Trakt"
4073
4074msgctxt "#90383"
4075msgid "Please set a valid Trakt Client Secret Key"
4076msgstr "Укажите действительный секретный ключ Client Secret Trakt"
4077
4078msgctxt "#90384"
4079msgid "Trakt Error Authorizing"
4080msgstr "Ошибка авторизации Trakt"
4081
4082msgctxt "#90385"
4083msgid "Trakt Account Authorized"
4084msgstr "Аккаунт Trakt авторизован"
4085
4086msgctxt "#90386"
4087msgid "You are now logged out from Trakt.tv"
4088msgstr "Вы вышли из Trakt.tv"
4089
4090msgctxt "#90387"
4091msgid "Failed to mark as watched"
4092msgstr "Не удалось отметить как просмотренное"
4093
4094msgctxt "#90388"
4095msgid "Marked as watched"
4096msgstr "Отмечено как просмотренное"
4097
4098msgctxt "#90389"
4099msgid "No results found"
4100msgstr "Результаты не найдены"
4101
4102msgctxt "#90390"
4103msgid "Invalid mode"
4104msgstr "Недопустимый режим"
4105
4106msgctxt "#90391"
4107msgid "No recommendations found"
4108msgstr "Рекомендации не найдены"
4109
4110msgctxt "#90392"
4111msgid "No similar items found"
4112msgstr "Похожие элементы не найдены"
4113
4114msgctxt "#90393"
4115msgid "TMDB metadata not found"
4116msgstr "Метаданные TMDB не найдены"
4117
4118msgctxt "#90394"
4119msgid "No TV shows found"
4120msgstr "Сериалы не найдены"
4121
4122msgctxt "#90395"
4123msgid "No shows airing today"
4124msgstr "Сегодня нет выходящих серий"
4125
4126msgctxt "#90396"
4127msgid "Invalid collection query"
4128msgstr "Недопустимый запрос коллекции"
4129
4130msgctxt "#90397"
4131msgid "No keywords found"
4132msgstr "Ключевые слова не найдены"
4133
4134msgctxt "#90398"
4135msgid "No keyword ID provided"
4136msgstr "ID ключевого слова не указан"
4137
4138msgctxt "#90399"
4139msgid "No results found for this keyword"
4140msgstr "Для этого ключевого слова результаты не найдены"
4141
4142msgctxt "#90400"
4143msgid "No TMDB ID found"
4144msgstr "ID TMDB не найден"
4145
4146msgctxt "#90401"
4147msgid "Unsupported media type or missing external_ids"
4148msgstr "Неподдерживаемый тип медиа или отсутствуют external_ids"
4149
4150msgctxt "#90402"
4151msgid "Refreshed Metadata"
4152msgstr "Метаданные обновлены"
4153
4154msgctxt "#90403"
4155msgid "No Stremio addons selected"
4156msgstr "Аддоны Stremio не выбраны"
4157
4158msgctxt "#90404"
4159msgid "No suitable source found for auto play."
4160msgstr "Подходящий источник для автовоспроизведения не найден."
4161
4162msgctxt "#90405"
4163msgid "No sources found with the preferred quality."
4164msgstr "Источники с предпочитаемым качеством не найдены."
4165
4166msgctxt "#90406"
4167msgid "No languages found"
4168msgstr "Языки не найдены"
4169
4170msgctxt "#90407"
4171msgid "Failed to resolve playback source"
4172msgstr "Не удалось определить источник воспроизведения"
4173
4174msgctxt "#90408"
4175msgid "Trakt not authorized. Please authenticate."
4176msgstr "Trakt не авторизован. Выполните аутентификацию."
4177
4178msgctxt "#90409"
4179msgid "Added to Trakt Watchlist"
4180msgstr "Добавлено в список просмотра Trakt"
4181
4182msgctxt "#90428"
4183msgid "Database read error. File may be corrupted."
4184msgstr "Ошибка чтения базы данных. Файл может быть повреждён."
4185
4186msgctxt "#90429"
4187msgid "Failed to delete data. Database error."
4188msgstr "Не удалось удалить данные. Ошибка базы данных."
4189
4190msgctxt "#90430"
4191msgid "Failed to clear cache. Database in use."
4192msgstr "Не удалось очистить кэш. База данных используется."
4193
4194msgctxt "#90458"
4195msgid "OpenSubtitles Stremio: No results found."
4196msgstr "OpenSubtitles Stremio: результаты не найдены."
4197
4198msgctxt "#90460"
4199msgid "Subtitle Manager: No subtitles available for this video."
4200msgstr "Менеджер субтитров: для этого видео субтитры недоступны."
4201
4202msgctxt "#90468"
4203msgid "Search menu"
4204msgstr "Меню поиска"
4205
4206msgctxt "#90469"
4207msgid "Continue watching menu"
4208msgstr "Меню продолжения просмотра"
4209
4210msgctxt "#90470"
4211msgid "TV Shows Items"
4212msgstr "Элементы сериалов"
4213
4214msgctxt "#90471"
4215msgid "Movies Items"
4216msgstr "Элементы фильмов"
4217
4218msgctxt "#90472"
4219msgid "Anime menu"
4220msgstr "Меню аниме"
4221
4222msgctxt "#90473"
4223msgid "TV Menu"
4224msgstr "Меню ТВ"
4225
4226msgctxt "#90474"
4227msgid "Library menu"
4228msgstr "Меню библиотеки"
4229
4230msgctxt "#90475"
4231msgid "Torrents Management"
4232msgstr "Управление торрентами"
4233
4234msgctxt "#90476"
4235msgid "Telegram menu"
4236msgstr "Меню Telegram"
4237
4238msgctxt "#90477"
4239msgid "Cloud Storage"
4240msgstr "Облачное хранилище"
4241
4242msgctxt "#90478"
4243msgid "Downloads Management"
4244msgstr "Управление загрузками"
4245
4246msgctxt "#90479"
4247msgid "Settings Management"
4248msgstr "Управление настройками"
4249
4250msgctxt "#90480"
4251msgid "History Menu"
4252msgstr "Меню истории"
4253
4254msgctxt "#90481"
4255msgid "Files History"
4256msgstr "История файлов"
4257
4258msgctxt "#90482"
4259msgid "Titles History"
4260msgstr "История названий"
4261
4262msgctxt "#90483"
4263msgid "Titles Calendar"
4264msgstr "Календарь названий"
4265
4266msgctxt "#90484"
4267msgid "My Shows"
4268msgstr "Мои сериалы"
4269
4270msgctxt "#90485"
4271msgid "My Movies"
4272msgstr "Мои фильмы"
4273
4274msgctxt "#90486"
4275msgid "Upcoming Episodes"
4276msgstr "Предстоящие эпизоды"
4277
4278msgctxt "#90487"
4279msgid "Discovery List"
4280msgstr "Список открытий"
4281
4282msgctxt "#90488"
4283msgid "Library List"
4284msgstr "Список библиотеки"
4285
4286msgctxt "#90489"
4287msgid "Trakt Recommendations"
4288msgstr "Рекомендации Trakt"
4289
4290msgctxt "#90490"
4291msgid "Trending Lists"
4292msgstr "Популярные сейчас списки"
4293
4294msgctxt "#90491"
4295msgid "Popular Lists"
4296msgstr "Популярные списки"
4297
4298msgctxt "#90492"
4299msgid "Search Lists"
4300msgstr "Поиск списков"
4301
4302msgctxt "#90493"
4303msgid "Recent Activity"
4304msgstr "Недавняя активность"
4305
4306msgctxt "#90494"
4307msgid "Downloads List"
4308msgstr "Список загрузок"
4309
4310msgctxt "#90495"
4311msgid "Download New"
4312msgstr "Новая загрузка"
4313
4314msgctxt "#90496"
4315msgid "Cartoons Popular"
4316msgstr "Популярные мультфильмы"
4317
4318msgctxt "#90497"
4319msgid "Animation Popular"
4320msgstr "Популярная анимация"
4321
4322msgctxt "#90500"
4323msgid "Translating subtitle..."
4324msgstr "Перевод субтитров..."
4325
4326msgctxt "#90501"
4327msgid "Translation done."
4328msgstr "Перевод завершён."
4329
4330msgctxt "#90502"
4331msgid "Searching subtitles..."
4332msgstr "Поиск субтитров..."
4333
4334msgctxt "#90503"
4335msgid "No subtitles found"
4336msgstr "Субтитры не найдены"
4337
4338msgctxt "#90504"
4339msgid "Failed to authenticate with DeepL. Please check your API key."
4340msgstr "Не удалось пройти аутентификацию в DeepL. Проверьте ключ API."
4341
4342msgctxt "#90505"
4343msgid "DeepL API key is missing or invalid."
4344msgstr "Ключ API DeepL отсутствует или недействителен."
4345
4346msgctxt "#90712"
4347msgid "Views"
4348msgstr "Виды"
4349
4350msgctxt "#90713"
4351msgid "Set Main View"
4352msgstr "Задать основной вид"
4353
4354msgctxt "#90714"
4355msgid "Set Movies View"
4356msgstr "Задать вид фильмов"
4357
4358msgctxt "#90715"
4359msgid "Set TV Shows View"
4360msgstr "Задать вид сериалов"
4361
4362msgctxt "#90716"
4363msgid "Set Seasons View"
4364msgstr "Задать вид сезонов"
4365
4366msgctxt "#90717"
4367msgid "Set Episodes View"
4368msgstr "Задать вид эпизодов"
4369
4370msgctxt "#90718"
4371msgid "Set Library View"
4372msgstr "Задать вид библиотеки"
4373
4374msgctxt "#90719"
4375msgid "Set History View"
4376msgstr "Задать вид истории"
4377
4378msgctxt "#90720"
4379msgid "Set Downloads View"
4380msgstr "Задать вид загрузок"
4381
4382msgctxt "#90721"
4383msgid "Save Current View"
4384msgstr "Сохранить текущий вид"
4385
4386msgctxt "#90722"
4387msgid "View saved"
4388msgstr "Вид сохранён"
4389
4390msgctxt "#90723"
4391msgid "Could not detect the current view"
4392msgstr "Не удалось определить текущий вид"
4393
4394msgctxt "#90724"
4395msgid "Saved views reset"
4396msgstr "Сохранённые виды сброшены"
4397
4398msgctxt "#90725"
4399msgid "Reset Saved Views"
4400msgstr "Сбросить сохранённые виды"
4401
4402msgctxt "#90726"
4403msgid "Open this screen, change the Kodi view manually, then select Save Current View."
4404msgstr "Откройте этот экран, вручную измените вид Kodi, затем выберите Сохранить текущий вид."
4405
4406msgctxt "#90730"
4407msgid "Search with title + year"
4408msgstr "Искать по названию + году"
4409
4410msgctxt "#90731"
4411msgid "Search with original title"
4412msgstr "Искать по оригинальному названию"
4413
4414msgctxt "#90732"
4415msgid "Search with original title + year"
4416msgstr "Искать по оригинальному названию + году"
4417
4418msgctxt "#90733"
4419msgid "Search modes"
4420msgstr "Режимы поиска"
4421
4422msgctxt "#90734"
4423msgid "Enter year"
4424msgstr "Введите год"
4425
4426msgctxt "#90735"
4427msgid "Invalid year"
4428msgstr "Недопустимый год"
4429
4430msgctxt "#90736"
4431msgid "Edit title"
4432msgstr "Редактировать название"
4433
4434msgctxt "#90737"
4435msgid "Invalid season"
4436msgstr "Недопустимый сезон"
4437
4438msgctxt "#90738"
4439msgid "Episode"
4440msgstr "Эпизод"
4441
4442msgctxt "#90739"
4443msgid "Invalid episode"
4444msgstr "Недопустимый эпизод"
4445
4446msgctxt "#90819"
4447msgid "Rename Stremio add-on"
4448msgstr "Переименовать дополнение Stremio"
4449
4450msgctxt "#90824"
4451msgid "Rename Stremio catalog"
4452msgstr "Переименовать каталог Stremio"
4453
4454msgctxt "#90827"
4455msgid "Set a Kodi-only display alias for a Stremio add-on."
4456msgstr "Задать отображаемый псевдоним только в Kodi для дополнения Stremio."
4457
4458msgctxt "#90828"
4459msgid "Set a Kodi-only display alias for a Stremio catalog."
4460msgstr "Задать отображаемый псевдоним только в Kodi для каталога Stremio."
4461
4462msgctxt "#90832"
4463msgid "Stremio: Addons Management"
4464msgstr "Stremio: Управление дополнениями"
4465
4466msgctxt "#90833"
4467msgid "Stremio: Catalogs & Visibility"
4468msgstr "Stremio: Каталоги и видимость"
4469
4470msgctxt "#90834"
4471msgid "Stremio: Rename & Customization"
4472msgstr "Stremio: Переименование и настройка"
4473
4474msgctxt "#90835"
4475msgid "Stremio: Search & Behavior"
4476msgstr "Stremio: Поиск и поведение"
plugin.video.jacktook/resources/language/Spanish/strings.po +777 -21
……@@ -312,8 +312,8 @@ msgid "Unknown"
312312msgstr "Desconocido"
313313
314314msgctxt "#30700"
315msgid "Buffer and play"
316msgstr "Buffer y reproducir"
315msgid "Play Torrent"
316msgstr "Reproducir torrent"
317317
318318msgctxt "#30701"
319319msgid "Stop downloading"
……@@ -431,6 +431,58 @@ msgctxt "#30939"
431431msgid "Auto-resume from last position without asking"
432432msgstr "Reanudar automáticamente desde la última posición sin preguntar"
433433
434msgctxt "#30940"
435msgid "Use Percentage Trigger"
436msgstr "Usar disparo por porcentaje"
437
438msgctxt "#30941"
439msgid "Show Dialog at Playback Percentage"
440msgstr "Mostrar diálogo al porcentaje de reproducción"
441
442msgctxt "#30942"
443msgid "Trigger the next-episode dialog based on percentage of video played instead of seconds remaining."
444msgstr "Activar el diálogo del siguiente episodio basado en el porcentaje de video reproducido en lugar de los segundos restantes."
445
446msgctxt "#30943"
447msgid "The percentage at which the next-episode dialog appears (e.g. 95 means 95% of the video)."
448msgstr "El porcentaje en el que aparece el diálogo del siguiente episodio (ej. 95 significa 95% del video)."
449
450msgctxt "#30944"
451msgid "Auto-Play Timeout"
452msgstr "Tiempo de espera de auto-reproducción"
453
454msgctxt "#30945"
455msgid "Seconds before auto-playing next episode. 0 = disabled, dialog waits for user input."
456msgstr "Segundos antes de reproducir automáticamente el siguiente episodio. 0 = desactivado, el diálogo espera la entrada del usuario."
457
458
459msgctxt "#30946"
460msgid "Offcloud Configuration"
461msgstr "Configuración de Offcloud"
462
463msgctxt "#30947"
464msgid "Enable Offcloud cached torrent and cloud transfer support."
465msgstr "Habilitar soporte de torrents en caché y transferencias en la nube de Offcloud."
466
467msgctxt "#30948"
468msgid "Your Offcloud account username/email."
469msgstr "Tu nombre de usuario o correo electrónico de Offcloud."
470
471msgctxt "#30949"
472msgid "Offcloud API key from Account API Key settings."
473msgstr "Clave API de Offcloud desde la configuración de Cuenta → Clave API."
474
475msgctxt "#30950"
476msgid "View Offcloud account information."
477msgstr "Ver información de la cuenta de Offcloud."
478
479msgctxt "#30951"
480msgid "Authorize add-on to access Offcloud account."
481msgstr "Autorizar el complemento para acceder a la cuenta de Offcloud."
482
483msgctxt "#30952"
484msgid "Revoke Offcloud account access."
485msgstr "Revocar el acceso de la cuenta de Offcloud."
434486msgctxt "#30728"
435487msgid "Seeds"
436488msgstr "Seeds"
……@@ -459,6 +511,10 @@ msgctxt "#30734"
459511msgid "Enable"
460512msgstr "Habilitar"
461513
514msgctxt "#30735"
515msgid "Still Watching? Threshold"
516msgstr "¿Siguen viendo? Límite"
517
462518msgctxt "#30736"
463519msgid "Subtitle Language"
464520msgstr "Idioma prioritario"
……@@ -607,6 +663,10 @@ msgctxt "#30772"
607663msgid "Zilean Host"
608664msgstr "Host de Zilean"
609665
666msgctxt "#30773"
667msgid "Number of consecutive auto-played episodes before asking 'Still Watching?'. Set to 0 to disable."
668msgstr "Número de episodios reproducidos automáticamente antes de preguntar '¿Siguen viendo?'. Pon 0 para desactivar."
669
610670msgctxt "#30774"
611671msgid "Zilean Timeout"
612672msgstr "Tiempo de espera de Zilean"
……@@ -1556,8 +1616,8 @@ msgid "TV Calendar"
15561616msgstr "Calendario de TV"
15571617
15581618msgctxt "#90022"
1559msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:"
1560msgstr "Si disfrutas usando Jacktook y quieres apoyar su desarrollo, puedes comprarme un café! Tu apoyo ayuda a mantener el proyecto activo y habilita mejoras futuras:"
1619msgid "Jacktook is free and built with care in spare time. If you enjoy the addon, consider supporting its development. Donations are optional and help keep updates coming."
1620msgstr "Jacktook es gratis y está hecho con cariño en tiempo libre. Si disfrutas el addon, considera apoyar su desarrollo. Las donaciones son opcionales y ayudan a mantener las actualizaciones."
15611621
15621622msgctxt "#90023"
15631623msgid "Support Jacktook"
……@@ -1797,7 +1857,7 @@ msgstr "Buscar Personas"
17971857
17981858msgctxt "#90082"
17991859msgid "Download subtitles"
1800msgstr "Descargar Subtitulos"
1860msgstr "Descargar subtítulos"
18011861
18021862msgctxt "#90083"
18031863msgid "Download video"
……@@ -1991,21 +2051,37 @@ msgctxt "#90144"
19912051msgid "Could not resolve playback URL"
19922052msgstr "No se pudo resolver la URL de reproducción"
19932053
1994msgctxt "#90145"
1995msgid "Precaching Enabled"
1996msgstr "Precaching Habilitado"
2054msgctxt "#90171"
2055msgid "Autoscrape Next Episode"
2056msgstr "Autoscrape del Siguiente Episodio"
2057
2058msgctxt "#90172"
2059msgid "Pre-resolve the next episode's playback URL in the background while watching."
2060msgstr "Pre-resolver la URL de reproducción del siguiente episodio en segundo plano."
2061
2062msgctxt "#90173"
2063msgid "Autoscrape Threshold"
2064msgstr "Umbral de Autoscrape"
2065
2066msgctxt "#90174"
2067msgid "Percentage of playback to trigger background pre-resolution."
2068msgstr "Porcentaje de reproducción para disparar la pre-resolución en segundo plano."
19972069
1998msgctxt "#90146"
1999msgid "Enable or disable precaching of next episodes."
2000msgstr "Habilitar o deshabilitar el precaching de los próximos episodios."
2070msgctxt "#90175"
2071msgid "Autoscrape Episode Count"
2072msgstr "Cantidad de Episodios a Autoscrapear"
20012073
2002msgctxt "#90147"
2003msgid "Precaching Episode Count"
2004msgstr "Cantidad de Episodios a Precargar"
2074msgctxt "#90176"
2075msgid "Number of upcoming episodes to pre-resolve."
2076msgstr "Número de próximos episodios a pre-resolver."
20052077
2006msgctxt "#90148"
2007msgid "Number of next episodes to precache."
2008msgstr "Número de próximos episodios a precargar."
2078msgctxt "#90177"
2079msgid "Autoscrape Cache TTL"
2080msgstr "TTL de Caché de Autoscrape"
2081
2082msgctxt "#90178"
2083msgid "Hours to keep resolved playback data in cache."
2084msgstr "Horas para mantener los datos de reproducción resueltos en caché."
20092085
20102086msgctxt "#30901"
20112087msgid "Show Only Stremio Catalogs"
……@@ -2555,6 +2631,10 @@ msgctxt "#90298"
25552631msgid "Liked Lists"
25562632msgstr "Listas con Me gusta"
25572633
2634msgctxt "#90299"
2635msgid "No metadata found for this torrent"
2636msgstr "No se encontró metadata para este torrent"
2637
25582638msgctxt "#90331"
25592639msgid "Stremio Addons"
25602640msgstr "Complementos de Stremio"
……@@ -3665,12 +3745,16 @@ msgid "Invalid episode"
36653745msgstr "Episodio no válido"
36663746
36673747msgctxt "#90740"
3668msgid "Edit current title"
3669msgstr "Editar título actual"
3748msgid "Search with current title"
3749msgstr "Buscar con título actual"
36703750
36713751msgctxt "#90741"
3672msgid "Use original title"
3673msgstr "Usar título original"
3752msgid "Search with original title"
3753msgstr "Buscar con título original"
3754
3755msgctxt "#90773"
3756msgid "Search full show with title + year"
3757msgstr "Buscar serie completa con título + año"
36743758
36753759msgctxt "#90742"
36763760msgid "Addon Settings"
……@@ -3723,3 +3807,675 @@ msgstr "Solo inglés"
37233807msgctxt "#90754"
37243808msgid "Localized first"
37253809msgstr "Localizado primero"
3810
3811msgctxt "#90755"
3812msgid "Manage Sources"
3813msgstr "Gestionar Fuentes"
3814
3815msgctxt "#90756"
3816msgid "No sources available"
3817msgstr "No hay fuentes disponibles"
3818
3819msgctxt "#90179"
3820msgid "Stinger Notifications"
3821msgstr "Notificaciones de Escenas Post-créditos"
3822
3823msgctxt "#90180"
3824msgid "Show a notification when a movie has post-credit scenes."
3825msgstr "Mostrar una notificación cuando una película tenga escenas post-créditos."
3826
3827msgctxt "#90181"
3828msgid "Stinger Notification Time"
3829msgstr "Tiempo de Notificación Post-créditos"
3830
3831msgctxt "#90182"
3832msgid "Seconds before the end to show the stinger notification."
3833msgstr "Segundos antes del final para mostrar la notificación."
3834
3835msgctxt "#90183"
3836msgid "Stay tuned after the credits!"
3837msgstr "¡No te vayas, hay escenas post-créditos!"
3838
3839msgctxt "#90757"
3840msgid "Image Resolution"
3841msgstr "Resolución de Imagen"
3842
3843msgctxt "#90758"
3844msgid "Low"
3845msgstr "Baja"
3846
3847msgctxt "#90759"
3848msgid "Medium"
3849msgstr "Media"
3850
3851msgctxt "#90760"
3852msgid "High"
3853msgstr "Alta"
3854
3855msgctxt "#90761"
3856msgid "Original"
3857msgstr "Original"
3858
3859msgctxt "#90762"
3860msgid "Enable RPDB Posters"
3861msgstr "Habilitar Posters RPDB"
3862
3863msgctxt "#90763"
3864msgid "RPDB API Key"
3865msgstr "RPDB Clave API"
3866
3867msgctxt "#90764"
3868msgid "Get your API key at ratingposterdb.com"
3869msgstr "Obten tu clave API en ratingposterdb.com"
3870
3871msgctxt "#90765"
3872msgid "Filter by Codecs"
3873msgstr "Filtrado por Códecs"
3874
3875msgctxt "#90766"
3876msgid "Filter by HDR"
3877msgstr "Filtrar por HDR"
3878
3879msgctxt "#90767"
3880msgid "Include HEVC / H.265"
3881msgstr "Incluir HEVC / H.265"
3882
3883msgctxt "#90768"
3884msgid "Include AV1"
3885msgstr "Incluir AV1"
3886
3887msgctxt "#90769"
3888msgid "Include H.264"
3889msgstr "Incluir H.264"
3890
3891msgctxt "#90770"
3892msgid "Include Dolby Vision"
3893msgstr "Incluir Dolby Vision"
3894
3895msgctxt "#90771"
3896msgid "Include HDR10 / HDR10+"
3897msgstr "Incluir HDR10 / HDR10+"
3898
3899msgctxt "#90772"
3900msgid "Include HDR (generic)"
3901msgstr "Incluir HDR (genérico)"
3902
3903msgctxt "#90774"
3904msgid "Organize downloads by type"
3905msgstr "Organizar descargas por tipo"
3906
3907msgctxt "#90775"
3908msgid "Movies folder name"
3909msgstr "Nombre de carpeta de peliculas"
3910
3911msgctxt "#90776"
3912msgid "TV Shows folder name"
3913msgstr "Nombre de carpeta de series"
3914
3915msgctxt "#90777"
3916msgid "Pause"
3917msgstr "Pausar"
3918
3919msgctxt "#90778"
3920msgid "Resume"
3921msgstr "Reanudar"
3922
3923msgctxt "#90779"
3924msgid "Downloading"
3925msgstr "Descargando"
3926
3927msgctxt "#90780"
3928msgid "Paused"
3929msgstr "Pausado"
3930
3931msgctxt "#90781"
3932msgid "Completed"
3933msgstr "Completado"
3934
3935msgctxt "#90782"
3936msgid "Delete"
3937msgstr "Eliminar"
3938
3939msgctxt "#90783"
3940msgid "Startup"
3941msgstr "Inicio"
3942
3943msgctxt "#90784"
3944msgid "Auto-start Jacktook on Kodi boot"
3945msgstr "Iniciar Jacktook automáticamente al arrancar Kodi"
3946
3947msgctxt "#90785"
3948msgid "Automatically open Jacktook when Kodi starts"
3949msgstr "Abrir Jacktook automáticamente cuando Kodi se inicia"
3950
3951msgctxt "#90786"
3952msgid "Widgets"
3953msgstr "Widgets"
3954
3955msgctxt "#90787"
3956msgid "Widget refresh interval (minutes)"
3957msgstr "Intervalo de actualización de widgets (minutos)"
3958
3959msgctxt "#90788"
3960msgid "Set to 0 to disable automatic widget refresh"
3961msgstr "Establecer en 0 para desactivar la actualización automática"
3962
3963msgctxt "#90789"
3964msgid "Show refresh notification"
3965msgstr "Mostrar notificación de actualización"
3966
3967msgctxt "#90790"
3968msgid "Display a notification when widgets are refreshed"
3969msgstr "Mostrar una notificación cuando se actualicen los widgets"
3970
3971msgctxt "#90791"
3972msgid "Download to Device"
3973msgstr "Descargar al dispositivo"
3974
3975msgctxt "#90792"
3976msgid "Download Manager"
3977msgstr "Gestor de descargas"
3978
3979msgctxt "#90793"
3980msgid "No active downloads"
3981msgstr "Sin descargas activas"
3982
3983msgctxt "#90794"
3984msgid "Progress"
3985msgstr "Progreso"
3986
3987msgctxt "#90795"
3988msgid "Speed"
3989msgstr "Velocidad"
3990
3991msgctxt "#90796"
3992msgid "ETA"
3993msgstr "Tiempo restante"
3994
3995msgctxt "#90797"
3996msgid "Size"
3997msgstr "Tamaño"
3998
3999msgctxt "#90798"
4000msgid "Pause"
4001msgstr "Pausar"
4002
4003msgctxt "#90799"
4004msgid "Resume"
4005msgstr "Reanudar"
4006
4007msgctxt "#90800"
4008msgid "Cancel"
4009msgstr "Cancelar"
4010
4011msgctxt "#90801"
4012msgid "Delete"
4013msgstr "Eliminar"
4014
4015msgctxt "#90802"
4016msgid "Clear Completed"
4017msgstr "Limpiar completados"
4018
4019msgctxt "#90803"
4020msgid "Download not yet completed"
4021msgstr "Descarga aún no completada"
4022
4023msgctxt "#90804"
4024msgid "File not found"
4025msgstr "Archivo no encontrado"
4026
4027msgctxt "#90805"
4028msgid "Download Manager"
4029msgstr "Gestor de descargas"
4030
4031msgctxt "#90806"
4032msgid "My Downloads"
4033msgstr "Mis descargas"
4034
4035msgctxt "#90807"
4036msgid "Connecting..."
4037msgstr "Conectando..."
4038
4039msgctxt "#90808"
4040msgid "Close"
4041msgstr "Cerrar"
4042
4043msgctxt "#90809"
4044msgid "Delete download?"
4045msgstr "¿Eliminar descarga?"
4046
4047msgctxt "#90810"
4048msgid "This will permanently delete the downloaded file. Are you sure?"
4049msgstr "Esto eliminará permanentemente el archivo descargado. ¿Estás seguro?"
4050
4051msgctxt "#90811"
4052msgid "Completed"
4053msgstr "Completada"
4054
4055msgctxt "#90812"
4056msgid "Excluded TMDB Languages"
4057msgstr "Idiomas excluidos de TMDB"
4058
4059msgctxt "#90813"
4060msgid "Select languages to exclude"
4061msgstr "Selecciona los idiomas a excluir"
4062
4063msgctxt "#90814"
4064msgid "Choose original languages to hide from TMDB listings"
4065msgstr "Elige los idiomas originales que quieres ocultar de los listados de TMDB"
4066
4067msgctxt "#90815"
4068msgid "Support Jacktook"
4069msgstr "Apoya a Jacktook"
4070
4071msgctxt "#90816"
4072msgid "Support Jacktook development with an optional Ko-fi donation."
4073msgstr "Apoya el desarrollo de Jacktook con una donación opcional en Ko-fi."
4074
4075msgctxt "#90817"
4076msgid "Scan the QR code or open this link:"
4077msgstr "Escanea el código QR o abre este enlace:"
4078
4079msgctxt "#90818"
4080msgid "Jacktook was updated with new improvements. Donations are optional, but they help keep development active. Would you like to see the support link?"
4081msgstr "Jacktook se actualizó con nuevas mejoras. Las donaciones son opcionales, pero ayudan a mantener el desarrollo activo. ¿Quieres ver el enlace de apoyo?"
4082
4083msgctxt "#90300"
4084msgid "External Scraper"
4085msgstr "Scraper Externo"
4086
4087msgctxt "#90301"
4088msgid "Enable external scraper module integration."
4089msgstr "Habilitar la integración del módulo scraper externo."
4090
4091msgctxt "#90302"
4092msgid "Choose External Scraper Module"
4093msgstr "Elegir Módulo Scraper Externo"
4094
4095msgctxt "#90303"
4096msgid "Select an external scraper addon module to use for torrent search."
4097msgstr "Selecciona un módulo addon scraper externo para buscar torrents."
4098
4099msgctxt "#90304"
4100msgid "Choose External Scraper"
4101msgstr "Elegir Scraper Externo"
4102
4103msgctxt "#90305"
4104msgid "Selected Module"
4105msgstr "Módulo Seleccionado"
4106
4107msgctxt "#90306"
4108msgid "Open External Scraper Settings"
4109msgstr "Abrir Ajustes del Scraper Externo"
4110
4111msgctxt "#90307"
4112msgid "Open the settings for the selected external scraper module."
4113msgstr "Abrir los ajustes del módulo scraper externo seleccionado."
4114
4115msgctxt "#90382"
4116msgid "Please set a valid Trakt Client ID Key"
4117msgstr "Configura una clave de ID de cliente de Trakt válida"
4118
4119msgctxt "#90383"
4120msgid "Please set a valid Trakt Client Secret Key"
4121msgstr "Configura una clave secreta de cliente de Trakt válida"
4122
4123msgctxt "#90384"
4124msgid "Trakt Error Authorizing"
4125msgstr "Error de autorización de Trakt"
4126
4127msgctxt "#90385"
4128msgid "Trakt Account Authorized"
4129msgstr "Cuenta de Trakt autorizada"
4130
4131msgctxt "#90386"
4132msgid "You are now logged out from Trakt.tv"
4133msgstr "Has cerrado sesión en Trakt.tv"
4134
4135msgctxt "#90387"
4136msgid "Failed to mark as watched"
4137msgstr "No se pudo marcar como visto"
4138
4139msgctxt "#90388"
4140msgid "Marked as watched"
4141msgstr "Marcado como visto"
4142
4143msgctxt "#90389"
4144msgid "No results found"
4145msgstr "No se encontraron resultados"
4146
4147msgctxt "#90390"
4148msgid "Invalid mode"
4149msgstr "Modo no válido"
4150
4151msgctxt "#90391"
4152msgid "No recommendations found"
4153msgstr "No se encontraron recomendaciones"
4154
4155msgctxt "#90392"
4156msgid "No similar items found"
4157msgstr "No se encontraron elementos similares"
4158
4159msgctxt "#90393"
4160msgid "TMDB metadata not found"
4161msgstr "No se encontraron metadatos de TMDB"
4162
4163msgctxt "#90394"
4164msgid "No TV shows found"
4165msgstr "No se encontraron series"
4166
4167msgctxt "#90395"
4168msgid "No shows airing today"
4169msgstr "No hay series emitiéndose hoy"
4170
4171msgctxt "#90396"
4172msgid "Invalid collection query"
4173msgstr "Consulta de colección no válida"
4174
4175msgctxt "#90397"
4176msgid "No keywords found"
4177msgstr "No se encontraron palabras clave"
4178
4179msgctxt "#90398"
4180msgid "No keyword ID provided"
4181msgstr "No se proporcionó ID de palabra clave"
4182
4183msgctxt "#90399"
4184msgid "No results found for this keyword"
4185msgstr "No se encontraron resultados para esta palabra clave"
4186
4187msgctxt "#90400"
4188msgid "No TMDB ID found"
4189msgstr "No se encontró ID de TMDB"
4190
4191msgctxt "#90401"
4192msgid "Unsupported media type or missing external_ids"
4193msgstr "Tipo de medio no compatible o external_ids faltantes"
4194
4195msgctxt "#90402"
4196msgid "Refreshed Metadata"
4197msgstr "Metadatos actualizados"
4198
4199msgctxt "#90403"
4200msgid "No Stremio addons selected"
4201msgstr "No hay addons de Stremio seleccionados"
4202
4203msgctxt "#90404"
4204msgid "No suitable source found for auto play."
4205msgstr "No se encontró una fuente adecuada para reproducción automática."
4206
4207msgctxt "#90405"
4208msgid "No sources found with the preferred quality."
4209msgstr "No se encontraron fuentes con la calidad preferida."
4210
4211msgctxt "#90406"
4212msgid "No languages found"
4213msgstr "No se encontraron idiomas"
4214
4215msgctxt "#90407"
4216msgid "Failed to resolve playback source"
4217msgstr "No se pudo resolver la fuente de reproducción"
4218
4219msgctxt "#90408"
4220msgid "Trakt not authorized. Please authenticate."
4221msgstr "Trakt no está autorizado. Autentícate."
4222
4223msgctxt "#90409"
4224msgid "Added to Trakt Watchlist"
4225msgstr "Añadido a la lista de seguimiento de Trakt"
4226
4227msgctxt "#90428"
4228msgid "Database read error. File may be corrupted."
4229msgstr "Error al leer la base de datos. El archivo puede estar dañado."
4230
4231msgctxt "#90429"
4232msgid "Failed to delete data. Database error."
4233msgstr "No se pudieron eliminar los datos. Error de base de datos."
4234
4235msgctxt "#90430"
4236msgid "Failed to clear cache. Database in use."
4237msgstr "No se pudo limpiar la caché. La base de datos está en uso."
4238
4239msgctxt "#90458"
4240msgid "OpenSubtitles Stremio: No results found."
4241msgstr "OpenSubtitles Stremio: no se encontraron resultados."
4242
4243msgctxt "#90460"
4244msgid "Subtitle Manager: No subtitles available for this video."
4245msgstr "Gestor de subtítulos: no hay subtítulos disponibles para este video."
4246
4247msgctxt "#90468"
4248msgid "Search menu"
4249msgstr "Menú de búsqueda"
4250
4251msgctxt "#90469"
4252msgid "Continue watching menu"
4253msgstr "Menú de continuar viendo"
4254
4255msgctxt "#90470"
4256msgid "TV Shows Items"
4257msgstr "Elementos de series"
4258
4259msgctxt "#90471"
4260msgid "Movies Items"
4261msgstr "Elementos de películas"
4262
4263msgctxt "#90472"
4264msgid "Anime menu"
4265msgstr "Menú de anime"
4266
4267msgctxt "#90473"
4268msgid "TV Menu"
4269msgstr "Menú de TV"
4270
4271msgctxt "#90474"
4272msgid "Library menu"
4273msgstr "Menú de biblioteca"
4274
4275msgctxt "#90475"
4276msgid "Torrents Management"
4277msgstr "Gestión de torrents"
4278
4279msgctxt "#90476"
4280msgid "Telegram menu"
4281msgstr "Menú de Telegram"
4282
4283msgctxt "#90477"
4284msgid "Cloud Storage"
4285msgstr "Almacenamiento en la nube"
4286
4287msgctxt "#90478"
4288msgid "Downloads Management"
4289msgstr "Gestión de descargas"
4290
4291msgctxt "#90479"
4292msgid "Settings Management"
4293msgstr "Gestión de ajustes"
4294
4295msgctxt "#90480"
4296msgid "History Menu"
4297msgstr "Menú de historial"
4298
4299msgctxt "#90481"
4300msgid "Files History"
4301msgstr "Historial de archivos"
4302
4303msgctxt "#90482"
4304msgid "Titles History"
4305msgstr "Historial de títulos"
4306
4307msgctxt "#90483"
4308msgid "Titles Calendar"
4309msgstr "Calendario de títulos"
4310
4311msgctxt "#90484"
4312msgid "My Shows"
4313msgstr "Mis series"
4314
4315msgctxt "#90485"
4316msgid "My Movies"
4317msgstr "Mis películas"
4318
4319msgctxt "#90486"
4320msgid "Upcoming Episodes"
4321msgstr "Próximos episodios"
4322
4323msgctxt "#90487"
4324msgid "Discovery List"
4325msgstr "Lista de descubrimiento"
4326
4327msgctxt "#90488"
4328msgid "Library List"
4329msgstr "Lista de biblioteca"
4330
4331msgctxt "#90489"
4332msgid "Trakt Recommendations"
4333msgstr "Recomendaciones de Trakt"
4334
4335msgctxt "#90490"
4336msgid "Trending Lists"
4337msgstr "Listas en tendencia"
4338
4339msgctxt "#90491"
4340msgid "Popular Lists"
4341msgstr "Listas populares"
4342
4343msgctxt "#90492"
4344msgid "Search Lists"
4345msgstr "Buscar listas"
4346
4347msgctxt "#90493"
4348msgid "Recent Activity"
4349msgstr "Actividad reciente"
4350
4351msgctxt "#90494"
4352msgid "Downloads List"
4353msgstr "Lista de descargas"
4354
4355msgctxt "#90495"
4356msgid "Download New"
4357msgstr "Nueva descarga"
4358
4359msgctxt "#90496"
4360msgid "Cartoons Popular"
4361msgstr "Dibujos animados populares"
4362
4363msgctxt "#90497"
4364msgid "Animation Popular"
4365msgstr "Animación popular"
4366
4367msgctxt "#90500"
4368msgid "Translating subtitle..."
4369msgstr "Traduciendo subtítulo..."
4370
4371msgctxt "#90501"
4372msgid "Translation done."
4373msgstr "Traducción completada."
4374
4375msgctxt "#90502"
4376msgid "Searching subtitles..."
4377msgstr "Buscando subtítulos..."
4378
4379msgctxt "#90503"
4380msgid "No subtitles found"
4381msgstr "No se encontraron subtítulos"
4382
4383msgctxt "#90504"
4384msgid "Failed to authenticate with DeepL. Please check your API key."
4385msgstr "No se pudo autenticar con DeepL. Comprueba tu clave API."
4386
4387msgctxt "#90505"
4388msgid "DeepL API key is missing or invalid."
4389msgstr "La clave API de DeepL falta o no es válida."
4390
4391msgctxt "#90712"
4392msgid "Views"
4393msgstr "Vistas"
4394
4395msgctxt "#90713"
4396msgid "Set Main View"
4397msgstr "Definir vista principal"
4398
4399msgctxt "#90714"
4400msgid "Set Movies View"
4401msgstr "Definir vista de películas"
4402
4403msgctxt "#90715"
4404msgid "Set TV Shows View"
4405msgstr "Definir vista de series"
4406
4407msgctxt "#90716"
4408msgid "Set Seasons View"
4409msgstr "Definir vista de temporadas"
4410
4411msgctxt "#90717"
4412msgid "Set Episodes View"
4413msgstr "Definir vista de episodios"
4414
4415msgctxt "#90718"
4416msgid "Set Library View"
4417msgstr "Definir vista de biblioteca"
4418
4419msgctxt "#90719"
4420msgid "Set History View"
4421msgstr "Definir vista de historial"
4422
4423msgctxt "#90720"
4424msgid "Set Downloads View"
4425msgstr "Definir vista de descargas"
4426
4427msgctxt "#90721"
4428msgid "Save Current View"
4429msgstr "Guardar vista actual"
4430
4431msgctxt "#90722"
4432msgid "View saved"
4433msgstr "Vista guardada"
4434
4435msgctxt "#90723"
4436msgid "Could not detect the current view"
4437msgstr "No se pudo detectar la vista actual"
4438
4439msgctxt "#90724"
4440msgid "Saved views reset"
4441msgstr "Vistas guardadas restablecidas"
4442
4443msgctxt "#90725"
4444msgid "Reset Saved Views"
4445msgstr "Restablecer vistas guardadas"
4446
4447msgctxt "#90726"
4448msgid "Open this screen, change the Kodi view manually, then select Save Current View."
4449msgstr "Abre esta pantalla, cambia manualmente la vista de Kodi y luego selecciona Guardar vista actual."
4450
4451msgctxt "#90819"
4452msgid "Rename Stremio add-on"
4453msgstr "Renombrar complemento de Stremio"
4454
4455msgctxt "#90824"
4456msgid "Rename Stremio catalog"
4457msgstr "Renombrar catálogo de Stremio"
4458
4459msgctxt "#90827"
4460msgid "Set a Kodi-only display alias for a Stremio add-on."
4461msgstr "Definir un alias de visualización solo en Kodi para un complemento de Stremio."
4462
4463msgctxt "#90828"
4464msgid "Set a Kodi-only display alias for a Stremio catalog."
4465msgstr "Definir un alias de visualización solo en Kodi para un catálogo de Stremio."
4466
4467msgctxt "#90832"
4468msgid "Stremio: Addons Management"
4469msgstr "Stremio: Gestión de complementos"
4470
4471msgctxt "#90833"
4472msgid "Stremio: Catalogs & Visibility"
4473msgstr "Stremio: Catálogos y visibilidad"
4474
4475msgctxt "#90834"
4476msgid "Stremio: Rename & Customization"
4477msgstr "Stremio: Renombrado y personalización"
4478
4479msgctxt "#90835"
4480msgid "Stremio: Search & Behavior"
4481msgstr "Stremio: Búsqueda y comportamiento"
plugin.video.jacktook/resources/settings.xml +444 -140
……@@ -3,21 +3,44 @@
33 <section id="plugin.video.jacktook">
44 <category id="general_category" label="30717">
55 <group id="appearance" label="90115">
6 <setting id="source_select_theme" type="string" label="90352" help="">
7 <default>0</default>
8 <constraints>
9 <options>
10 <option label="90353">0</option>
11 <option label="90354">1</option>
12 <option label="90355">2</option>
13 <option label="90356">3</option>
14 <option label="90357">4</option>
15 </options>
16 </constraints>
17 <control type="spinner" format="string" />
18 </setting>
19 </group>
20 <group id="search" label="90006">
6 <setting id="source_select_theme" type="string" label="90352" help="">
7 <default>0</default>
8 <constraints>
9 <options>
10 <option label="90353">0</option>
11 <option label="90354">1</option>
12 <option label="90355">2</option>
13 <option label="90356">3</option>
14 <option label="90357">4</option>
15 </options>
16 </constraints>
17 <control type="spinner" format="string" />
18 </setting>
19 </group>
20 <group id="startup" label="90783">
21 <setting id="auto_start_jacktook" type="boolean" label="90784" help="90785">
22 <default>false</default>
23 <control type="toggle" />
24 </setting>
25 </group>
26 <group id="widgets" label="90786">
27 <setting id="widget_refresh_timer" type="integer" label="90787" help="90788">
28 <default>60</default>
29 <constraints>
30 <minimum>0</minimum>
31 <step>15</step>
32 <maximum>180</maximum>
33 </constraints>
34 <control type="slider" format="integer">
35 <popup>false</popup>
36 </control>
37 </setting>
38 <setting id="widget_refresh_notification" type="boolean" label="90789" help="90790">
39 <default>true</default>
40 <control type="toggle" />
41 </setting>
42 </group>
43 <group id="search" label="90006">
2144 <setting id="search_dialog_style" type="string" label="90238" help="">
2245 <default>0</default>
2346 <constraints>
……@@ -59,7 +82,7 @@
5982 <close>false</close>
6083 </control>
6184 </setting>
62 <setting id="donate" type="action" label="90018" help="">
85 <setting id="donate" type="action" label="90018" help="90816">
6386 <data>RunPlugin(plugin://plugin.video.jacktook/?action=donate)</data>
6487 <control type="button" format="action">
6588 <close>false</close>
……@@ -341,6 +364,79 @@
341364 </dependencies>
342365 </setting>
343366 </group>
367 <group id="offcloud" label="30946">
368 <setting id="offcloud_enabled" type="boolean" label="30734" help="30947">
369 <default>false</default>
370 <control type="toggle" />
371 </setting>
372 <setting id="offcloud_authorized" type="boolean" label="" help="">
373 <visible>false</visible>
374 <default>false</default>
375 <control type="toggle" />
376 <dependencies>
377 <dependency type="visible">
378 <condition operator="is" setting="offcloud_enabled">true</condition>
379 </dependency>
380 </dependencies>
381 </setting>
382 <setting id="offcloud_user" type="string" label="30782" help="30948">
383 <default />
384 <constraints>
385 <allowempty>true</allowempty>
386 </constraints>
387 <control type="edit" format="string" />
388 <dependencies>
389 <dependency type="visible">
390 <condition operator="is" setting="offcloud_enabled">true</condition>
391 </dependency>
392 </dependencies>
393 </setting>
394 <setting id="offcloud_token" type="string" label="30787" help="30949">
395 <default />
396 <constraints>
397 <allowempty>true</allowempty>
398 </constraints>
399 <control type="edit" format="string" />
400 <dependencies>
401 <dependency type="visible">
402 <condition operator="is" setting="offcloud_enabled">true</condition>
403 </dependency>
404 </dependencies>
405 </setting>
406 <setting id="oc_auth" type="action" label="30783" help="30951">
407 <data>RunPlugin(plugin://plugin.video.jacktook/?action=oc_auth)</data>
408 <control type="button" format="action">
409 <close>false</close>
410 </control>
411 <dependencies>
412 <dependency type="visible">
413 <condition operator="is" setting="offcloud_enabled">true</condition>
414 </dependency>
415 </dependencies>
416 </setting>
417 <setting id="oc_remove_auth" type="action" label="30784" help="30952">
418 <data>RunPlugin(plugin://plugin.video.jacktook/?action=oc_remove_auth)</data>
419 <control type="button" format="action">
420 <close>false</close>
421 </control>
422 <dependencies>
423 <dependency type="visible">
424 <condition operator="is" setting="offcloud_enabled">true</condition>
425 </dependency>
426 </dependencies>
427 </setting>
428 <setting id="offcloud_info" type="action" label="30916" help="30950">
429 <data>RunPlugin(plugin://plugin.video.jacktook/?action=offcloud_info)</data>
430 <control type="button" format="action">
431 <close>false</close>
432 </control>
433 <dependencies>
434 <dependency type="visible">
435 <condition operator="is" setting="offcloud_enabled">true</condition>
436 </dependency>
437 </dependencies>
438 </setting>
439 </group>
344440 <group id="premiumize" label="30785">
345441 <setting id="premiumize_enabled" type="boolean" label="30734" help="30824">
346442 <default>false</default>
……@@ -499,7 +595,7 @@
499595 </group>
500596 </category>
501597 <category id="sources_category" label="30864">
502 <group id="stremio_general" label="90150">
598 <group id="stremio_general" label="90151">
503599 <setting id="stremio_enabled" type="boolean" label="30734" help="30869">
504600 <default>false</default>
505601 <control type="toggle" />
……@@ -523,63 +619,10 @@
523619 <control type="edit" format="string" />
524620 <visible>false</visible>
525621 </setting>
526 <setting id="stremio_timeout" type="integer" label="90123" help="">
527 <default>25</default>
528 <constraints>
529 <minimum>1</minimum>
530 <maximum>120</maximum>
531 <step>1</step>
532 </constraints>
533 <control type="slider" format="integer">
534 <popup>false</popup>
535 </control>
536 <dependencies>
537 <dependency type="visible">
538 <condition operator="is" setting="stremio_enabled">true</condition>
539 </dependency>
540 </dependencies>
541 </setting>
542 <setting id="stremio_only_catalogs" type="boolean" label="30901" help="30902">
543 <default>false</default>
544 <control type="toggle" />
545 <dependencies>
546 <dependency type="visible">
547 <condition operator="is" setting="stremio_enabled">true</condition>
548 </dependency>
549 </dependencies>
550 </setting>
551 <setting id="stremio_ping_addons" type="action" label="30930" help="30931">
552 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_filtered_selection)</data>
553 <control type="button" format="action">
554 <close>false</close>
555 </control>
556 <dependencies>
557 <dependency type="visible">
558 <condition operator="is" setting="stremio_enabled">true</condition>
559 </dependency>
560 </dependencies>
561 </setting>
562 <setting id="clear_stremio_cache" type="action" label="90343">
563 <data>RunPlugin(plugin://plugin.video.jacktook/?action=clear_stremio_cache)</data>
564 <control type="button" format="action">
565 <close>true</close>
566 </control>
567 <dependencies>
568 <dependency type="visible">
569 <and>
570 <condition operator="is" setting="stremio_enabled">true</condition>
571 <condition operator="is" setting="cache_enabled">true</condition>
572 </and>
573 </dependency>
574 </dependencies>
575 </setting>
576622 <setting id="stremio_login" type="action" label="30871" help="30866">
577623 <dependencies>
578624 <dependency type="visible">
579 <and>
580 <condition operator="is" setting="stremio_enabled">true</condition>
581 <condition operator="is" setting="stremio_loggedin">false</condition>
582 </and>
625 <condition operator="is" setting="stremio_loggedin">false</condition>
583626 </dependency>
584627 </dependencies>
585628 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_login)</data>
……@@ -590,10 +633,7 @@
590633 <setting id="stremio_logout" type="action" label="30868" help="30867">
591634 <dependencies>
592635 <dependency type="visible">
593 <and>
594 <condition operator="is" setting="stremio_enabled">true</condition>
595 <condition operator="is" setting="stremio_loggedin">true</condition>
596 </and>
636 <condition operator="is" setting="stremio_loggedin">true</condition>
597637 </dependency>
598638 </dependencies>
599639 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_logout)</data>
……@@ -604,10 +644,7 @@
604644 <setting id="stremio_update" type="action" label="30874" help="90336">
605645 <dependencies>
606646 <dependency type="visible">
607 <and>
608 <condition operator="is" setting="stremio_enabled">true</condition>
609 <condition operator="is" setting="stremio_loggedin">true</condition>
610 </and>
647 <condition operator="is" setting="stremio_loggedin">true</condition>
611648 </dependency>
612649 </dependencies>
613650 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_update)</data>
……@@ -615,69 +652,91 @@
615652 <close>false</close>
616653 </control>
617654 </setting>
655 </group>
656 <group id="stremio_addons_management" label="90832">
618657 <setting id="stremio_toggle_addons" type="action" label="30870" help="90337">
619658 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_toggle_addons)</data>
620659 <control type="button" format="action">
621660 <close>false</close>
622661 </control>
623 <dependencies>
624 <dependency type="visible">
625 <condition operator="is" setting="stremio_enabled">true</condition>
626 </dependency>
627 </dependencies>
662 </setting>
663 <setting id="stremio_add_custom_addon" type="action" label="90001" help="90002">
664 <data>RunPlugin(plugin://plugin.video.jacktook/?action=add_custom_stremio_addon)</data>
665 <control type="button" format="action">
666 <close>false</close>
667 </control>
668 </setting>
669 <setting id="stremio_remove_custom_addon" type="action" label="90138" help="90340">
670 <data>RunPlugin(plugin://plugin.video.jacktook/?action=remove_custom_stremio_addon)</data>
671 <control type="button" format="action">
672 <close>false</close>
673 </control>
674 </setting>
675 <setting id="stremio_manage_phone" type="action" label="90162" help="90163">
676 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_manage_phone)</data>
677 <control type="button" format="action">
678 <close>false</close>
679 </control>
680 </setting>
681 </group>
682 <group id="stremio_catalogs_visibility" label="90833">
683 <setting id="stremio_only_catalogs" type="boolean" label="30901" help="30902">
684 <default>false</default>
685 <control type="toggle" />
628686 </setting>
629687 <setting id="stremio_toggle_catalogs" type="action" label="30875" help="90338">
630688 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_toggle_catalogs)</data>
631689 <control type="button" format="action">
632690 <close>false</close>
633691 </control>
634 <dependencies>
635 <dependency type="visible">
636 <condition operator="is" setting="stremio_enabled">true</condition>
637 </dependency>
638 </dependencies>
639692 </setting>
640693 <setting id="stremio_toggle_tv_addons" type="action" label="30903" help="90339">
641694 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_toggle_tv_addons)</data>
642695 <control type="button" format="action">
643696 <close>false</close>
644697 </control>
645 <dependencies>
646 <dependency type="visible">
647 <condition operator="is" setting="stremio_enabled">true</condition>
648 </dependency>
649 </dependencies>
650698 </setting>
651 <setting id="stremio_add_custom_addon" type="action" label="90001" help="90002">
652 <data>RunPlugin(plugin://plugin.video.jacktook/?action=add_custom_stremio_addon)</data>
699 </group>
700 <group id="stremio_rename_customization" label="90834">
701 <setting id="stremio_rename_addon" type="action" label="90819" help="90827">
702 <data>RunPlugin(plugin://plugin.video.jacktook/?action=rename_stremio_addon)</data>
653703 <control type="button" format="action">
654704 <close>false</close>
655705 </control>
656 <dependencies>
657 <dependency type="visible">
658 <condition operator="is" setting="stremio_enabled">true</condition>
659 </dependency>
660 </dependencies>
661706 </setting>
662 <setting id="stremio_remove_custom_addon" type="action" label="90138" help="90340">
663 <data>RunPlugin(plugin://plugin.video.jacktook/?action=remove_custom_stremio_addon)</data>
707 <setting id="stremio_rename_catalog" type="action" label="90824" help="90828">
708 <data>RunPlugin(plugin://plugin.video.jacktook/?action=rename_stremio_catalog)</data>
664709 <control type="button" format="action">
665710 <close>false</close>
666711 </control>
667 <dependencies>
668 <dependency type="visible">
669 <condition operator="is" setting="stremio_enabled">true</condition>
670 </dependency>
671 </dependencies>
672712 </setting>
673 <setting id="stremio_manage_phone" type="action" label="90162" help="90163">
674 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_manage_phone)</data>
713 </group>
714 <group id="stremio_search_behavior" label="90835">
715 <setting id="stremio_timeout" type="integer" label="90123" help="">
716 <default>25</default>
717 <constraints>
718 <minimum>1</minimum>
719 <maximum>120</maximum>
720 <step>1</step>
721 </constraints>
722 <control type="slider" format="integer">
723 <popup>false</popup>
724 </control>
725 </setting>
726 <setting id="stremio_ping_addons" type="action" label="30930" help="30931">
727 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_filtered_selection)</data>
675728 <control type="button" format="action">
676 <close>false</close>
729 <close>false</close>
730 </control>
731 </setting>
732 <setting id="clear_stremio_cache" type="action" label="90343">
733 <data>RunPlugin(plugin://plugin.video.jacktook/?action=clear_stremio_cache)</data>
734 <control type="button" format="action">
735 <close>true</close>
677736 </control>
678737 <dependencies>
679738 <dependency type="visible">
680 <condition operator="is" setting="stremio_enabled">true</condition>
739 <condition operator="is" setting="cache_enabled">true</condition>
681740 </dependency>
682741 </dependencies>
683742 </setting>
……@@ -857,6 +916,44 @@
857916 </dependencies>
858917 </setting>
859918 </group>
919 <group id="external_scraper" label="90300">
920 <setting id="external_scraper_enabled" type="boolean" label="30734" help="90301">
921 <default>false</default>
922 <control type="toggle"/>
923 </setting>
924 <setting id="external_scraper_module" type="string">
925 <constraints>
926 <allowempty>true</allowempty>
927 </constraints>
928 <control type="edit" format="string" />
929 <visible>false</visible>
930 </setting>
931 <setting id="external_scraper_module_name" type="string">
932 <constraints>
933 <allowempty>true</allowempty>
934 </constraints>
935 <control type="edit" format="string" />
936 <visible>false</visible>
937 </setting>
938 <setting id="external_scraper_choose" type="action" label="90302" help="90303">
939 <data>RunPlugin(plugin://plugin.video.jacktook/?action=choose_external_scraper)</data>
940 <control type="button" format="action"/>
941 <dependencies>
942 <dependency type="visible">
943 <condition operator="is" setting="external_scraper_enabled">true</condition>
944 </dependency>
945 </dependencies>
946 </setting>
947 <setting id="external_scraper_open_settings" type="action" label="90306" help="90307">
948 <data>RunPlugin(plugin://plugin.video.jacktook/?action=open_external_scraper_settings)</data>
949 <control type="button" format="action"/>
950 <dependencies>
951 <dependency type="visible">
952 <condition operator="!is" setting="external_scraper_module"/>
953 </dependency>
954 </dependencies>
955 </setting>
956 </group>
860957 </category>
861958 <category id="playback_category" label="90139">
862959 <group id="playback_group" label="90139">
……@@ -894,11 +991,27 @@
894991 </dependency>
895992 </dependencies>
896993 </setting>
897 <setting id="precaching_enabled" type="boolean" label="90145" help="90146">
994 <setting id="autoscrape_next_episode" type="boolean" label="90171" help="90172">
898995 <default>false</default>
899996 <control type="toggle" />
900997 </setting>
901 <setting id="precaching_episode_count" type="integer" label="90147" help="90148">
998 <setting id="autoscrape_threshold" type="integer" label="90173" help="90174">
999 <default>70</default>
1000 <constraints>
1001 <minimum>50</minimum>
1002 <maximum>90</maximum>
1003 <step>1</step>
1004 </constraints>
1005 <control type="slider" format="integer">
1006 <popup>false</popup>
1007 </control>
1008 <dependencies>
1009 <dependency type="visible">
1010 <condition operator="is" setting="autoscrape_next_episode">true</condition>
1011 </dependency>
1012 </dependencies>
1013 </setting>
1014 <setting id="autoscrape_episode_count" type="integer" label="90175" help="90176">
9021015 <default>1</default>
9031016 <constraints>
9041017 <minimum>1</minimum>
……@@ -910,14 +1023,31 @@
9101023 </control>
9111024 <dependencies>
9121025 <dependency type="visible">
913 <condition operator="is" setting="precaching_enabled">true</condition>
1026 <condition operator="is" setting="autoscrape_next_episode">true</condition>
1027 </dependency>
1028 </dependencies>
1029 </setting>
1030 <setting id="autoscrape_ttl" type="integer" label="90177" help="90178">
1031 <default>4</default>
1032 <constraints>
1033 <minimum>1</minimum>
1034 <maximum>12</maximum>
1035 <step>1</step>
1036 </constraints>
1037 <control type="slider" format="integer">
1038 <popup>false</popup>
1039 </control>
1040 <dependencies>
1041 <dependency type="visible">
1042 <condition operator="is" setting="autoscrape_next_episode">true</condition>
9141043 </dependency>
9151044 </dependencies>
916 </setting> <setting id="playnext_dialog_enabled" type="boolean" label="30719" help="30834">
1045 </setting>
1046 <setting id="playnext_dialog_enabled" type="boolean" label="30719" help="30834">
9171047 <default>true</default>
9181048 <control type="toggle" />
9191049 </setting>
920 <setting id="playnext_time" type="integer" label="30720" help="30829">
1050 <setting id="playnext_time" type="integer" label="30720" help="30829">
9211051 <default>50</default>
9221052 <constraints>
9231053 <minimum>5</minimum>
……@@ -927,6 +1057,71 @@
9271057 <control type="slider" format="integer">
9281058 <popup>false</popup>
9291059 </control>
1060 <dependencies>
1061 <dependency type="visible">
1062 <condition operator="is" setting="playnext_use_percentage">false</condition>
1063 </dependency>
1064 </dependencies>
1065 </setting>
1066 <setting id="playnext_use_percentage" type="boolean" label="30940" help="30942">
1067 <default>false</default>
1068 <control type="toggle" />
1069 <dependencies>
1070 <dependency type="visible">
1071 <condition operator="is" setting="playnext_dialog_enabled">true</condition>
1072 </dependency>
1073 </dependencies>
1074 </setting>
1075 <setting id="playnext_percentage" type="integer" label="30941" help="30943">
1076 <default>95</default>
1077 <constraints>
1078 <minimum>75</minimum>
1079 <maximum>99</maximum>
1080 <step>1</step>
1081 </constraints>
1082 <control type="slider" format="integer">
1083 <popup>false</popup>
1084 </control>
1085 <dependencies>
1086 <dependency type="visible">
1087 <and>
1088 <condition operator="is" setting="playnext_dialog_enabled">true</condition>
1089 <condition operator="is" setting="playnext_use_percentage">true</condition>
1090 </and>
1091 </dependency>
1092 </dependencies>
1093 </setting>
1094 <setting id="playnext_threshold" type="integer" label="30735" help="30773">
1095 <default>0</default>
1096 <constraints>
1097 <minimum>0</minimum>
1098 <maximum>10</maximum>
1099 <step>1</step>
1100 </constraints>
1101 <control type="slider" format="integer">
1102 <popup>false</popup>
1103 </control>
1104 <dependencies>
1105 <dependency type="visible">
1106 <condition operator="is" setting="playnext_dialog_enabled">true</condition>
1107 </dependency>
1108 </dependencies>
1109 </setting>
1110 <setting id="playnext_auto_timeout" type="integer" label="30944" help="30945">
1111 <default>10</default>
1112 <constraints>
1113 <minimum>5</minimum>
1114 <maximum>30</maximum>
1115 <step>1</step>
1116 </constraints>
1117 <control type="slider" format="integer">
1118 <popup>false</popup>
1119 </control>
1120 <dependencies>
1121 <dependency type="visible">
1122 <condition operator="is" setting="playnext_dialog_enabled">true</condition>
1123 </dependency>
1124 </dependencies>
9301125 </setting>
9311126 <setting id="volume_check_enabled" type="boolean" label="90216" help="90217">
9321127 <default>false</default>
……@@ -1000,17 +1195,34 @@
10001195 </dependencies>
10011196 </setting>
10021197 </group>
1198 <group id="stinger" label="90179">
1199 <setting id="stinger_notifications_enabled" type="boolean" label="90179" help="90180">
1200 <default>true</default>
1201 <control type="toggle" />
1202 </setting>
1203 <setting id="stinger_notification_time" type="integer" label="90181" help="90182">
1204 <default>180</default>
1205 <constraints>
1206 <minimum>30</minimum>
1207 <maximum>600</maximum>
1208 <step>30</step>
1209 </constraints>
1210 <control type="slider" format="integer">
1211 <popup>false</popup>
1212 </control>
1213 <dependencies>
1214 <dependency type="visible">
1215 <condition operator="is" setting="stinger_notifications_enabled">true</condition>
1216 </dependency>
1217 </dependencies>
1218 </setting>
1219 </group>
10031220 </category>
10041221 <category id="filtering_category" label="30861">
10051222 <group id="filtering_stremio" label="90155">
10061223 <setting id="stremio_bypass_addons" type="boolean" label="90211" help="90212">
10071224 <default>false</default>
10081225 <control type="toggle" />
1009 <dependencies>
1010 <dependency type="visible">
1011 <condition operator="is" setting="stremio_enabled">true</condition>
1012 </dependency>
1013 </dependencies>
10141226 </setting>
10151227 <setting id="stremio_bypass_addons_select" type="action" label="90213" help="90214">
10161228 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_bypass_addons_select)</data>
……@@ -1019,10 +1231,7 @@
10191231 </control>
10201232 <dependencies>
10211233 <dependency type="visible">
1022 <and>
1023 <condition operator="is" setting="stremio_enabled">true</condition>
1024 <condition operator="is" setting="stremio_bypass_addons">true</condition>
1025 </and>
1234 <condition operator="is" setting="stremio_bypass_addons">true</condition>
10261235 </dependency>
10271236 </dependencies>
10281237 </setting>
……@@ -1115,6 +1324,34 @@
11151324 <control type="toggle" />
11161325 </setting>
11171326 </group>
1327 <group id="codec_filters" label="90765">
1328 <setting id="codec_hevc_enabled" type="boolean" label="90767" help="">
1329 <default>true</default>
1330 <control type="toggle" />
1331 </setting>
1332 <setting id="codec_av1_enabled" type="boolean" label="90768" help="">
1333 <default>true</default>
1334 <control type="toggle" />
1335 </setting>
1336 <setting id="codec_h264_enabled" type="boolean" label="90769" help="">
1337 <default>true</default>
1338 <control type="toggle" />
1339 </setting>
1340 </group>
1341 <group id="hdr_filters" label="90766">
1342 <setting id="dolby_vision_enabled" type="boolean" label="90770" help="">
1343 <default>true</default>
1344 <control type="toggle" />
1345 </setting>
1346 <setting id="hdr10_enabled" type="boolean" label="90771" help="">
1347 <default>true</default>
1348 <control type="toggle" />
1349 </setting>
1350 <setting id="hdr_enabled" type="boolean" label="90772" help="">
1351 <default>true</default>
1352 <control type="toggle" />
1353 </setting>
1354 </group>
11181355 <group id="size_filter" label="90058">
11191356 <setting id="filter_size_enabled" type="boolean" label="90059" help="">
11201357 <default>false</default>
……@@ -1169,11 +1406,6 @@
11691406 <setting id="stremio_subtitle_enabled" type="boolean" label="30734" help="">
11701407 <default>false</default>
11711408 <control type="toggle" />
1172 <dependencies>
1173 <dependency type="visible">
1174 <condition operator="is" setting="stremio_enabled">true</condition>
1175 </dependency>
1176 </dependencies>
11771409 </setting>
11781410 <setting id="stremio_sub_addon_host" type="string" label="30885" help="">
11791411 <default>https://opensubtitles-v3.strem.io/</default>
……@@ -1182,10 +1414,7 @@
11821414 </constraints>
11831415 <dependencies>
11841416 <dependency type="visible">
1185 <and>
1186 <condition operator="is" setting="stremio_enabled">true</condition>
1187 <condition operator="is" setting="stremio_subtitle_enabled">true</condition>
1188 </and>
1417 <condition operator="is" setting="stremio_subtitle_enabled">true</condition>
11891418 </dependency>
11901419 </dependencies>
11911420 <control type="edit" format="string" />
……@@ -1216,11 +1445,6 @@
12161445 </options>
12171446 </constraints>
12181447 <control type="list" format="string" />
1219 <dependencies>
1220 <dependency type="visible">
1221 <condition operator="is" setting="stremio_enabled">true</condition>
1222 </dependency>
1223 </dependencies>
12241448 </setting>
12251449 </group>
12261450 <group id="deepl_translator" label="30891">
……@@ -1399,6 +1623,50 @@
13991623 </dependency>
14001624 </dependencies>
14011625 </setting>
1626 <setting id="image_resolution_tier" type="string" label="90757" help="">
1627 <default>2</default>
1628 <constraints>
1629 <options>
1630 <option label="90758">0</option>
1631 <option label="90759">1</option>
1632 <option label="90760">2</option>
1633 <option label="90761">3</option>
1634 </options>
1635 </constraints>
1636 <control type="spinner" format="string" />
1637 </setting>
1638 <setting id="open_excluded_languages_dialog" type="action" label="90812" help="90814">
1639 <data>RunPlugin(plugin://plugin.video.jacktook/?action=open_excluded_languages_dialog)</data>
1640 <control type="button" format="action">
1641 <close>false</close>
1642 </control>
1643 </setting>
1644 <setting id="excluded_languages" type="string" label="">
1645 <visible>false</visible>
1646 <default>[]</default>
1647 <constraints>
1648 <allowempty>true</allowempty>
1649 </constraints>
1650 <control type="edit" format="string" />
1651 </setting>
1652 </group>
1653 <group id="rpdb" label="90762">
1654 <setting id="rpdb_enabled" type="boolean" label="90762" help="90764">
1655 <default>false</default>
1656 <control type="toggle" />
1657 </setting>
1658 <setting id="rpdb_api_key" type="string" label="90763" help="90764">
1659 <default />
1660 <constraints>
1661 <allowempty>true</allowempty>
1662 </constraints>
1663 <control type="edit" format="string" />
1664 <dependencies>
1665 <dependency type="visible">
1666 <condition operator="is" setting="rpdb_enabled">true</condition>
1667 </dependency>
1668 </dependencies>
1669 </setting>
14021670 </group>
14031671 <group id="calendar" label="30896">
14041672 <setting id="use_broadcast_adjustment" type="boolean" label="30897" help="30898">
……@@ -1695,6 +1963,34 @@
16951963 </constraints>
16961964 <control type="button" format="path"/>
16971965 </setting>
1966 <setting id="organize_downloads" type="boolean" label="90774" help="">
1967 <default>false</default>
1968 <control type="toggle" />
1969 </setting>
1970 <setting id="download_folder_movies" type="string" label="90775" help="">
1971 <default>Movies</default>
1972 <constraints>
1973 <allowempty>false</allowempty>
1974 </constraints>
1975 <control type="edit" format="string" />
1976 <dependencies>
1977 <dependency type="visible">
1978 <condition operator="is" setting="organize_downloads">true</condition>
1979 </dependency>
1980 </dependencies>
1981 </setting>
1982 <setting id="download_folder_tvshows" type="string" label="90776" help="">
1983 <default>TV Shows</default>
1984 <constraints>
1985 <allowempty>false</allowempty>
1986 </constraints>
1987 <control type="edit" format="string" />
1988 <dependencies>
1989 <dependency type="visible">
1990 <condition operator="is" setting="organize_downloads">true</condition>
1991 </dependency>
1992 </dependencies>
1993 </setting>
16981994 </group>
16991995 <group id="cleanup" label="30106">
17001996 <setting id="clear_database_cache" type="action" label="90342">
……@@ -1788,6 +2084,14 @@
17882084 </constraints>
17892085 <control type="edit" format="string" />
17902086 </setting>
2087 <setting id="donation_prompt_last_version" type="string" label="" help="">
2088 <visible>false</visible>
2089 <default />
2090 <constraints>
2091 <allowempty>true</allowempty>
2092 </constraints>
2093 <control type="edit" format="string" />
2094 </setting>
17912095 </group>
17922096 </category>
17932097 </section>
plugin.video.jacktook/resources/skins/Default/1080i/custom_progress_dialog.xml +110 -57
……@@ -1,92 +1,145 @@
11<window type="dialog" id="12000">
22 <coordinates>
3 <left>600</left>
4 <top>400</top>
5 <width>800</width>
6 <height>300</height>
3 <left>460</left>
4 <top>320</top>
5 <width>1000</width>
6 <height>440</height>
77 </coordinates>
8 <defaultcontrol always="true">12005</defaultcontrol>
8 <defaultcontrol always="true">12003</defaultcontrol>
99 <animation type="WindowOpen" reversible="false">
10 <effect type="zoom" start="80" end="100" center="50%,50%" delay="160" tween="back" time="240"/>
11 <effect type="fade" delay="160" end="100" time="240"/>
10 <effect type="fade" start="0" end="100" time="200" />
1211 </animation>
1312 <animation type="WindowClose" reversible="false">
14 <effect type="zoom" start="100" end="80" center="50%,50%" easing="in" tween="back" time="240"/>
15 <effect type="fade" start="100" end="0" time="240"/>
13 <effect type="fade" start="100" end="0" time="200" />
1614 </animation>
17 <depth>0.40</depth>
1815 <controls>
16 <!-- Card background -->
17 <control type="image">
18 <left>0</left>
19 <top>0</top>
20 <width>1000</width>
21 <height>440</height>
22 <texture border="20">circle.png</texture>
23 <colordiffuse>FF1A1A1A</colordiffuse>
24 </control>
25
26 <!-- Accent bar -->
27 <control type="image">
28 <left>0</left>
29 <top>0</top>
30 <width>1000</width>
31 <height>4</height>
32 <texture>white.png</texture>
33 <colordiffuse>FF00559D</colordiffuse>
34 </control>
35
1936 <control type="group">
20 <width>800</width>
21 <height>300</height>
22 <control type="image">
23 <width>800</width>
24 <height>300</height>
25 <texture border="30">circle.png</texture>
26 <colordiffuse>CC000000</colordiffuse>
27 </control>
37 <left>40</left>
38 <top>25</top>
39 <width>920</width>
40 <height>390</height>
41
42 <!-- Title -->
2843 <control type="label" id="12001">
29 <top>30</top>
30 <width>780</width>
31 <height>40</height>
44 <top>0</top>
45 <width>920</width>
46 <height>35</height>
3247 <align>center</align>
33 <font>font14</font>
48 <font>font30_title</font>
3449 <textcolor>white</textcolor>
3550 <shadowcolor>AA000000</shadowcolor>
3651 </control>
52
53 <!-- File name -->
3754 <control type="label" id="12002">
38 <top>80</top>
39 <width>780</width>
40 <height>60</height>
55 <top>40</top>
56 <width>920</width>
57 <height>50</height>
4158 <align>center</align>
42 <font>font12</font>
43 <textcolor>white</textcolor>
59 <font>font14</font>
60 <textcolor>EEEEEE</textcolor>
4461 <shadowcolor>AA000000</shadowcolor>
4562 <wrapmultiline>true</wrapmultiline>
4663 </control>
64
65 <!-- Big percentage -->
66 <control type="label" id="12006">
67 <top>100</top>
68 <width>920</width>
69 <height>70</height>
70 <align>center</align>
71 <font>font48</font>
72 <textcolor>FFFFD700</textcolor>
73 <shadowcolor>AA000000</shadowcolor>
74 <label>0%</label>
75 </control>
76
77 <!-- Details row -->
78 <control type="label" id="12007">
79 <top>175</top>
80 <left>0</left>
81 <width>455</width>
82 <height>25</height>
83 <align>left</align>
84 <font>font12</font>
85 <textcolor>CCCCCC</textcolor>
86 <label> </label>
87 </control>
88 <control type="label" id="12008">
89 <top>175</top>
90 <left>465</left>
91 <width>455</width>
92 <height>25</height>
93 <align>right</align>
94 <font>font12</font>
95 <textcolor>CCCCCC</textcolor>
96 <label> </label>
97 </control>
98
99 <!-- Progress bar -->
47100 <control type="progress" id="12004">
48 <top>180</top>
49 <left>40</left>
50 <width>720</width>
51 <height>20</height>
101 <top>210</top>
102 <left>0</left>
103 <width>920</width>
104 <height>24</height>
52105 <info>percentage</info>
53106 </control>
54 <control type="grouplist" orientation="horizontal">
55 <orientation>horizontal</orientation>
56 <top>220</top>
57 <left>200</left>
58 <itemgap>100</itemgap> <!-- Adjust spacing between buttons -->
59 <!-- Cancel Button -->
107
108 <!-- Buttons -->
109 <control type="group">
110 <top>250</top>
111 <left>0</left>
112 <width>920</width>
113 <height>50</height>
114 <!-- Cancel -->
60115 <control type="button" id="12003">
61 <width>auto</width>
62 <height>56</height>
63 <label>Cancel</label>
116 <left>140</left>
117 <width>260</width>
118 <height>50</height>
119 <label>$ADDON[plugin.video.jacktook 90800]</label>
64120 <align>center</align>
65 <textoffsetx>20</textoffsetx>
66 <font>font14</font>
121 <font>font13</font>
67122 <textcolor>DDFFFFFF</textcolor>
68123 <focusedcolor>EEFFFFFF</focusedcolor>
69 <selectedcolor>DDFFFFFF</selectedcolor>
70 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
71 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
72 <pulseonselect>no</pulseonselect>
124 <texturefocus border="20" colordiffuse="FFB22222">circle.png</texturefocus>
125 <texturenofocus border="20" colordiffuse="66000000">circle.png</texturenofocus>
126 <onright>12005</onright>
73127 </control>
74 <!-- Close Button -->
128 <!-- Close -->
75129 <control type="button" id="12005">
76 <width>auto</width>
77 <height>56</height>
78 <label>Close</label>
130 <left>520</left>
131 <width>260</width>
132 <height>50</height>
133 <label>$ADDON[plugin.video.jacktook 90808]</label>
79134 <align>center</align>
80 <textoffsetx>20</textoffsetx>
81 <font>font14</font>
135 <font>font13</font>
82136 <textcolor>DDFFFFFF</textcolor>
83137 <focusedcolor>EEFFFFFF</focusedcolor>
84 <selectedcolor>DDFFFFFF</selectedcolor>
85 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
86 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
87 <pulseonselect>no</pulseonselect>
138 <texturefocus border="20" colordiffuse="FF999999">circle.png</texturefocus>
139 <texturenofocus border="20" colordiffuse="66000000">circle.png</texturenofocus>
140 <onleft>12003</onleft>
88141 </control>
89142 </control>
90143 </control>
91144 </controls>
92</window>
145</window>
plugin.video.jacktook/resources/skins/Default/1080i/customdialog.xml +26 -16
……@@ -34,7 +34,7 @@
3434 <control type="label" id="32500">
3535 <top>20</top>
3636 <width>1000</width>
37 <height>40</height>
37 <height>50</height>
3838 <font>font25</font>
3939 <align>center</align>
4040 <textcolor>white</textcolor>
……@@ -43,10 +43,10 @@
4343
4444 <!-- QR Code -->
4545 <control type="image" id="32504">
46 <top>110</top>
47 <left>60</left>
48 <width>280</width>
49 <height>280</height>
46 <top>95</top>
47 <left>55</left>
48 <width>330</width>
49 <height>330</height>
5050 <texture>qr_placeholder.png</texture>
5151 <aspectratio>keep</aspectratio>
5252 </control>
……@@ -54,10 +54,10 @@
5454 <!-- Text Body -->
5555 <control type="label" id="32501">
5656 <font>font14</font>
57 <top>110</top>
58 <left>400</left>
59 <width>550</width>
60 <height>180</height>
57 <top>100</top>
58 <left>430</left>
59 <width>510</width>
60 <height>185</height>
6161 <align>left</align>
6262 <textcolor>white</textcolor>
6363 <shadowcolor>AA000000</shadowcolor>
……@@ -76,17 +76,27 @@
7676 </control>
7777
7878 <control type="group">
79 <width>550</width>
80 <height>100</height>
81 <top>320</top>
82 <left>400</left>
79 <width>510</width>
80 <height>110</height>
81 <top>355</top>
82 <left>430</left>
8383
8484 <!-- Card with Rounded Corners -->
8585 <control type="image">
8686 <right>-6</right>
8787 <bottom>-2</bottom>
8888 <texture border="30">circle.png</texture>
89 <colordiffuse>CCFF0000</colordiffuse>
89 <colordiffuse>DD12A0C7</colordiffuse>
90 </control>
91
92 <!-- Call to action -->
93 <control type="label" id="32505">
94 <font>font13</font>
95 <top>10</top>
96 <align>center</align>
97 <height>25</height>
98 <textcolor>white</textcolor>
99 <shadowcolor>FF000000</shadowcolor>
90100 </control>
91101
92102 <!-- Text URL -->
……@@ -94,7 +104,8 @@
94104 <font>font14</font>
95105 <align>center</align>
96106 <aligny>center</aligny>
97 <height>100</height>
107 <top>48</top>
108 <height>55</height>
98109 <textcolor>white</textcolor>
99110 <shadowcolor>FF000000</shadowcolor>
100111 <wrap>true</wrap>
……@@ -102,4 +113,3 @@
102113 </control>
103114 </controls>
104115</window>
105
plugin.video.jacktook/resources/skins/Default/1080i/download_manager.xml +358 -0
……@@ -0,0 +1,358 @@
1<window type="dialog" id="14000">
2 <coordinates>
3 <left>0</left>
4 <top>0</top>
5 <width>1920</width>
6 <height>1080</height>
7 </coordinates>
8 <defaultcontrol always="true">14002</defaultcontrol>
9 <animation type="WindowOpen" reversible="false">
10 <effect type="fade" start="0" end="100" time="300" />
11 </animation>
12 <animation type="WindowClose" reversible="false">
13 <effect type="fade" start="100" end="0" time="300" />
14 </animation>
15 <controls>
16 <!-- Background Fanart -->
17 <control type="image">
18 <left>0</left>
19 <top>0</top>
20 <width>1920</width>
21 <height>1080</height>
22 <texture background="true">$INFO[Window.Property(info.fanart)]</texture>
23 <aspectratio align="center" aligny="center">scale</aspectratio>
24 </control>
25 <!-- Solid dark overlay -->
26 <control type="image">
27 <left>0</left>
28 <top>0</top>
29 <width>1920</width>
30 <height>1080</height>
31 <texture>white.png</texture>
32 <colordiffuse>CC000000</colordiffuse>
33 </control>
34
35 <!-- Main Card Background -->
36 <control type="image">
37 <left>340</left>
38 <top>100</top>
39 <width>1240</width>
40 <height>880</height>
41 <texture border="20">circle.png</texture>
42 <colordiffuse>FF1A1A1A</colordiffuse>
43 </control>
44
45 <!-- Accent Bar (top) -->
46 <control type="image">
47 <left>340</left>
48 <top>100</top>
49 <width>1240</width>
50 <height>4</height>
51 <texture>white.png</texture>
52 <colordiffuse>FF00559D</colordiffuse>
53 </control>
54
55 <control type="group">
56 <left>360</left>
57 <top>120</top>
58 <width>1200</width>
59 <height>840</height>
60
61 <!-- Title -->
62 <control type="label">
63 <top>0</top>
64 <width>1100</width>
65 <height>50</height>
66 <align>center</align>
67 <font>font30_title</font>
68 <textcolor>white</textcolor>
69 <shadowcolor>AA000000</shadowcolor>
70 <label>$ADDON[plugin.video.jacktook 90792]</label>
71 </control>
72
73 <!-- Close Button -->
74 <control type="button" id="14008">
75 <left>1120</left>
76 <top>0</top>
77 <width>80</width>
78 <height>50</height>
79 <label>X</label>
80 <align>center</align>
81 <font>font13</font>
82 <textcolor>99FFFFFF</textcolor>
83 <focusedcolor>EEFFFFFF</focusedcolor>
84 <texturefocus border="15" colordiffuse="FFB22222">circle.png</texturefocus>
85 <texturenofocus border="15" colordiffuse="66000000">circle.png</texturenofocus>
86 <onleft>14001</onleft>
87 <onright>14001</onright>
88 <ondown>14001</ondown>
89 <onup>14001</onup>
90 </control>
91
92 <!-- Empty message -->
93 <control type="label">
94 <top>250</top>
95 <width>1200</width>
96 <height>50</height>
97 <align>center</align>
98 <font>font13</font>
99 <textcolor>EEEEEE</textcolor>
100 <label>$ADDON[plugin.video.jacktook 90793]</label>
101 <visible>String.IsEqual(Window.Property(downloads_empty),true)</visible>
102 </control>
103
104 <!-- Download List -->
105 <control type="list" id="14001">
106 <top>70</top>
107 <left>0</left>
108 <width>1200</width>
109 <height>560</height>
110 <onleft>14003</onleft>
111 <onright>14003</onright>
112 <ondown>14003</ondown>
113 <visible>!String.IsEqual(Window.Property(downloads_empty),true)</visible>
114 <itemlayout height="80">
115 <control type="image">
116 <left>5</left>
117 <top>4</top>
118 <width>1190</width>
119 <height>72</height>
120 <texture border="20">circle.png</texture>
121 <colordiffuse>2AFFFFFF</colordiffuse>
122 </control>
123 <!-- Line 1: Filename -->
124 <control type="label">
125 <left>20</left>
126 <top>6</top>
127 <width>900</width>
128 <height>32</height>
129 <font>font13</font>
130 <textcolor>FFFFFFFF</textcolor>
131 <shadowcolor>AA000000</shadowcolor>
132 <label>$INFO[ListItem.Property(entry_name)]</label>
133 </control>
134 <!-- Line 1 right: Progress % -->
135 <control type="label">
136 <left>940</left>
137 <top>6</top>
138 <width>240</width>
139 <height>32</height>
140 <font>font13</font>
141 <align>right</align>
142 <textcolor>FFAAAAAA</textcolor>
143 <label>$INFO[ListItem.Property(entry_progress)]%</label>
144 </control>
145 <!-- Line 2: Speed • ETA • Downloaded/Size -->
146 <control type="label">
147 <left>20</left>
148 <top>38</top>
149 <width>1160</width>
150 <height>28</height>
151 <font>font10</font>
152 <textcolor>FF999999</textcolor>
153 <label>$INFO[ListItem.Property(entry_info)]</label>
154 </control>
155 </itemlayout>
156 <focusedlayout height="80">
157 <control type="image">
158 <left>5</left>
159 <top>4</top>
160 <width>1190</width>
161 <height>72</height>
162 <texture border="20">circle.png</texture>
163 <colordiffuse>992A3E5C</colordiffuse>
164 </control>
165 <!-- Line 1: Filename -->
166 <control type="label">
167 <left>20</left>
168 <top>6</top>
169 <width>900</width>
170 <height>32</height>
171 <font>font13</font>
172 <textcolor>FFFFFFFF</textcolor>
173 <shadowcolor>AA000000</shadowcolor>
174 <label>$INFO[ListItem.Property(entry_name)]</label>
175 </control>
176 <!-- Line 1 right: Status badge + progress -->
177 <control type="label">
178 <left>940</left>
179 <top>6</top>
180 <width>240</width>
181 <height>32</height>
182 <font>font13</font>
183 <align>right</align>
184 <textcolor>FFFFD700</textcolor>
185 <label>$INFO[ListItem.Property(entry_progress)]%</label>
186 </control>
187 <!-- Line 2: Speed • ETA • Downloaded/Size -->
188 <control type="label">
189 <left>20</left>
190 <top>38</top>
191 <width>1160</width>
192 <height>28</height>
193 <font>font10</font>
194 <textcolor>FFCCCCCC</textcolor>
195 <label>$INFO[ListItem.Property(entry_info)]</label>
196 </control>
197 </focusedlayout>
198 </control>
199
200 <!-- Details Panel -->
201 <control type="group">
202 <top>580</top>
203 <left>0</left>
204 <width>1200</width>
205 <height>120</height>
206 <visible>!String.IsEqual(Window.Property(downloads_empty),true)</visible>
207 <control type="image">
208 <left>0</left>
209 <width>1200</width>
210 <height>120</height>
211 <texture border="20">circle.png</texture>
212 <colordiffuse>66000000</colordiffuse>
213 </control>
214 <control type="label">
215 <left>20</left>
216 <top>10</top>
217 <width>580</width>
218 <height>30</height>
219 <font>font13</font>
220 <textcolor>white</textcolor>
221 <label>$ADDON[plugin.video.jacktook 90794]: $INFO[Window.Property(selected_progress)]%</label>
222 </control>
223 <control type="label">
224 <left>620</left>
225 <top>10</top>
226 <width>580</width>
227 <height>30</height>
228 <font>font13</font>
229 <textcolor>white</textcolor>
230 <label>$ADDON[plugin.video.jacktook 90795]: $INFO[Window.Property(selected_speed)]</label>
231 </control>
232 <control type="label">
233 <left>20</left>
234 <top>50</top>
235 <width>580</width>
236 <height>30</height>
237 <font>font13</font>
238 <textcolor>white</textcolor>
239 <label>$ADDON[plugin.video.jacktook 90796]: $INFO[Window.Property(selected_eta)]</label>
240 </control>
241 <control type="label">
242 <left>620</left>
243 <top>50</top>
244 <width>580</width>
245 <height>30</height>
246 <font>font13</font>
247 <textcolor>white</textcolor>
248 <label>$ADDON[plugin.video.jacktook 90797]: $INFO[Window.Property(selected_size)]</label>
249 </control>
250 </control>
251
252 <!-- Action Buttons -->
253 <control type="group">
254 <top>720</top>
255 <left>0</left>
256 <width>1200</width>
257 <height>60</height>
258 <visible>!String.IsEqual(Window.Property(downloads_empty),true)</visible>
259 <!-- Pause -->
260 <control type="button" id="14003">
261 <left>0</left>
262 <width>230</width>
263 <height>60</height>
264 <label>$ADDON[plugin.video.jacktook 90798]</label>
265 <align>center</align>
266 <font>font13</font>
267 <textcolor>DDFFFFFF</textcolor>
268 <focusedcolor>EEFFFFFF</focusedcolor>
269 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
270 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
271 <onleft>14007</onleft>
272 <onright>14004</onright>
273 <onup>14001</onup>
274 <ondown>14001</ondown>
275 </control>
276 <!-- Resume -->
277 <control type="button" id="14004">
278 <left>240</left>
279 <width>230</width>
280 <height>60</height>
281 <label>$ADDON[plugin.video.jacktook 90799]</label>
282 <align>center</align>
283 <font>font13</font>
284 <textcolor>DDFFFFFF</textcolor>
285 <focusedcolor>EEFFFFFF</focusedcolor>
286 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
287 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
288 <onleft>14003</onleft>
289 <onright>14005</onright>
290 <onup>14001</onup>
291 <ondown>14001</ondown>
292 </control>
293 <!-- Cancel -->
294 <control type="button" id="14005">
295 <left>480</left>
296 <width>230</width>
297 <height>60</height>
298 <label>$ADDON[plugin.video.jacktook 90800]</label>
299 <align>center</align>
300 <font>font13</font>
301 <textcolor>DDFFFFFF</textcolor>
302 <focusedcolor>EEFFFFFF</focusedcolor>
303 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
304 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
305 <onleft>14004</onleft>
306 <onright>14006</onright>
307 <onup>14001</onup>
308 <ondown>14001</ondown>
309 </control>
310 <!-- Delete -->
311 <control type="button" id="14006">
312 <left>720</left>
313 <width>230</width>
314 <height>60</height>
315 <label>$ADDON[plugin.video.jacktook 90801]</label>
316 <align>center</align>
317 <font>font13</font>
318 <textcolor>DDFFFFFF</textcolor>
319 <focusedcolor>EEFFFFFF</focusedcolor>
320 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
321 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
322 <onleft>14005</onleft>
323 <onright>14007</onright>
324 <onup>14001</onup>
325 <ondown>14001</ondown>
326 </control>
327 <!-- Clear Completed -->
328 <control type="button" id="14007">
329 <left>960</left>
330 <width>240</width>
331 <height>60</height>
332 <label>$ADDON[plugin.video.jacktook 90802]</label>
333 <align>center</align>
334 <font>font13</font>
335 <textcolor>DDFFFFFF</textcolor>
336 <focusedcolor>EEFFFFFF</focusedcolor>
337 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
338 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
339 <onleft>14006</onleft>
340 <onright>14003</onright>
341 <onup>14001</onup>
342 <ondown>14001</ondown>
343 </control>
344 </control>
345
346 <!-- Hidden Refresh Button — must be visible for focus, but 1x1px with no texture -->
347 <control type="button" id="14002">
348 <left>0</left>
349 <top>0</top>
350 <width>1</width>
351 <height>1</height>
352 <texturefocus>-</texturefocus>
353 <texturenofocus>-</texturenofocus>
354 <label></label>
355 </control>
356 </control>
357 </controls>
358</window>
plugin.video.jacktook/resources/skins/Default/1080i/playing_next.xml +177 -87
……@@ -1,103 +1,193 @@
11<?xml version="1.0" encoding="UTF-8"?>
22<window>
3 <onload>Dialog.Close(fullscreeninfo,true)</onload>
3 <onload>Dialog.Close(fullscreeninfo,true)</onload>
44 <onload>Dialog.Close(videoosd,true)</onload>
55
6 <defaultcontrol always="true">3001</defaultcontrol>
6 <defaultcontrol always="true">3001</defaultcontrol>
77
8 <controls>
9 <control type="group">
10 <right>20</right>
11 <bottom>150</bottom>
12 <width>520</width>
13 <height>120</height>
8 <controls>
9 <control type="group">
10 <right>40</right>
11 <bottom>70</bottom>
12 <width>800</width>
13 <height>290</height>
1414
1515 <animation type="WindowOpen" reversible="false">
16 <effect type="fade" start="0" end="100" time="300" tween="cubic" easing="in"/>
17 <effect type="slide" start="400%,0" end="0,0" time="400" tween="cubic" easing="in"/>
16 <effect type="fade" start="0" end="100" time="250" tween="cubic" easing="out"/>
17 <effect type="slide" start="120,40" end="0,0" time="360" tween="cubic" easing="out"/>
1818 </animation>
1919
2020 <animation type="WindowClose" reversible="false">
21 <effect type="slide" start="0,0" end="400%,0" time="400" tween="cubic" easing="in"/>
21 <effect type="fade" start="100" end="0" time="180" tween="cubic" easing="in"/>
22 <effect type="slide" start="0,0" end="120,40" time="220" tween="cubic" easing="in"/>
2223 </animation>
2324
24 <!-- Card with Rounded Corners -->
25 <control type="image">
26 <right>-6</right>
27 <bottom>-6</bottom>
28 <texture border="30">circle.png</texture>
29 <colordiffuse>00000000</colordiffuse>
30 </control>
31
32 <!-- Progress Bar -->
33 <control type="progress" id="3014">
34 <reveal>true</reveal>
35 <righttexture></righttexture>
36 <overlaytexture></overlaytexture>
37 <lefttexture></lefttexture>
38 <texturebg border="30"colordiffuse="33FFFFFF">circle.png</texturebg>
39 <midtexture border="30" colordiffuse="FFFFFFFF">circle.png</midtexture>
40 </control>
41
42 <!-- Shadow -->
43 <control type="image">
44 <texture colordiffuse="99000000" border="30">circle.png</texture>
45 </control>
46
47 <!-- Time -->
48 <control type="label">
49 <top>25</top>
50 <font>font12</font>
51 <align>center</align>
52 <textcolor>ffffffff</textcolor>
53 <label>Playing in $INFO[Window.Property(timer)] seconds</label>
54 </control>
25 <!-- Soft shadow -->
26 <control type="image">
27 <left>10</left>
28 <top>10</top>
29 <width>790</width>
30 <height>280</height>
31 <texture border="35" colordiffuse="99000000">circle.png</texture>
32 </control>
5533
56 </control>
34 <!-- Fanart layer, dimmed under the glass card. Empty image is safe. -->
35 <control type="image">
36 <left>0</left>
37 <top>0</top>
38 <width>800</width>
39 <height>290</height>
40 <aspectratio>scale</aspectratio>
41 <texture background="true" colordiffuse="55FFFFFF">$INFO[Window.Property(next.fanart)]</texture>
42 </control>
43
44 <!-- Dark translucent card -->
45 <control type="image">
46 <left>0</left>
47 <top>0</top>
48 <width>800</width>
49 <height>290</height>
50 <texture border="35" colordiffuse="DD05070B">circle.png</texture>
51 </control>
52
53 <!-- Poster fallback plate when no image is provided -->
54 <control type="image">
55 <left>26</left>
56 <top>28</top>
57 <width>142</width>
58 <height>213</height>
59 <texture border="20" colordiffuse="44000000">circle.png</texture>
60 </control>
61
62 <!-- Poster -->
63 <control type="image">
64 <left>26</left>
65 <top>28</top>
66 <width>142</width>
67 <height>213</height>
68 <aspectratio>scale</aspectratio>
69 <texture background="true">$INFO[Window.Property(next.poster)]</texture>
70 </control>
71
72 <!-- Text stack -->
73 <control type="group">
74 <left>195</left>
75 <top>26</top>
76 <width>570</width>
77 <height>190</height>
78
79 <control type="label">
80 <top>0</top>
81 <width>570</width>
82 <height>24</height>
83 <font>font12</font>
84 <textcolor>FFE4B14A</textcolor>
85 <label>NEXT EPISODE</label>
86 </control>
87
88 <control type="image">
89 <top>32</top>
90 <width>270</width>
91 <height>66</height>
92 <aspectratio>keep</aspectratio>
93 <texture background="true">$INFO[Window.Property(next.clearlogo)]</texture>
94 <visible>!String.IsEmpty(Window.Property(next.clearlogo))</visible>
95 </control>
96
97 <control type="label">
98 <top>36</top>
99 <width>570</width>
100 <height>58</height>
101 <font>font16</font>
102 <textcolor>FFFFFFFF</textcolor>
103 <shadowcolor>AA000000</shadowcolor>
104 <label>$INFO[Window.Property(next.title)]</label>
105 <visible>String.IsEmpty(Window.Property(next.clearlogo))</visible>
106 </control>
57107
58 <!-- BUTTONS -->
59 <control type="group">
60 <right>20</right>
61 <bottom>50</bottom>
62 <width>520</width>
63 <height>120</height>
64
65 <control type="grouplist">
66 <orientation>horizontal</orientation>
67 <height>56</height>
68 <itemgap>20</itemgap>
69 <align>center</align>
70
71 <!-- Continue Watching -->
72 <control type="button" id="3002">
73 <label>Continue Watching</label>
74 <width>auto</width>
75 <font>font12</font>
76 <textoffsetx>20</textoffsetx>
77 <textcolor>ddffffff</textcolor>
78 <focusedcolor>eeffffff</focusedcolor>
79 <selectedcolor>ddffffff</selectedcolor>
80 <align>center</align>
81 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
82 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
83 <pulseonselect>no</pulseonselect>
84 </control>
85
86 <!-- Next Episode -->
87 <control type="button" id="3001">
88 <label>Next Episode</label>
89 <width>auto</width>
90 <font>font12</font>
91 <textoffsetx>20</textoffsetx>
92 <textcolor>DDFFFFFF</textcolor>
93 <focusedcolor>EEFFFFFF</focusedcolor>
94 <selectedcolor>DDFFFFFF</selectedcolor>
95 <align>center</align>
96 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
97 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
98 <pulseonselect>no</pulseonselect>
99 </control>
100 </control>
101 </control>
108 <control type="label">
109 <top>106</top>
110 <width>570</width>
111 <height>30</height>
112 <font>font12</font>
113 <textcolor>EEFFFFFF</textcolor>
114 <label>$INFO[Window.Property(next.episode_label)] $INFO[Window.Property(next.episode_name)]</label>
115 </control>
116
117 <control type="textbox">
118 <top>138</top>
119 <width>550</width>
120 <height>52</height>
121 <font>font10</font>
122 <textcolor>BBFFFFFF</textcolor>
123 <label>$INFO[Window.Property(next.plot)]</label>
124 </control>
125 </control>
126
127 <control type="label">
128 <left>195</left>
129 <top>227</top>
130 <width>230</width>
131 <height>34</height>
132 <font>font12</font>
133 <textcolor>FFFFFFFF</textcolor>
134 <label>$INFO[Window.Property(timer_label)]</label>
135 </control>
136
137 <!-- Buttons -->
138 <control type="grouplist">
139 <left>432</left>
140 <top>220</top>
141 <width>333</width>
142 <height>46</height>
143 <orientation>horizontal</orientation>
144 <itemgap>12</itemgap>
145 <align>right</align>
146
147 <control type="button" id="3002">
148 <label>Keep Watching</label>
149 <width>168</width>
150 <height>46</height>
151 <font>font12</font>
152 <textoffsetx>14</textoffsetx>
153 <align>center</align>
154 <textcolor>DDFFFFFF</textcolor>
155 <focusedcolor>FFFFFFFF</focusedcolor>
156 <selectedcolor>DDFFFFFF</selectedcolor>
157 <texturefocus border="24" colordiffuse="88999999">circle.png</texturefocus>
158 <texturenofocus border="24" colordiffuse="66000000">circle.png</texturenofocus>
159 <pulseonselect>no</pulseonselect>
160 </control>
161
162 <control type="button" id="3001">
163 <label>Play Next</label>
164 <width>153</width>
165 <height>46</height>
166 <font>font12</font>
167 <textoffsetx>14</textoffsetx>
168 <align>center</align>
169 <textcolor>FF111111</textcolor>
170 <focusedcolor>FF000000</focusedcolor>
171 <selectedcolor>FF111111</selectedcolor>
172 <texturefocus border="24" colordiffuse="FFFFD36A">circle.png</texturefocus>
173 <texturenofocus border="24" colordiffuse="FFE4B14A">circle.png</texturenofocus>
174 <pulseonselect>no</pulseonselect>
175 </control>
176 </control>
177
178 <!-- Progress strip: keep id 3014 for PlayWindow.background_tasks(). -->
179 <control type="progress" id="3014">
180 <left>22</left>
181 <bottom>12</bottom>
182 <width>756</width>
183 <height>4</height>
184 <reveal>true</reveal>
185 <righttexture></righttexture>
186 <overlaytexture></overlaytexture>
187 <lefttexture></lefttexture>
188 <texturebg border="2" colordiffuse="22FFFFFF">circle.png</texturebg>
189 <midtexture border="2" colordiffuse="FFE4B14A">circle.png</midtexture>
190 </control>
191 </control>
102192 </controls>
103193</window>
plugin.video.jacktook/resources/skins/Default/1080i/search_status.xml +100 -0
……@@ -112,6 +112,16 @@
112112 <textcolor>white</textcolor>
113113 <label>$INFO[Window.Property(task_0_results)]</label>
114114 </control>
115 <control type="image">
116 <left>880</left>
117 <top>8</top>
118 <width>24</width>
119 <height>24</height>
120 <texture>spinner.png</texture>
121 <aspectratio>keep</aspectratio>
122 <animation effect="rotate" start="0" end="360" center="auto" time="2000" loop="true" condition="true">Conditional</animation>
123 <visible>String.IsEqual(Window.Property(task_0_show_spinner),true)</visible>
124 </control>
115125 </control>
116126
117127 <!-- Task 1 -->
……@@ -145,6 +155,16 @@
145155 <textcolor>white</textcolor>
146156 <label>$INFO[Window.Property(task_1_results)]</label>
147157 </control>
158 <control type="image">
159 <left>880</left>
160 <top>8</top>
161 <width>24</width>
162 <height>24</height>
163 <texture>spinner.png</texture>
164 <aspectratio>keep</aspectratio>
165 <animation effect="rotate" start="0" end="360" center="auto" time="2000" loop="true" condition="true">Conditional</animation>
166 <visible>String.IsEqual(Window.Property(task_1_show_spinner),true)</visible>
167 </control>
148168 </control>
149169
150170 <!-- Task 2 -->
……@@ -178,6 +198,16 @@
178198 <textcolor>white</textcolor>
179199 <label>$INFO[Window.Property(task_2_results)]</label>
180200 </control>
201 <control type="image">
202 <left>880</left>
203 <top>8</top>
204 <width>24</width>
205 <height>24</height>
206 <texture>spinner.png</texture>
207 <aspectratio>keep</aspectratio>
208 <animation effect="rotate" start="0" end="360" center="auto" time="2000" loop="true" condition="true">Conditional</animation>
209 <visible>String.IsEqual(Window.Property(task_2_show_spinner),true)</visible>
210 </control>
181211 </control>
182212
183213 <!-- Task 3 -->
……@@ -211,6 +241,16 @@
211241 <textcolor>white</textcolor>
212242 <label>$INFO[Window.Property(task_3_results)]</label>
213243 </control>
244 <control type="image">
245 <left>880</left>
246 <top>8</top>
247 <width>24</width>
248 <height>24</height>
249 <texture>spinner.png</texture>
250 <aspectratio>keep</aspectratio>
251 <animation effect="rotate" start="0" end="360" center="auto" time="2000" loop="true" condition="true">Conditional</animation>
252 <visible>String.IsEqual(Window.Property(task_3_show_spinner),true)</visible>
253 </control>
214254 </control>
215255
216256 <!-- Task 4 -->
……@@ -244,6 +284,16 @@
244284 <textcolor>white</textcolor>
245285 <label>$INFO[Window.Property(task_4_results)]</label>
246286 </control>
287 <control type="image">
288 <left>880</left>
289 <top>8</top>
290 <width>24</width>
291 <height>24</height>
292 <texture>spinner.png</texture>
293 <aspectratio>keep</aspectratio>
294 <animation effect="rotate" start="0" end="360" center="auto" time="2000" loop="true" condition="true">Conditional</animation>
295 <visible>String.IsEqual(Window.Property(task_4_show_spinner),true)</visible>
296 </control>
247297 </control>
248298
249299 <!-- Task 5 -->
……@@ -277,6 +327,16 @@
277327 <textcolor>white</textcolor>
278328 <label>$INFO[Window.Property(task_5_results)]</label>
279329 </control>
330 <control type="image">
331 <left>880</left>
332 <top>8</top>
333 <width>24</width>
334 <height>24</height>
335 <texture>spinner.png</texture>
336 <aspectratio>keep</aspectratio>
337 <animation effect="rotate" start="0" end="360" center="auto" time="2000" loop="true" condition="true">Conditional</animation>
338 <visible>String.IsEqual(Window.Property(task_5_show_spinner),true)</visible>
339 </control>
280340 </control>
281341
282342 <!-- Task 6 -->
……@@ -310,6 +370,16 @@
310370 <textcolor>white</textcolor>
311371 <label>$INFO[Window.Property(task_6_results)]</label>
312372 </control>
373 <control type="image">
374 <left>880</left>
375 <top>8</top>
376 <width>24</width>
377 <height>24</height>
378 <texture>spinner.png</texture>
379 <aspectratio>keep</aspectratio>
380 <animation effect="rotate" start="0" end="360" center="auto" time="2000" loop="true" condition="true">Conditional</animation>
381 <visible>String.IsEqual(Window.Property(task_6_show_spinner),true)</visible>
382 </control>
313383 </control>
314384
315385 <!-- Task 7 -->
……@@ -343,6 +413,16 @@
343413 <textcolor>white</textcolor>
344414 <label>$INFO[Window.Property(task_7_results)]</label>
345415 </control>
416 <control type="image">
417 <left>880</left>
418 <top>8</top>
419 <width>24</width>
420 <height>24</height>
421 <texture>spinner.png</texture>
422 <aspectratio>keep</aspectratio>
423 <animation effect="rotate" start="0" end="360" center="auto" time="2000" loop="true" condition="true">Conditional</animation>
424 <visible>String.IsEqual(Window.Property(task_7_show_spinner),true)</visible>
425 </control>
346426 </control>
347427
348428 <!-- Task 8 -->
……@@ -376,6 +456,16 @@
376456 <textcolor>white</textcolor>
377457 <label>$INFO[Window.Property(task_8_results)]</label>
378458 </control>
459 <control type="image">
460 <left>880</left>
461 <top>8</top>
462 <width>24</width>
463 <height>24</height>
464 <texture>spinner.png</texture>
465 <aspectratio>keep</aspectratio>
466 <animation effect="rotate" start="0" end="360" center="auto" time="2000" loop="true" condition="true">Conditional</animation>
467 <visible>String.IsEqual(Window.Property(task_8_show_spinner),true)</visible>
468 </control>
379469 </control>
380470
381471 <!-- Task 9 -->
……@@ -409,6 +499,16 @@
409499 <textcolor>white</textcolor>
410500 <label>$INFO[Window.Property(task_9_results)]</label>
411501 </control>
502 <control type="image">
503 <left>880</left>
504 <top>8</top>
505 <width>24</width>
506 <height>24</height>
507 <texture>spinner.png</texture>
508 <aspectratio>keep</aspectratio>
509 <animation effect="rotate" start="0" end="360" center="auto" time="2000" loop="true" condition="true">Conditional</animation>
510 <visible>String.IsEqual(Window.Property(task_9_show_spinner),true)</visible>
511 </control>
412512 </control>
413513
414514 <!-- Progress Bar -->
plugin.video.jacktook/scripts/check_py37_compat.py +138 -29
……@@ -4,12 +4,109 @@ import sys
44
55# Disallowed base names used as generics in Python < 3.9
66PEP585_TYPES = {"list", "dict", "set", "tuple", "frozenset", "type", "callable"}
7PEP585_REPLACEMENTS = {
8 "list": "List",
9 "dict": "Dict",
10 "set": "Set",
11 "tuple": "Tuple",
12 "frozenset": "FrozenSet",
13 "type": "Type",
14 "callable": "Callable",
15}
716
817# PEP 593, 604, 647, 612, 673 keywords
918UNSUPPORTED_NAMES = {
1019 "Annotated", "Self", "TypeGuard", "ParamSpec", "Concatenate", "Required", "NotRequired"
1120}
1221
22EXCLUDED_DIRS = {
23 ".git",
24 ".venv",
25 ".agent",
26 ".agents",
27 ".opencode",
28 ".worktrees",
29 ".mypy_cache",
30 ".pytest_cache",
31 "__pycache__",
32}
33
34EXCLUDED_PATH_PARTS = {
35 ("lib", "vendor"),
36 ("lib", "api", "tmdbv3api"),
37}
38
39
40def is_excluded_path(path):
41 normalized = os.path.normpath(path)
42 path_parts = normalized.split(os.sep)
43 parts = set(path_parts)
44 if parts & EXCLUDED_DIRS:
45 return True
46 return any(has_path_parts(path_parts, excluded) for excluded in EXCLUDED_PATH_PARTS)
47
48
49def has_path_parts(path_parts, excluded_parts):
50 excluded_len = len(excluded_parts)
51 return any(
52 tuple(path_parts[index : index + excluded_len]) == excluded_parts
53 for index in range(len(path_parts) - excluded_len + 1)
54 )
55
56
57def iter_annotation_nodes(annotation):
58 if annotation is None:
59 return
60 yield annotation
61 for child in ast.iter_child_nodes(annotation):
62 yield from iter_annotation_nodes(child)
63
64
65def is_pep585_annotation(annotation):
66 return (
67 isinstance(annotation, ast.Subscript)
68 and isinstance(annotation.value, ast.Name)
69 and annotation.value.id in PEP585_TYPES
70 )
71
72
73def unsupported_typing_name(node):
74 if isinstance(node, ast.Name) and node.id in UNSUPPORTED_NAMES:
75 return node.id
76 if isinstance(node, ast.Attribute) and node.attr in UNSUPPORTED_NAMES:
77 return node.attr
78 return None
79
80
81def pep585_message(base_name):
82 replacement = PEP585_REPLACEMENTS.get(base_name, base_name.capitalize())
83 return f"Use typing.{replacement} instead of built-in {base_name}[...]"
84
85
86def check_annotation(annotation, issues):
87 for node in iter_annotation_nodes(annotation):
88 if is_pep585_annotation(node):
89 base_name = node.value.id
90 issues.append(("PEP 585 generic", node.lineno, pep585_message(base_name)))
91 elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
92 issues.append(("Union | syntax", node.lineno, "Use Union[...] instead of | in annotations"))
93 unsupported_name = unsupported_typing_name(node)
94 if unsupported_name is not None:
95 issues.append(
96 (
97 f"Unsupported typing: {unsupported_name}",
98 node.lineno,
99 "Requires typing_extensions or a newer Python runtime",
100 )
101 )
102
103
104def check_value_expression(node, issues):
105 for child in iter_annotation_nodes(node):
106 if is_pep585_annotation(child):
107 base_name = child.value.id
108 issues.append(("PEP 585 generic", child.lineno, pep585_message(base_name)))
109
13110def find_incompatible_syntax(file_path):
14111 with open(file_path, "r", encoding="utf-8") as f:
15112 try:
……@@ -20,61 +117,72 @@ def find_incompatible_syntax(file_path):
20117 issues = []
21118
22119 class Visitor(ast.NodeVisitor):
23 def visit_BinOp(self, node):
24 # Check for union types (e.g., int | None)
25 if isinstance(node.op, ast.BitOr):
26 issues.append(("Union | syntax", node.lineno, "Use Union[...] instead of |"))
120 def visit_Assign(self, node):
121 check_value_expression(node.value, issues)
27122 self.generic_visit(node)
28123
29124 def visit_AnnAssign(self, node):
30 if isinstance(node.annotation, ast.Subscript):
31 base = node.annotation.value
32 if isinstance(base, ast.Name) and base.id.lower() in PEP585_TYPES:
33 issues.append(("PEP 585 generic", node.lineno, f"Use typing.{base.id.capitalize()} instead"))
125 check_annotation(node.annotation, issues)
126 if node.value is not None:
127 check_value_expression(node.value, issues)
34128 self.generic_visit(node)
35129
36130 def visit_FunctionDef(self, node):
37 for arg in node.args.args + node.args.kwonlyargs:
38 if isinstance(arg.annotation, ast.Subscript):
39 base = arg.annotation.value
40 if isinstance(base, ast.Name) and base.id.lower() in PEP585_TYPES:
41 issues.append(("PEP 585 generic", node.lineno, f"Use typing.{base.id.capitalize()} instead"))
42 if isinstance(node.returns, ast.Subscript):
43 base = node.returns.value
44 if isinstance(base, ast.Name) and base.id.lower() in PEP585_TYPES:
45 issues.append(("PEP 585 generic", node.lineno, f"Use typing.{base.id.capitalize()} instead"))
131 check_function_annotations(node, issues)
46132 self.generic_visit(node)
47133
48 def visit_Match(self, node):
49 issues.append(("match-case", node.lineno, "Structural pattern matching not supported in Python 3.7"))
134 def visit_AsyncFunctionDef(self, node):
135 check_function_annotations(node, issues)
50136 self.generic_visit(node)
51137
52 def visit_Name(self, node):
53 if node.id in UNSUPPORTED_NAMES:
54 issues.append((f"Unsupported typing: {node.id}", node.lineno, f"Requires typing_extensions or Python >= 3.8/3.10"))
138 def visit_Match(self, node):
139 issues.append(("match-case", node.lineno, "Structural pattern matching not supported in Python 3.7"))
55140 self.generic_visit(node)
56141
57142 Visitor().visit(tree)
58143 return issues
59144
145
146def check_function_annotations(node, issues):
147 args = node.args
148 all_args = args.posonlyargs + args.args + args.kwonlyargs
149 if args.vararg is not None:
150 all_args.append(args.vararg)
151 if args.kwarg is not None:
152 all_args.append(args.kwarg)
153 for arg in all_args:
154 check_annotation(arg.annotation, issues)
155 check_annotation(node.returns, issues)
156
60157def scan_project(root_path):
61158 results = []
62 for dirpath, _, filenames in os.walk(root_path):
159 if os.path.isfile(root_path):
160 if root_path.endswith(".py") and not is_excluded_path(root_path):
161 issues = find_incompatible_syntax(root_path)
162 for kind, lineno, message in issues:
163 results.append((root_path, lineno, kind, message))
164 return results
165
166 for dirpath, dirnames, filenames in os.walk(root_path):
167 if is_excluded_path(dirpath):
168 continue
169 dirnames[:] = [dirname for dirname in dirnames if not is_excluded_path(os.path.join(dirpath, dirname))]
170 filenames = [file for file in filenames if not file.endswith(".pyc")]
63171 for file in filenames:
64172 if file.endswith(".py"):
65173 filepath = os.path.join(dirpath, file)
174 if is_excluded_path(filepath):
175 continue
66176 issues = find_incompatible_syntax(filepath)
67177 for kind, lineno, message in issues:
68178 results.append((filepath, lineno, kind, message))
69179 return results
70180
71181if __name__ == "__main__":
72 if len(sys.argv) < 2:
73 print("Usage: python check_py37_compat.py /path/to/project")
74 sys.exit(1)
75
76 root = sys.argv[1]
77 findings = scan_project(root)
182 roots = sys.argv[1:] if len(sys.argv) > 1 else ["."]
183 findings = []
184 for root in roots:
185 findings.extend(scan_project(root))
78186
79187 if not findings:
80188 print("✅ No Python 3.7 incompatibilities found.")
……@@ -82,3 +190,4 @@ if __name__ == "__main__":
82190 print("❌ Found potential Python 3.7 incompatibilities:\n")
83191 for path, line, kind, msg in findings:
84192 print(f"{path}:{line} [{kind}] {msg}")
193 sys.exit(1)
plugin.video.jacktook/service.py +0 -1
……@@ -1,4 +1,3 @@
11from lib.services.background_service import JacktookMOnitor
22
3
43JacktookMOnitor().waitForAbort()
plugin.video.jacktook/tests/conftest.py +1 -1
……@@ -1,5 +1,5 @@
1import sys
21import os
2import sys
33from unittest.mock import MagicMock
44
55# --- Create stub classes for xbmcgui to avoid type hint issues in Python 3.13 ---
plugin.video.jacktook/tests/unit/gui/test_source_manager_dialog.py +351 -0
……@@ -0,0 +1,351 @@
1import json
2from unittest.mock import MagicMock, patch
3
4
5class FakeAddon:
6 def __init__(self, key, name):
7 self._key = key
8 self.manifest = MagicMock()
9 self.manifest.name = name
10
11 def key(self):
12 return self._key
13
14 def url(self):
15 return f"http://example.com/{self._key}"
16
17
18class TestOpenSourceManagerDialog:
19 @patch("lib.gui.source_manager_dialog.get_setting")
20 @patch("lib.gui.source_manager_dialog.xbmcgui.Dialog")
21 @patch("lib.gui.source_manager_dialog.xbmcgui.ListItem")
22 @patch("lib.gui.source_manager_dialog.cache")
23 def test_no_sources_enabled_shows_notification(
24 self, mock_cache, mock_listitem, mock_dialog_cls, mock_get_setting
25 ):
26 mock_get_setting.return_value = False
27 mock_dialog = MagicMock()
28 mock_dialog_cls.return_value = mock_dialog
29
30 from lib.gui.source_manager_dialog import open_source_manager_dialog
31
32 open_source_manager_dialog()
33
34 mock_dialog.notification.assert_called_once()
35 mock_dialog.multiselect.assert_not_called()
36
37 @patch("lib.gui.source_manager_dialog.get_setting")
38 @patch("lib.gui.source_manager_dialog.xbmcgui.Dialog")
39 @patch("lib.gui.source_manager_dialog.xbmcgui.ListItem")
40 @patch("lib.gui.source_manager_dialog.cache")
41 def test_enabled_builtins_are_shown(
42 self, mock_cache, mock_listitem, mock_dialog_cls, mock_get_setting
43 ):
44 def setting_side_effect(key):
45 return key in ("jackett_enabled", "prowlarr_enabled")
46
47 mock_get_setting.side_effect = setting_side_effect
48 mock_dialog = MagicMock()
49 mock_dialog.multiselect.return_value = None
50 mock_dialog_cls.return_value = mock_dialog
51
52 from lib.gui.source_manager_dialog import open_source_manager_dialog
53
54 open_source_manager_dialog()
55
56 calls = mock_listitem.call_args_list
57 labels = [call.kwargs.get("label", call.args[0] if call.args else "") for call in calls]
58 assert "Jackett" in labels
59 assert "Prowlarr" in labels
60 assert "Burst" not in labels
61 assert "Jackgram" not in labels
62
63 @patch("lib.gui.source_manager_dialog.get_setting")
64 @patch("lib.gui.source_manager_dialog.xbmcgui.Dialog")
65 @patch("lib.gui.source_manager_dialog.xbmcgui.ListItem")
66 @patch("lib.gui.source_manager_dialog.cache")
67 @patch(
68 "lib.clients.stremio.helpers.get_selected_stream_addons",
69 return_value=[
70 FakeAddon("addon1|url1", "Addon One"),
71 FakeAddon("addon2|url2", "Addon Two"),
72 ],
73 )
74 def test_stremio_addons_included_when_enabled(
75 self,
76 mock_get_addons,
77 mock_cache,
78 mock_listitem,
79 mock_dialog_cls,
80 mock_get_setting,
81 ):
82 def setting_side_effect(key):
83 return key == "stremio_enabled"
84
85 mock_get_setting.side_effect = setting_side_effect
86 mock_dialog = MagicMock()
87 mock_dialog.multiselect.return_value = None
88 mock_dialog_cls.return_value = mock_dialog
89
90 from lib.gui.source_manager_dialog import open_source_manager_dialog
91
92 open_source_manager_dialog()
93
94 calls = mock_listitem.call_args_list
95 labels = [call.kwargs.get("label", call.args[0] if call.args else "") for call in calls]
96 assert "Stremio" not in labels
97 assert "Addon One" in labels
98 assert "Addon Two" in labels
99
100 @patch("lib.gui.source_manager_dialog.get_setting")
101 @patch("lib.gui.source_manager_dialog.xbmcgui.Dialog")
102 @patch("lib.gui.source_manager_dialog.xbmcgui.ListItem")
103 @patch("lib.gui.source_manager_dialog.cache")
104 def test_preselect_uses_cache_selection(
105 self, mock_cache, mock_listitem, mock_dialog_cls, mock_get_setting
106 ):
107 def setting_side_effect(key):
108 return key in ("jackett_enabled", "jacktookburst_enabled")
109
110 mock_get_setting.side_effect = setting_side_effect
111 mock_cache.get.return_value = json.dumps(["Jackett", "Burst"])
112 mock_dialog = MagicMock()
113 mock_dialog.multiselect.return_value = None
114 mock_dialog_cls.return_value = mock_dialog
115
116 from lib.gui.source_manager_dialog import open_source_manager_dialog
117
118 open_source_manager_dialog()
119
120 preselect = mock_dialog.multiselect.call_args[1]["preselect"]
121 # Jackett is first item (index 0), Burst is second item (index 1)
122 assert preselect == [0, 1]
123
124 @patch("lib.gui.source_manager_dialog.get_setting")
125 @patch("lib.gui.source_manager_dialog.xbmcgui.Dialog")
126 @patch("lib.gui.source_manager_dialog.xbmcgui.ListItem")
127 @patch("lib.gui.source_manager_dialog.cache")
128 def test_selection_saved_to_cache(
129 self, mock_cache, mock_listitem, mock_dialog_cls, mock_get_setting
130 ):
131 def setting_side_effect(key):
132 if key == "external_scraper_module_name":
133 return None
134 return True
135
136 mock_get_setting.side_effect = setting_side_effect
137 mock_cache.get.return_value = None
138 mock_dialog = MagicMock()
139 # User selects first and third items
140 mock_dialog.multiselect.return_value = [0, 2]
141 mock_dialog_cls.return_value = mock_dialog
142
143 from lib.gui.source_manager_dialog import open_source_manager_dialog
144
145 open_source_manager_dialog()
146
147 # cache.set is called twice to initialize defaults and twice to
148 # persist the user's final selection (selection + known keys).
149 assert mock_cache.set.call_count == 4
150 saved_calls = {
151 call.args[0]: json.loads(call.args[1]) for call in mock_cache.set.call_args_list[-2:]
152 }
153 assert saved_calls["source_manager_selection"] == ["Jackett", "Burst"]
154 assert saved_calls["source_manager_known_keys"] == [
155 "Jackett",
156 "Prowlarr",
157 "Burst",
158 "Jackgram",
159 "Easynews",
160 "External Scraper",
161 ]
162
163 @patch("lib.gui.source_manager_dialog.get_setting")
164 @patch("lib.gui.source_manager_dialog.xbmcgui.Dialog")
165 @patch("lib.gui.source_manager_dialog.xbmcgui.ListItem")
166 @patch("lib.gui.source_manager_dialog.cache")
167 def test_cancel_does_not_modify_cache(
168 self, mock_cache, mock_listitem, mock_dialog_cls, mock_get_setting
169 ):
170 # Seed an existing selection so the default-init path is skipped
171 def setting_side_effect(key):
172 return key in ("jackett_enabled", "prowlarr_enabled")
173
174 mock_get_setting.side_effect = setting_side_effect
175 mock_cache.get.return_value = json.dumps(["Jackett", "Prowlarr"])
176 mock_dialog = MagicMock()
177 mock_dialog.multiselect.return_value = None
178 mock_dialog_cls.return_value = mock_dialog
179
180 from lib.gui.source_manager_dialog import open_source_manager_dialog
181
182 open_source_manager_dialog()
183
184 mock_cache.set.assert_not_called()
185
186 @patch("lib.gui.source_manager_dialog.get_setting")
187 @patch("lib.gui.source_manager_dialog.xbmcgui.Dialog")
188 @patch("lib.gui.source_manager_dialog.xbmcgui.ListItem")
189 @patch("lib.gui.source_manager_dialog.cache")
190 def test_empty_selection_does_not_modify_cache(
191 self, mock_cache, mock_listitem, mock_dialog_cls, mock_get_setting
192 ):
193 # Seed an existing selection so the default-init path is skipped
194 def setting_side_effect(key):
195 return key in ("jackett_enabled", "prowlarr_enabled")
196
197 mock_get_setting.side_effect = setting_side_effect
198 mock_cache.get.return_value = json.dumps(["Jackett", "Prowlarr"])
199 mock_dialog = MagicMock()
200 mock_dialog.multiselect.return_value = []
201 mock_dialog_cls.return_value = mock_dialog
202
203 from lib.gui.source_manager_dialog import open_source_manager_dialog
204
205 open_source_manager_dialog()
206
207 mock_cache.set.assert_not_called()
208
209 @patch("lib.gui.source_manager_dialog.get_setting")
210 @patch("lib.gui.source_manager_dialog.xbmcgui.Dialog")
211 @patch("lib.gui.source_manager_dialog.xbmcgui.ListItem")
212 @patch("lib.gui.source_manager_dialog.cache")
213 @patch(
214 "lib.clients.stremio.helpers.get_selected_stream_addons",
215 return_value=[FakeAddon("addon1|url1", "Addon One")],
216 )
217 def test_stremio_addon_cache_key_format(
218 self,
219 mock_get_addons,
220 mock_cache,
221 mock_listitem,
222 mock_dialog_cls,
223 mock_get_setting,
224 ):
225 def setting_side_effect(key):
226 return key == "stremio_enabled"
227
228 mock_get_setting.side_effect = setting_side_effect
229 mock_cache.get.return_value = None
230 mock_dialog = MagicMock()
231 # Select first addon (index 0)
232 mock_dialog.multiselect.return_value = [0]
233 mock_dialog_cls.return_value = mock_dialog
234
235 from lib.gui.source_manager_dialog import open_source_manager_dialog
236
237 open_source_manager_dialog()
238
239 saved = json.loads(mock_cache.set.call_args[0][1])
240 assert "Stremio" not in saved
241 assert "Stremio:addon1|url1" in saved
242
243 @patch("lib.gui.source_manager_dialog.get_setting")
244 @patch("lib.gui.source_manager_dialog.xbmcgui.Dialog")
245 @patch("lib.gui.source_manager_dialog.xbmcgui.ListItem")
246 @patch("lib.gui.source_manager_dialog.cache")
247 def test_empty_cache_preselects_all_enabled_sources(
248 self, mock_cache, mock_listitem, mock_dialog_cls, mock_get_setting
249 ):
250 """When no prior selection exists, all enabled sources should be preselected."""
251
252 def setting_side_effect(key):
253 return key in ("jackett_enabled", "prowlarr_enabled")
254
255 mock_get_setting.side_effect = setting_side_effect
256 mock_cache.get.return_value = None
257 mock_dialog = MagicMock()
258 mock_dialog.multiselect.return_value = None
259 mock_dialog_cls.return_value = mock_dialog
260
261 from lib.gui.source_manager_dialog import open_source_manager_dialog
262
263 open_source_manager_dialog()
264
265 preselect = mock_dialog.multiselect.call_args[1]["preselect"]
266 # Both Jackett (index 0) and Prowlarr (index 1) should be preselected
267 assert preselect == [0, 1]
268 # Default selection and known keys should be saved to cache
269 assert mock_cache.set.call_count == 2
270 saved_keys = {call.args[0] for call in mock_cache.set.call_args_list}
271 assert "source_manager_selection" in saved_keys
272 assert "source_manager_known_keys" in saved_keys
273
274 @patch("lib.gui.source_manager_dialog.get_setting")
275 @patch("lib.gui.source_manager_dialog.xbmcgui.Dialog")
276 @patch("lib.gui.source_manager_dialog.xbmcgui.ListItem")
277 @patch("lib.gui.source_manager_dialog.cache")
278 def test_newly_enabled_source_is_auto_selected(
279 self, mock_cache, mock_listitem, mock_dialog_cls, mock_get_setting
280 ):
281 """A source enabled in settings but missing from known keys should be auto-selected."""
282
283 def setting_side_effect(key):
284 return key in ("jackett_enabled", "prowlarr_enabled")
285
286 mock_get_setting.side_effect = setting_side_effect
287
288 def cache_get_side_effect(key):
289 if key == "source_manager_selection":
290 return json.dumps(["Jackett"])
291 if key == "source_manager_known_keys":
292 return json.dumps(["Jackett"])
293 return None
294
295 mock_cache.get.side_effect = cache_get_side_effect
296 mock_dialog = MagicMock()
297 mock_dialog.multiselect.return_value = None
298 mock_dialog_cls.return_value = mock_dialog
299
300 from lib.gui.source_manager_dialog import open_source_manager_dialog
301
302 open_source_manager_dialog()
303
304 preselect = mock_dialog.multiselect.call_args[1]["preselect"]
305 # Jackett (index 0) and Prowlarr (index 1) should both be preselected
306 assert preselect == [0, 1]
307 # Updated selection and known keys should be saved to cache
308 assert mock_cache.set.call_count == 2
309 saved_calls = {
310 call.args[0]: json.loads(call.args[1]) for call in mock_cache.set.call_args_list
311 }
312 assert "Jackett" in saved_calls["source_manager_selection"]
313 assert "Prowlarr" in saved_calls["source_manager_selection"]
314
315 @patch("lib.gui.source_manager_dialog.get_setting")
316 @patch("lib.gui.source_manager_dialog.xbmcgui.Dialog")
317 @patch("lib.gui.source_manager_dialog.xbmcgui.ListItem")
318 @patch("lib.gui.source_manager_dialog.cache")
319 def test_deselected_source_stays_deselected(
320 self, mock_cache, mock_listitem, mock_dialog_cls, mock_get_setting
321 ):
322 """A source deliberately deselected by the user must not be re-selected on reopen."""
323
324 def setting_side_effect(key):
325 return key in ("jackett_enabled", "prowlarr_enabled")
326
327 mock_get_setting.side_effect = setting_side_effect
328
329 def cache_get_side_effect(key):
330 if key == "source_manager_selection":
331 # User previously deselected Prowlarr
332 return json.dumps(["Jackett"])
333 if key == "source_manager_known_keys":
334 # Prowlarr was already known at the time of the last save
335 return json.dumps(["Jackett", "Prowlarr"])
336 return None
337
338 mock_cache.get.side_effect = cache_get_side_effect
339 mock_dialog = MagicMock()
340 mock_dialog.multiselect.return_value = None
341 mock_dialog_cls.return_value = mock_dialog
342
343 from lib.gui.source_manager_dialog import open_source_manager_dialog
344
345 open_source_manager_dialog()
346
347 preselect = mock_dialog.multiselect.call_args[1]["preselect"]
348 # Only Jackett (index 0) should be preselected
349 assert preselect == [0]
350 # Cache must not be modified because nothing newly enabled appeared
351 mock_cache.set.assert_not_called()
plugin.video.jacktook/tests/unit/test_autoscrape.py +455 -0
……@@ -0,0 +1,455 @@
1import json
2import re
3from datetime import timedelta
4from pathlib import Path
5from unittest.mock import MagicMock, patch
6
7
8def test_get_autoscrape_cache_key_format():
9 from lib.utils.player.utils import get_autoscrape_cache_key
10
11 assert get_autoscrape_cache_key("tt123", 1, 2) == "as:tt123_1_2"
12 assert get_autoscrape_cache_key(456, 3, 4) == "as:456_3_4"
13
14
15def test_cache_autoscrape_result_calls_hybrid_cache():
16 from lib.utils.player.utils import cache_autoscrape_result
17
18 with patch("lib.utils.player.utils.cache") as mock_cache:
19 data = {"url": "http://example.com", "title": "Test"}
20 cache_autoscrape_result("as:key_1_2", data, 4)
21 mock_cache.set.assert_called_once()
22 call_args = mock_cache.set.call_args
23 assert call_args[0][0] == "as:key_1_2"
24 assert call_args[0][1] == data
25 assert call_args[1]["expires"] == timedelta(hours=4)
26
27
28def test_cache_autoscrape_result_uses_default_ttl():
29 from lib.utils.player.utils import cache_autoscrape_result
30
31 with patch("lib.utils.player.utils.cache") as mock_cache, patch(
32 "lib.utils.player.utils.get_setting", return_value=6
33 ):
34 data = {"url": "http://example.com"}
35 cache_autoscrape_result("as:key_1_2", data)
36 call_args = mock_cache.set.call_args
37 assert call_args[1]["expires"] == timedelta(hours=6)
38
39
40def test_autoscrape_next_episode_resolves_and_caches():
41 from lib.utils.player.utils import autoscrape_next_episode
42
43 fake_result = MagicMock()
44 fake_result.title = "Test Episode"
45 fake_result.indexer = "Jackett"
46 fake_result.type = "tv"
47 fake_result.debridType = "RD"
48 fake_result.infoHash = "abc123"
49 fake_result.url = "magnet:?xt=urn:btih:abc123"
50 fake_result.quality = "1080p"
51
52 resolved_data = {"url": "http://resolved", "title": "Test Episode"}
53
54 with patch("lib.search.search_client", return_value=[fake_result]) as mock_search, patch(
55 "lib.utils.player.utils.resolve_playback_url", return_value=resolved_data
56 ) as mock_resolve, patch("lib.utils.player.utils.cache") as mock_cache, patch(
57 "lib.utils.player.utils.get_setting",
58 side_effect=lambda key, default=None: {
59 "autoscrape_next_episode": True,
60 "auto_play": True,
61 "auto_play_quality": "1080p",
62 }.get(key, default),
63 ):
64 item_data = {
65 "mode": "tv",
66 "ids": {"tmdb_id": "123", "imdb_id": "tt123"},
67 "tv_data": {"season": 1, "episode": 1},
68 "title": "Show Name",
69 }
70 next_tv_data = {"season": 1, "episode": 2, "name": "Next Ep"}
71
72 autoscrape_next_episode(item_data, next_tv_data)
73
74 mock_search.assert_called_once()
75 mock_resolve.assert_called_once()
76 assert mock_cache.set.call_count == 2, (
77 f"Expected 2 cache.set calls, got {mock_cache.set.call_count}"
78 )
79 # First call: raw results cache
80 raw_call_args = mock_cache.set.call_args_list[0]
81 assert raw_call_args[0][0] == "as_results:tt123_1_2"
82 assert raw_call_args[0][1] == [fake_result]
83 # Second call: resolved playback data cache
84 resolve_call_args = mock_cache.set.call_args_list[1]
85 assert resolve_call_args[0][0] == "as:tt123_1_2"
86 assert resolve_call_args[0][1] == resolved_data
87
88
89def test_autoscrape_next_episode_no_results_does_not_cache():
90 from lib.utils.player.utils import autoscrape_next_episode
91
92 with patch("lib.search.search_client", return_value=[]) as mock_search, patch(
93 "lib.utils.player.utils.cache"
94 ) as mock_cache, patch(
95 "lib.utils.player.utils.get_setting",
96 side_effect=lambda key, default=None: {
97 "autoscrape_next_episode": True,
98 "auto_play": True,
99 "auto_play_quality": "1080p",
100 }.get(key, default),
101 ):
102 item_data = {
103 "mode": "tv",
104 "ids": {"tmdb_id": "123"},
105 "tv_data": {"season": 1, "episode": 1},
106 "title": "Show Name",
107 }
108 next_tv_data = {"season": 1, "episode": 2, "name": "Next Ep"}
109
110 autoscrape_next_episode(item_data, next_tv_data)
111
112 mock_search.assert_called_once()
113 mock_cache.set.assert_not_called()
114
115
116def test_autoscrape_next_episode_disabled_exits_early():
117 from lib.utils.player.utils import autoscrape_next_episode
118
119 with patch("lib.search.search_client") as mock_search, patch(
120 "lib.utils.player.utils.get_setting", return_value=False
121 ):
122 item_data = {
123 "mode": "tv",
124 "ids": {"tmdb_id": "123"},
125 "tv_data": {"season": 1, "episode": 1},
126 }
127 next_tv_data = {"season": 1, "episode": 2, "name": "Next Ep"}
128
129 autoscrape_next_episode(item_data, next_tv_data)
130
131 mock_search.assert_not_called()
132
133
134def test_play_autoscraped_cache_hit_plays_directly():
135 from lib.navigation import play_autoscraped
136
137 cached_data = {"url": "http://cached", "title": "Cached Episode"}
138
139 with patch("lib.navigation.cache") as mock_cache, patch(
140 "lib.navigation.JacktookPLayer"
141 ) as mock_player_cls, patch("lib.search.run_search_entry") as mock_search_entry, patch(
142 "lib.utils.kodi.settings.auto_play_enabled", return_value=True
143 ):
144 mock_cache.get.return_value = cached_data
145
146 params = {
147 "mode": "tv",
148 "query": "Show",
149 "ids": json.dumps({"tmdb_id": "123"}),
150 "tv_data": json.dumps({"season": 1, "episode": 2}),
151 }
152
153 play_autoscraped(params)
154
155 mock_player_cls.return_value.run.assert_called_once_with(data=cached_data)
156 mock_search_entry.assert_not_called()
157
158
159def test_play_autoscraped_cache_hit_playnext_context_plays_directly():
160 from lib.navigation import play_autoscraped
161
162 cached_data = {"url": "http://cached", "title": "Cached Episode"}
163
164 with patch("lib.navigation.cache") as mock_cache, patch(
165 "lib.navigation.JacktookPLayer"
166 ) as mock_player_cls, patch("lib.search.run_search_entry") as mock_search_entry, patch(
167 "lib.utils.kodi.settings.auto_play_enabled", return_value=True
168 ):
169 mock_cache.get.return_value = cached_data
170
171 params = {
172 "mode": "tv",
173 "query": "Show",
174 "ids": json.dumps({"tmdb_id": "123"}),
175 "tv_data": json.dumps({"season": 1, "episode": 2}),
176 "autoplay_context": "1",
177 }
178
179 play_autoscraped(params)
180
181 mock_player_cls.return_value.run.assert_called_once_with(
182 data={
183 "url": "http://cached",
184 "title": "Cached Episode",
185 "autoplay": True,
186 "playnext_context": True,
187 "direct_play": True,
188 }
189 )
190 mock_search_entry.assert_not_called()
191
192
193def test_play_autoscraped_autoplay_disabled_cached_results_shows_source_select():
194 """Task 3.2: Autoplay disabled with cached results shows source select."""
195 from lib.navigation import play_autoscraped
196
197 cached_data = {"url": "http://cached", "title": "Cached Episode"}
198 cached_results = [MagicMock()]
199
200 with patch("lib.navigation.cache") as mock_cache, patch(
201 "lib.navigation.JacktookPLayer"
202 ) as mock_player_cls, patch("lib.search.run_search_entry") as mock_search_entry, patch(
203 "lib.search.show_source_select"
204 ) as mock_show_source_select, patch(
205 "lib.utils.kodi.settings.auto_play_enabled", return_value=False
206 ):
207 # First cache.get returns cached_data (autoscrape hit)
208 # Second cache.get returns cached_results (results hit)
209 mock_cache.get.side_effect = [cached_data, cached_results]
210
211 params = {
212 "mode": "tv",
213 "query": "Show",
214 "ids": json.dumps({"tmdb_id": "123"}),
215 "tv_data": json.dumps({"season": 1, "episode": 2}),
216 }
217
218 play_autoscraped(params)
219
220 mock_player_cls.return_value.run.assert_not_called()
221 mock_search_entry.assert_not_called()
222 mock_show_source_select.assert_called_once()
223 assert mock_show_source_select.call_args.kwargs["autoplay_context"] is None
224
225
226def test_play_autoscraped_cached_source_select_preserves_playnext_context():
227 from lib.navigation import play_autoscraped
228
229 cached_data = {"url": "http://cached", "title": "Cached Episode"}
230 cached_results = [MagicMock()]
231
232 with patch("lib.navigation.cache") as mock_cache, patch(
233 "lib.navigation.JacktookPLayer"
234 ) as mock_player_cls, patch("lib.search.run_search_entry") as mock_search_entry, patch(
235 "lib.search.show_source_select"
236 ) as mock_show_source_select, patch(
237 "lib.utils.kodi.settings.auto_play_enabled", return_value=False
238 ):
239 mock_cache.get.side_effect = [cached_data, cached_results]
240
241 params = {
242 "mode": "tv",
243 "query": "Show",
244 "ids": json.dumps({"tmdb_id": "123"}),
245 "tv_data": json.dumps({"season": 1, "episode": 2}),
246 "autoplay_context": "1",
247 }
248
249 play_autoscraped(params)
250
251 mock_player_cls.return_value.run.assert_not_called()
252 mock_search_entry.assert_not_called()
253 mock_show_source_select.assert_called_once()
254 assert mock_show_source_select.call_args.kwargs["autoplay_context"] == "1"
255
256
257def test_play_autoscraped_autoplay_disabled_no_cached_results_falls_back():
258 """Task 3.2: Autoplay disabled with no cached results falls back to search."""
259 from lib.navigation import play_autoscraped
260
261 cached_data = {"url": "http://cached", "title": "Cached Episode"}
262
263 with patch("lib.navigation.cache") as mock_cache, patch(
264 "lib.navigation.JacktookPLayer"
265 ) as mock_player_cls, patch("lib.search.run_search_entry") as mock_search_entry, patch(
266 "lib.search.show_source_select"
267 ) as mock_show_source_select, patch(
268 "lib.utils.kodi.settings.auto_play_enabled", return_value=False
269 ):
270 # First cache.get returns cached_data (autoscrape hit)
271 # Second cache.get returns None (no cached results)
272 mock_cache.get.side_effect = [cached_data, None]
273
274 params = {
275 "mode": "tv",
276 "query": "Show",
277 "ids": json.dumps({"tmdb_id": "123"}),
278 "tv_data": json.dumps({"season": 1, "episode": 2}),
279 }
280
281 play_autoscraped(params)
282
283 mock_player_cls.return_value.run.assert_not_called()
284 mock_show_source_select.assert_not_called()
285 mock_search_entry.assert_called_once_with(params)
286
287
288def test_play_autoscraped_cache_miss_falls_back():
289 from lib.navigation import play_autoscraped
290
291 with patch("lib.navigation.cache") as mock_cache, patch(
292 "lib.navigation.JacktookPLayer"
293 ) as mock_player_cls, patch("lib.search.run_search_entry") as mock_search_entry:
294 mock_cache.get.return_value = None
295
296 params = {
297 "mode": "tv",
298 "query": "Show",
299 "ids": json.dumps({"tmdb_id": "123"}),
300 "tv_data": json.dumps({"season": 1, "episode": 2}),
301 }
302
303 play_autoscraped(params)
304
305 mock_player_cls.return_value.run.assert_not_called()
306 mock_search_entry.assert_called_once_with(params)
307
308
309PLAYER_PATH = Path(__file__).resolve().parents[2] / "lib" / "player.py"
310
311
312def test_build_playlist_emits_play_autoscraped_when_enabled():
313 source = PLAYER_PATH.read_text()
314 match = re.search(r"def build_playlist\(self\):(?P<body>.*?)def kill_dialog", source, re.S)
315 assert match is not None
316 body = match.group("body")
317 # When autoscrape is enabled, build_url must be called with "play_autoscraped"
318 assert '"play_autoscraped"' in body
319 # The conditional must check get_setting("autoscrape_next_episode")
320 assert 'get_setting("autoscrape_next_episode"' in body
321
322
323def test_build_playlist_uses_search_when_disabled():
324 source = PLAYER_PATH.read_text()
325 match = re.search(r"def build_playlist\(self\):(?P<body>.*?)def kill_dialog", source, re.S)
326 assert match is not None
327 body = match.group("body")
328 # Fallback search path must still exist
329 assert '"search"' in body
330 # play_autoscraped must only be in the True branch
331 assert 'if get_setting("autoscrape_next_episode"' in body
332
333
334def test_monitor_autoscrape_threshold_spawns_thread_once():
335 source = PLAYER_PATH.read_text()
336
337 # check_autoscrape_threshold must exist
338 assert "def check_autoscrape_threshold(self):" in source
339
340 # Must spawn a Thread with target=autoscrape_next_episode
341 threshold_match = re.search(
342 r"def check_autoscrape_threshold\(self\):(?P<body>.*?)def check_next_dialog",
343 source,
344 re.S,
345 )
346 assert threshold_match is not None
347 threshold_body = threshold_match.group("body")
348 assert "Thread(target=autoscrape_next_episode" in threshold_body
349
350 # Must reset autoscrape_started in set_constants
351 constants_match = re.search(
352 r"def set_constants\(self, data\):(?P<body>.*?)def fetch_introdb_segments",
353 source,
354 re.S,
355 )
356 assert constants_match is not None
357 assert "self.autoscrape_started = False" in constants_match.group("body")
358
359 # Must call check_autoscrape_threshold inside monitor loop
360 monitor_match = re.search(
361 r"def monitor\(self\):(?P<body>.*?)def handle_subtitles", source, re.S
362 )
363 assert monitor_match is not None
364 assert "self.check_autoscrape_threshold()" in monitor_match.group("body")
365
366
367def test_run_next_dialog_sets_pending_action_without_direct_playback():
368 from lib.gui.custom_dialogs import run_next_dialog
369
370 with patch("lib.gui.custom_dialogs.PLAYLIST") as mock_playlist, patch(
371 "lib.gui.custom_dialogs.PlayNext"
372 ) as mock_window_cls, patch("lib.gui.custom_dialogs.set_property") as mock_set_property, patch(
373 "lib.gui.custom_dialogs.clear_property"
374 ) as mock_clear_property, patch("xbmc.Player") as mock_xbmc_player:
375 mock_playlist.size.return_value = 2
376 mock_playlist.getposition.return_value = 0
377 mock_next_item = MagicMock()
378 mock_next_item.getLabel.return_value = "1x2. Next Episode"
379 mock_playlist.__getitem__.return_value = mock_next_item
380 mock_window = MagicMock()
381 mock_window.action = "next_episode"
382 mock_window_cls.return_value = mock_window
383
384 params = {
385 "item_info": json.dumps(
386 {
387 "mode": "tv",
388 "title": "Show",
389 "ids": {"tmdb_id": "123"},
390 "tv_data": {"season": 1, "episode": 1},
391 }
392 )
393 }
394
395 run_next_dialog(params)
396
397 mock_set_property.assert_called_once_with("jacktook_next_dialog_action", "next_episode")
398 mock_clear_property.assert_not_called()
399 mock_xbmc_player.assert_not_called()
400 created_item_info = mock_window_cls.call_args.kwargs["item_information"]
401 assert created_item_info["next_label"] == "1x2. Next Episode"
402
403
404def test_run_next_dialog_clears_pending_action_when_dialog_not_accepted():
405 from lib.gui.custom_dialogs import run_next_dialog
406
407 with patch("lib.gui.custom_dialogs.PLAYLIST") as mock_playlist, patch(
408 "lib.gui.custom_dialogs.PlayNext"
409 ) as mock_window_cls, patch("lib.gui.custom_dialogs.set_property") as mock_set_property, patch(
410 "lib.gui.custom_dialogs.clear_property"
411 ) as mock_clear_property:
412 mock_playlist.size.return_value = 2
413 mock_playlist.getposition.return_value = 0
414 mock_window = MagicMock()
415 mock_window.action = "close"
416 mock_window_cls.return_value = mock_window
417
418 params = {
419 "item_info": json.dumps(
420 {
421 "mode": "tv",
422 "title": "Show",
423 "ids": {"tmdb_id": "123"},
424 "tv_data": {"season": 1, "episode": 1},
425 }
426 )
427 }
428
429 run_next_dialog(params)
430
431 mock_set_property.assert_not_called()
432 mock_clear_property.assert_called_once_with("jacktook_next_dialog_action")
433
434
435def test_run_next_dialog_shows_without_next_playlist_item():
436 from lib.gui.custom_dialogs import run_next_dialog
437
438 with patch("lib.gui.custom_dialogs.PLAYLIST") as mock_playlist, patch(
439 "lib.gui.custom_dialogs.PlayNext"
440 ) as mock_window_cls, patch("lib.gui.custom_dialogs.set_property") as mock_set_property, patch(
441 "lib.gui.custom_dialogs.clear_property"
442 ) as mock_clear_property:
443 mock_playlist.size.return_value = 1
444 mock_playlist.getposition.return_value = 0
445 mock_window = MagicMock()
446 mock_window.action = "next_episode"
447 mock_window_cls.return_value = mock_window
448
449 run_next_dialog({"item_info": "{}"})
450
451 mock_window_cls.assert_called_once()
452 created_item_info = mock_window_cls.call_args.kwargs["item_information"]
453 assert "next_label" not in created_item_info
454 mock_set_property.assert_called_once_with("jacktook_next_dialog_action", "next_episode")
455 mock_clear_property.assert_not_called()
plugin.video.jacktook/tests/unit/test_base_window_focus_cache.py +1 -1
……@@ -1,4 +1,4 @@
1from unittest.mock import MagicMock, patch
1from unittest.mock import patch
22
33from lib.gui.base_window import BaseWindow
44
plugin.video.jacktook/tests/unit/test_burst_client.py +190 -0
……@@ -0,0 +1,190 @@
1from unittest.mock import MagicMock, patch
2
3import pytest
4
5from lib.domain.torrent import TorrentStream
6from lib.jacktook.client import Burst
7
8
9@pytest.fixture
10def burst_client():
11 return Burst(notification=MagicMock())
12
13
14class MockProviderResult:
15 def __init__(self, title, guid, indexer, seeders, peers, size):
16 self.title = title
17 self.guid = guid
18 self.indexer = indexer
19 self.seeders = seeders
20 self.peers = peers
21 self.size = size
22
23
24def test_parse_response_valid_results(burst_client):
25 results = [
26 MockProviderResult(
27 title="Movie 2024 1080p",
28 guid="magnet:?xt=urn:btih:abc123",
29 indexer="1337x",
30 seeders=100,
31 peers=50,
32 size="1.5 GB",
33 ),
34 MockProviderResult(
35 title="Movie 2024 720p",
36 guid="magnet:?xt=urn:btih:def456",
37 indexer="ThePirateBay",
38 seeders=50,
39 peers=25,
40 size="800 MB",
41 ),
42 ]
43
44 parsed = burst_client.parse_response(results)
45
46 assert len(parsed) == 2
47 assert parsed[0].title == "Movie 2024 1080p"
48 assert parsed[0].seeders == 100
49 assert parsed[0].peers == 50
50 assert parsed[0].size == 1610612736 # 1.5 GB in bytes
51 assert parsed[0].indexer == "Burst"
52 assert parsed[0].provider == "1337x"
53 assert parsed[1].title == "Movie 2024 720p"
54 assert parsed[1].size == 838860800 # 800 MB in bytes
55
56
57def test_parse_response_with_none_seeders_peers(burst_client):
58 results = [
59 MockProviderResult(
60 title="Movie None Seeds",
61 guid="magnet:?xt=urn:btih:abc123",
62 indexer="1337x",
63 seeders=None,
64 peers=None,
65 size="500 MB",
66 ),
67 ]
68
69 parsed = burst_client.parse_response(results)
70
71 assert len(parsed) == 1
72 assert parsed[0].seeders == 0
73 assert parsed[0].peers == 0
74 assert parsed[0].size == 524288000
75
76
77def test_parse_response_with_string_seeders_peers(burst_client):
78 results = [
79 MockProviderResult(
80 title="Movie String Seeds",
81 guid="magnet:?xt=urn:btih:abc123",
82 indexer="1337x",
83 seeders="42",
84 peers="21",
85 size="1 GB",
86 ),
87 ]
88
89 parsed = burst_client.parse_response(results)
90
91 assert len(parsed) == 1
92 assert parsed[0].seeders == 42
93 assert parsed[0].peers == 21
94
95
96@patch("lib.clients.base.notification")
97def test_parse_response_with_invalid_seeders_skips_result(mock_notification):
98 burst_client = Burst(notification=MagicMock())
99 results = [
100 MockProviderResult(
101 title="Valid Movie",
102 guid="magnet:?xt=urn:btih:abc123",
103 indexer="1337x",
104 seeders=10,
105 peers=5,
106 size="1 GB",
107 ),
108 MockProviderResult(
109 title="Invalid Seeds Movie",
110 guid="magnet:?xt=urn:btih:def456",
111 indexer="TPB",
112 seeders="1000+", # Invalid int conversion
113 peers="50",
114 size="2 GB",
115 ),
116 MockProviderResult(
117 title="Another Valid",
118 guid="magnet:?xt=urn:btih:ghi789",
119 indexer="RARBG",
120 seeders=20,
121 peers=10,
122 size="3 GB",
123 ),
124 ]
125
126 parsed = burst_client.parse_response(results)
127
128 assert len(parsed) == 2
129 assert parsed[0].title == "Valid Movie"
130 assert parsed[1].title == "Another Valid"
131 mock_notification.assert_called_once()
132
133
134def test_parse_response_with_none_size(burst_client):
135 results = [
136 MockProviderResult(
137 title="Movie No Size",
138 guid="magnet:?xt=urn:btih:abc123",
139 indexer="1337x",
140 seeders=10,
141 peers=5,
142 size=None,
143 ),
144 ]
145
146 parsed = burst_client.parse_response(results)
147
148 assert len(parsed) == 1
149 assert parsed[0].size == 0
150
151
152def test_parse_response_with_empty_size(burst_client):
153 results = [
154 MockProviderResult(
155 title="Movie Empty Size",
156 guid="magnet:?xt=urn:btih:abc123",
157 indexer="1337x",
158 seeders=10,
159 peers=5,
160 size="",
161 ),
162 ]
163
164 parsed = burst_client.parse_response(results)
165
166 assert len(parsed) == 1
167 assert parsed[0].size == 0
168
169
170def test_parse_response_empty_input(burst_client):
171 parsed = burst_client.parse_response([])
172 assert parsed == []
173
174
175def test_parse_response_returns_torrent_stream_instances(burst_client):
176 results = [
177 MockProviderResult(
178 title="Test",
179 guid="magnet:?xt=urn:btih:abc123",
180 indexer="1337x",
181 seeders=1,
182 peers=1,
183 size="100 MB",
184 ),
185 ]
186
187 parsed = burst_client.parse_response(results)
188
189 assert len(parsed) == 1
190 assert isinstance(parsed[0], TorrentStream)
plugin.video.jacktook/tests/unit/test_check_py37_compat.py +126 -0
……@@ -0,0 +1,126 @@
1import importlib.util
2from pathlib import Path
3
4
5SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "check_py37_compat.py"
6
7
8def load_checker():
9 spec = importlib.util.spec_from_file_location("check_py37_compat", SCRIPT_PATH)
10 module = importlib.util.module_from_spec(spec)
11 spec.loader.exec_module(module)
12 return module
13
14
15def write_python_file(tmp_path, source):
16 file_path = tmp_path / "sample.py"
17 file_path.write_text(source, encoding="utf-8")
18 return str(file_path)
19
20
21def test_typing_aliases_are_not_reported_as_pep585(tmp_path):
22 checker = load_checker()
23 file_path = write_python_file(
24 tmp_path,
25 "from typing import Dict, List, Tuple\n"
26 "value: List[Tuple[str, str]] = []\n"
27 "def f(items: Dict[str, int]) -> Tuple[str, int]:\n"
28 " return ('ok', 1)\n",
29 )
30
31 assert checker.find_incompatible_syntax(file_path) == []
32
33
34def test_builtin_generic_annotations_are_reported(tmp_path):
35 checker = load_checker()
36 file_path = write_python_file(
37 tmp_path,
38 "value: list[tuple[str, str]] = []\n"
39 "def f(items: dict[str, int]) -> set[str]:\n"
40 " return set()\n",
41 )
42
43 issues = checker.find_incompatible_syntax(file_path)
44
45 assert [issue[0] for issue in issues] == [
46 "PEP 585 generic",
47 "PEP 585 generic",
48 "PEP 585 generic",
49 "PEP 585 generic",
50 ]
51
52
53def test_value_level_set_union_is_not_reported(tmp_path):
54 checker = load_checker()
55 file_path = write_python_file(
56 tmp_path,
57 "def f():\n"
58 " left = {'a'}\n"
59 " right = {'b'}\n"
60 " return left | right\n",
61 )
62
63 assert checker.find_incompatible_syntax(file_path) == []
64
65
66def test_union_pipe_annotation_is_reported(tmp_path):
67 checker = load_checker()
68 file_path = write_python_file(tmp_path, "def f(value: str | None) -> None:\n pass\n")
69
70 assert checker.find_incompatible_syntax(file_path) == [
71 ("Union | syntax", 1, "Use Union[...] instead of | in annotations")
72 ]
73
74
75def test_builtin_generic_alias_assignment_is_reported(tmp_path):
76 checker = load_checker()
77 file_path = write_python_file(tmp_path, "TorrentMap = dict[str, str]\n")
78
79 assert checker.find_incompatible_syntax(file_path) == [
80 ("PEP 585 generic", 1, "Use typing.Dict instead of built-in dict[...]")
81 ]
82
83
84def test_qualified_unsupported_typing_name_is_reported(tmp_path):
85 checker = load_checker()
86 file_path = write_python_file(
87 tmp_path,
88 "import typing\n"
89 "class Example:\n"
90 " def clone(self) -> typing.Self:\n"
91 " return self\n",
92 )
93
94 assert checker.find_incompatible_syntax(file_path) == [
95 ("Unsupported typing: Self", 3, "Requires typing_extensions or a newer Python runtime")
96 ]
97
98
99def test_direct_file_path_is_scanned(tmp_path):
100 checker = load_checker()
101 file_path = write_python_file(tmp_path, "value: list[str] = []\n")
102
103 assert checker.scan_project(file_path) == [
104 (file_path, 1, "PEP 585 generic", "Use typing.List instead of built-in list[...]")
105 ]
106
107
108def test_similarly_named_vendor_directory_is_not_excluded(tmp_path):
109 checker = load_checker()
110 vendorized_dir = tmp_path / "lib" / "vendorized"
111 vendorized_dir.mkdir(parents=True)
112 file_path = vendorized_dir / "bad.py"
113 file_path.write_text("value: list[str] = []\n", encoding="utf-8")
114
115 assert checker.scan_project(str(tmp_path)) == [
116 (str(file_path), 1, "PEP 585 generic", "Use typing.List instead of built-in list[...]")
117 ]
118
119
120def test_excluded_directories_are_skipped(tmp_path):
121 checker = load_checker()
122 venv_dir = tmp_path / ".venv"
123 venv_dir.mkdir()
124 (venv_dir / "bad.py").write_text("value: list[str] = []\n", encoding="utf-8")
125
126 assert checker.scan_project(str(tmp_path)) == []
plugin.video.jacktook/tests/unit/test_clear_tmdb_cache.py +46 -0
……@@ -0,0 +1,46 @@
1from unittest.mock import MagicMock, patch
2
3from lib.utils.general.utils import clear_tmdb_cache
4
5
6def test_clear_tmdb_cache_deletes_tmdb_ui_meta_entries():
7 with patch("lib.utils.general.utils.cache") as mock_cache:
8 with patch("lib.utils.general.utils.notification"):
9 with patch("lib.utils.general.utils.translation", return_value=""):
10 with patch("lib.utils.general.utils.TMDb") as mock_tmdb_cls:
11 mock_tmdb_instance = MagicMock()
12 mock_tmdb_cls.return_value = mock_tmdb_instance
13 clear_tmdb_cache()
14 # Verify tmdb_ui_meta prefix is deleted
15 delete_like_calls = [
16 call
17 for call in mock_cache.method_calls
18 if call[0] == "delete_like" and "tmdb_ui_meta" in call[1][0]
19 ]
20 assert len(delete_like_calls) == 1
21 assert delete_like_calls[0][1][0] == "tmdb_ui_meta|%"
22
23
24def test_clear_tmdb_cache_calls_tmdb_cache_clear():
25 with patch("lib.utils.general.utils.cache"):
26 with patch("lib.utils.general.utils.notification"):
27 with patch("lib.utils.general.utils.translation", return_value=""):
28 with patch("lib.utils.general.utils.TMDb") as mock_tmdb_cls:
29 mock_tmdb_instance = MagicMock()
30 mock_tmdb_cls.return_value = mock_tmdb_instance
31 clear_tmdb_cache()
32 mock_tmdb_instance.cache_clear.assert_called_once()
33
34
35def test_clear_tmdb_cache_still_deletes_other_prefixes():
36 with patch("lib.utils.general.utils.cache") as mock_cache:
37 with patch("lib.utils.general.utils.notification"):
38 with patch("lib.utils.general.utils.translation", return_value=""):
39 with patch("lib.utils.general.utils.TMDb"):
40 clear_tmdb_cache()
41 # Verify some legacy prefixes are still deleted
42 delete_like_calls = [
43 call[1][0] for call in mock_cache.method_calls if call[0] == "delete_like"
44 ]
45 assert "search_%|%" in delete_like_calls
46 assert "movie_%|%" in delete_like_calls
plugin.video.jacktook/tests/unit/test_cloud_download.py +319 -0
……@@ -0,0 +1,319 @@
1import os
2import sys
3import tempfile
4from unittest.mock import MagicMock, patch
5
6# Ensure Kodi modules are mocked before importing lib modules
7sys.modules.setdefault("xbmc", MagicMock())
8sys.modules.setdefault("xbmcgui", MagicMock())
9sys.modules.setdefault("xbmcplugin", MagicMock())
10sys.modules.setdefault("xbmcvfs", MagicMock())
11sys.modules.setdefault("xbmcaddon", MagicMock())
12
13
14from lib.download_manager import DownloadManager
15
16
17def _load_downloader_module():
18 import importlib
19
20 if "lib.downloader" in sys.modules:
21 return importlib.reload(sys.modules["lib.downloader"])
22 return importlib.import_module("lib.downloader")
23
24
25class TestDownloadCloudFile:
26 def setup_method(self):
27 DownloadManager().clear()
28
29 def test_download_cloud_file_rd_registers_and_starts(self):
30 downloader = _load_downloader_module()
31
32 with tempfile.TemporaryDirectory() as tmpdir:
33 with patch.object(
34 downloader,
35 "get_setting",
36 side_effect=lambda key, default="": {
37 "download_dir": tmpdir,
38 "organize_downloads": False,
39 }.get(key, default),
40 ), patch.object(downloader, "translatePath", return_value=tmpdir), patch.object(
41 downloader, "normalize_file_name", return_value="Movie.mkv"
42 ), patch.object(downloader, "Downloader") as mock_downloader_cls:
43 mock_downloader = MagicMock()
44 mock_downloader_cls.return_value = mock_downloader
45
46 downloader.download_cloud_file(
47 {
48 "url": "https://example.com/Movie.mkv",
49 "filename": "Movie.mkv",
50 "mode": "movie",
51 "debrid_type": "RD",
52 }
53 )
54
55 manager = DownloadManager()
56 entries = manager.list_entries()
57 assert len(entries) == 1
58 assert entries[0].name == "Movie.mkv"
59 assert entries[0].status == "downloading"
60 assert entries[0].url == "https://example.com/Movie.mkv"
61 assert entries[0].thread is not None
62
63 mock_downloader_cls.assert_called_once()
64 call_kwargs = mock_downloader_cls.call_args.kwargs
65 assert call_kwargs["url"] == "https://example.com/Movie.mkv"
66 assert call_kwargs["name"] == "Movie.mkv"
67 assert call_kwargs["destination"] == tmpdir
68 assert call_kwargs["registry_id"] is not None
69 mock_downloader.run.assert_called_once()
70
71 def test_download_cloud_file_tb_resolves_link_and_starts(self):
72 downloader = _load_downloader_module()
73
74 with tempfile.TemporaryDirectory() as tmpdir:
75 with patch.object(
76 downloader,
77 "get_setting",
78 side_effect=lambda key, default="": {
79 "download_dir": tmpdir,
80 "organize_downloads": False,
81 }.get(key, default),
82 ), patch.object(downloader, "translatePath", return_value=tmpdir), patch.object(
83 downloader, "normalize_file_name", return_value="Movie.mkv"
84 ), patch.object(downloader, "Downloader") as mock_downloader_cls, patch.object(
85 downloader,
86 "resolve_cloud_download_url",
87 return_value="https://tb.example.com/Movie.mkv",
88 ) as mock_resolve:
89 mock_downloader = MagicMock()
90 mock_downloader_cls.return_value = mock_downloader
91
92 downloader.download_cloud_file(
93 {
94 "torrent_id": "123",
95 "file_id": "456",
96 "filename": "Movie.mkv",
97 "mode": "movie",
98 "debrid_type": "TB",
99 }
100 )
101
102 mock_resolve.assert_called_once()
103 manager = DownloadManager()
104 entries = manager.list_entries()
105 assert len(entries) == 1
106 assert entries[0].url == "https://tb.example.com/Movie.mkv"
107 assert entries[0].thread is not None
108
109 call_kwargs = mock_downloader_cls.call_args.kwargs
110 assert call_kwargs["destination"] == tmpdir
111 mock_downloader.run.assert_called_once()
112
113 def test_download_cloud_file_missing_url_notifies(self):
114 downloader = _load_downloader_module()
115
116 with patch.object(downloader, "notification") as mock_notification:
117 downloader.download_cloud_file(
118 {
119 "filename": "Movie.mkv",
120 "mode": "movie",
121 "debrid_type": "RD",
122 }
123 )
124
125 mock_notification.assert_called_once()
126 manager = DownloadManager()
127 assert len(manager.list_entries()) == 0
128
129 def test_download_cloud_file_duplicate_reuses_existing(self):
130 downloader = _load_downloader_module()
131
132 manager = DownloadManager()
133
134 with tempfile.TemporaryDirectory() as tmpdir:
135 manager.register(
136 name="Movie.mkv",
137 dest_path=os.path.join(tmpdir, "Movie.mkv"),
138 url="https://example.com/Movie.mkv",
139 )
140
141 with patch.object(
142 downloader,
143 "get_setting",
144 side_effect=lambda key, default="": {
145 "download_dir": tmpdir,
146 "organize_downloads": False,
147 }.get(key, default),
148 ), patch.object(downloader, "translatePath", return_value=tmpdir), patch.object(
149 downloader, "normalize_file_name", return_value="Movie.mkv"
150 ), patch.object(downloader, "notification") as mock_notification:
151 downloader.download_cloud_file(
152 {
153 "url": "https://example.com/Movie.mkv",
154 "filename": "Movie.mkv",
155 "mode": "movie",
156 "debrid_type": "RD",
157 }
158 )
159
160 mock_notification.assert_called_once()
161 assert len(manager.list_entries()) == 1
162
163 def test_download_cloud_file_uses_organized_destination(self):
164 downloader = _load_downloader_module()
165
166 with tempfile.TemporaryDirectory() as tmpdir:
167 movies_dir = os.path.join(tmpdir, "Movies")
168 with patch.object(
169 downloader,
170 "get_setting",
171 side_effect=lambda key, default="": {
172 "download_dir": tmpdir,
173 "organize_downloads": True,
174 "download_folder_movies": "Movies",
175 "download_folder_tvshows": "TV Shows",
176 }.get(key, default),
177 ), patch.object(downloader, "translatePath", return_value=tmpdir), patch.object(
178 downloader, "normalize_file_name", return_value="Movie.mkv"
179 ), patch.object(downloader, "Downloader") as mock_downloader_cls, patch.object(
180 downloader.xbmcvfs, "mkdirs"
181 ) as mock_mkdirs:
182 mock_downloader = MagicMock()
183 mock_downloader_cls.return_value = mock_downloader
184
185 downloader.download_cloud_file(
186 {
187 "url": "https://example.com/Movie.mkv",
188 "filename": "Movie.mkv",
189 "mode": "movie",
190 "debrid_type": "RD",
191 }
192 )
193
194 mock_mkdirs.assert_called_once_with(movies_dir)
195 call_kwargs = mock_downloader_cls.call_args.kwargs
196 assert call_kwargs["destination"] == movies_dir
197 assert call_kwargs["registry_id"] == os.path.join(movies_dir, "Movie.mkv")
198
199 def test_get_rd_downloads_adds_menu_even_without_url(self):
200 debrid = __import__("lib.nav.debrid", fromlist=["get_rd_downloads"])
201
202 mock_li = MagicMock()
203 with patch.object(debrid, "build_list_item", return_value=mock_li), patch.object(
204 debrid, "get_random_color", return_value="red"
205 ), patch.object(
206 debrid,
207 "get_setting",
208 side_effect=lambda key, default="": "" if key == "real_debrid_token" else default,
209 ), patch.object(
210 debrid.cache,
211 "get",
212 return_value=[{"filename": "Movie.mkv", "download": ""}],
213 ), patch.object(debrid, "addDirectoryItem"), patch.object(
214 debrid, "end_of_directory"
215 ), patch.object(debrid, "apply_section_view"), patch.object(
216 debrid, "action_url_run", return_value="run:download"
217 ) as mock_action:
218 debrid.get_rd_downloads({"page": 1})
219 mock_li.addContextMenuItems.assert_called_once()
220 assert mock_action.call_count == 1
221 assert mock_action.call_args.kwargs.get("url") == ""
222
223 def test_resolve_cloud_download_url_rd_returns_direct(self):
224 debrid = __import__("lib.nav.debrid", fromlist=["resolve_cloud_download_url"])
225
226 result = debrid.resolve_cloud_download_url(
227 {
228 "debrid_type": "RD",
229 "url": "https://example.com/Movie.mkv",
230 }
231 )
232 assert result == "https://example.com/Movie.mkv"
233
234 def test_resolve_cloud_download_url_rd_missing_url_returns_none(self):
235 debrid = __import__("lib.nav.debrid", fromlist=["resolve_cloud_download_url"])
236
237 result = debrid.resolve_cloud_download_url(
238 {
239 "debrid_type": "RD",
240 }
241 )
242 assert result is None
243
244 def test_resolve_cloud_download_url_tb_calls_create_link(self):
245 debrid = __import__("lib.nav.debrid", fromlist=["resolve_cloud_download_url"])
246
247 mock_client = MagicMock()
248 mock_client.create_download_link.return_value = {"data": "https://tb.example.com/Movie.mkv"}
249 mock_helper = MagicMock()
250 mock_helper.client = mock_client
251
252 with patch.object(debrid, "TorboxHelper", return_value=mock_helper), patch.object(
253 debrid, "get_public_ip", return_value="1.2.3.4"
254 ), patch.object(debrid.cache, "get", return_value=None), patch.object(debrid.cache, "set"):
255 result = debrid.resolve_cloud_download_url(
256 {
257 "debrid_type": "TB",
258 "torrent_id": "123",
259 "file_id": "456",
260 }
261 )
262
263 assert result == "https://tb.example.com/Movie.mkv"
264 mock_client.create_download_link.assert_called_once_with("123", "456", "1.2.3.4")
265
266 def test_resolve_cloud_download_url_tb_failure_returns_none(self):
267 debrid = __import__("lib.nav.debrid", fromlist=["resolve_cloud_download_url"])
268
269 mock_client = MagicMock()
270 mock_client.create_download_link.return_value = None
271 mock_helper = MagicMock()
272 mock_helper.client = mock_client
273
274 with patch.object(debrid, "TorboxHelper", return_value=mock_helper), patch.object(
275 debrid, "get_public_ip", return_value="1.2.3.4"
276 ), patch.object(debrid.cache, "get", return_value=None), patch.object(debrid.cache, "set"):
277 result = debrid.resolve_cloud_download_url(
278 {
279 "debrid_type": "TB",
280 "torrent_id": "123",
281 "file_id": "456",
282 }
283 )
284
285 assert result is None
286
287 def test_resolve_cloud_download_url_tb_caches_result(self):
288 debrid = __import__("lib.nav.debrid", fromlist=["resolve_cloud_download_url"])
289
290 mock_client = MagicMock()
291 mock_client.create_download_link.return_value = {"data": "https://tb.example.com/Movie.mkv"}
292 mock_helper = MagicMock()
293 mock_helper.client = mock_client
294
295 with patch.object(debrid, "TorboxHelper", return_value=mock_helper), patch.object(
296 debrid, "get_public_ip", return_value="1.2.3.4"
297 ), patch.object(
298 debrid.cache, "get", side_effect=[None, "https://tb.example.com/Movie.mkv"]
299 ), patch.object(debrid.cache, "set") as mock_cache_set:
300 result1 = debrid.resolve_cloud_download_url(
301 {
302 "debrid_type": "TB",
303 "torrent_id": "123",
304 "file_id": "456",
305 }
306 )
307 assert result1 == "https://tb.example.com/Movie.mkv"
308 mock_client.create_download_link.assert_called_once_with("123", "456", "1.2.3.4")
309 mock_cache_set.assert_called_once()
310
311 result2 = debrid.resolve_cloud_download_url(
312 {
313 "debrid_type": "TB",
314 "torrent_id": "123",
315 "file_id": "456",
316 }
317 )
318 assert result2 == "https://tb.example.com/Movie.mkv"
319 assert mock_client.create_download_link.call_count == 1
plugin.video.jacktook/tests/unit/test_debrid_utils.py +3 -9
……@@ -13,9 +13,7 @@ def test_get_magnet_from_uri_follows_redirect_to_magnet():
1313 with patch(
1414 "lib.utils.debrid.debrid_utils.requests.get", return_value=redirect_response
1515 ) as mock_get:
16 magnet, info_hash, torrent_url = get_magnet_from_uri(
17 "https://jackett.local/dl/test"
18 )
16 magnet, info_hash, torrent_url = get_magnet_from_uri("https://jackett.local/dl/test")
1917
2018 assert magnet.startswith("magnet:?xt=urn:btih:1234567890ABCDEF")
2119 assert info_hash == "1234567890abcdef1234567890abcdef12345678"
……@@ -38,9 +36,7 @@ def test_get_magnet_from_uri_follows_http_redirect_chain():
3836 "lib.utils.debrid.debrid_utils.requests.get",
3937 side_effect=[first_response, second_response],
4038 ) as mock_get:
41 magnet, info_hash, torrent_url = get_magnet_from_uri(
42 "https://jackett.local/dl/test"
43 )
39 magnet, info_hash, torrent_url = get_magnet_from_uri("https://jackett.local/dl/test")
4440
4541 assert magnet.startswith("magnet:?xt=urn:btih:ABCDEF1234567890")
4642 assert info_hash == "abcdef1234567890abcdef1234567890abcdef12"
……@@ -60,9 +56,7 @@ def test_get_magnet_from_uri_preserves_torrent_url_for_playback():
6056 "lib.utils.debrid.debrid_utils.extract_torrent_metadata",
6157 return_value="magnet:?xt=urn:btih:AAAABBBBCCCCDDDDEEEEFFFF0000111122223333&dn=Test",
6258 ):
63 magnet, info_hash, torrent_url = get_magnet_from_uri(
64 "https://jackett.local/dl/test"
65 )
59 magnet, info_hash, torrent_url = get_magnet_from_uri("https://jackett.local/dl/test")
6660
6761 assert magnet.startswith("magnet:?xt=urn:btih:AAAABBBBCCCCDDDDEEEEFFFF")
6862 assert info_hash == "aaaabbbbccccddddeeeeffff0000111122223333"
plugin.video.jacktook/tests/unit/test_download_manager.py +219 -0
……@@ -0,0 +1,219 @@
1import threading
2from unittest.mock import MagicMock
3
4from lib.download_manager import DownloadManager
5
6
7class TestDownloadManager:
8 def setup_method(self):
9 DownloadManager().clear()
10
11 def test_register_creates_entry(self):
12 manager = DownloadManager()
13 entry = manager.register(
14 name="test.mp4",
15 dest_path="/downloads/test.mp4",
16 url="https://example.com/test.mp4",
17 )
18
19 assert entry.name == "test.mp4"
20 assert entry.dest_path == "/downloads/test.mp4"
21 assert entry.url == "https://example.com/test.mp4"
22 assert entry.status == "downloading"
23 assert entry.progress == 0
24 assert entry.id == "/downloads/test.mp4"
25
26 def test_register_duplicate_rejects_second_download(self):
27 manager = DownloadManager()
28 manager.register(
29 name="test.mp4",
30 dest_path="/downloads/test.mp4",
31 url="https://example.com/test.mp4",
32 )
33
34 result = manager.register(
35 name="test.mp4",
36 dest_path="/downloads/test.mp4",
37 url="https://example.com/test.mp4",
38 )
39
40 assert result is None
41 assert len(manager.registry) == 1
42
43 def test_register_duplicate_allows_after_completed(self):
44 manager = DownloadManager()
45 manager.register(
46 name="test.mp4",
47 dest_path="/downloads/test.mp4",
48 url="https://example.com/test.mp4",
49 )
50 manager.set_status("/downloads/test.mp4", "completed")
51
52 result = manager.register(
53 name="test.mp4",
54 dest_path="/downloads/test.mp4",
55 url="https://example.com/test.mp4",
56 )
57
58 assert result is not None
59 assert result.status == "downloading"
60
61 def test_update_progress_updates_fields(self):
62 manager = DownloadManager()
63 manager.register(
64 name="test.mp4",
65 dest_path="/downloads/test.mp4",
66 url="https://example.com/test.mp4",
67 )
68
69 manager.update_progress(
70 "/downloads/test.mp4",
71 downloaded=5_000_000,
72 speed=1_000_000,
73 eta=60,
74 progress=50,
75 size=10_000_000,
76 )
77
78 entry = manager.get_entry("/downloads/test.mp4")
79 assert entry.downloaded == 5_000_000
80 assert entry.speed == 1_000_000
81 assert entry.eta == 60
82 assert entry.progress == 50
83 assert entry.size == 10_000_000
84
85 def test_set_status_changes_status(self):
86 manager = DownloadManager()
87 manager.register(
88 name="test.mp4",
89 dest_path="/downloads/test.mp4",
90 url="https://example.com/test.mp4",
91 )
92
93 manager.set_status("/downloads/test.mp4", "paused")
94 entry = manager.get_entry("/downloads/test.mp4")
95 assert entry.status == "paused"
96
97 def test_get_entry_missing_returns_none(self):
98 manager = DownloadManager()
99 assert manager.get_entry("/downloads/missing.mp4") is None
100
101 def test_remove_entry_deletes_from_registry(self):
102 manager = DownloadManager()
103 manager.register(
104 name="test.mp4",
105 dest_path="/downloads/test.mp4",
106 url="https://example.com/test.mp4",
107 )
108
109 manager.remove_entry("/downloads/test.mp4")
110 assert manager.get_entry("/downloads/test.mp4") is None
111
112 def test_list_entries_returns_all(self):
113 manager = DownloadManager()
114 manager.register(
115 name="a.mp4", dest_path="/downloads/a.mp4", url="https://example.com/a.mp4"
116 )
117 manager.register(
118 name="b.mp4", dest_path="/downloads/b.mp4", url="https://example.com/b.mp4"
119 )
120
121 entries = manager.list_entries()
122 assert len(entries) == 2
123 assert {e.name for e in entries} == {"a.mp4", "b.mp4"}
124
125 def test_singleton_same_instance(self):
126 manager1 = DownloadManager()
127 manager2 = DownloadManager()
128 assert manager1 is manager2
129
130 def test_update_progress_missing_entry_no_crash(self):
131 manager = DownloadManager()
132 manager.update_progress(
133 "/downloads/missing.mp4",
134 downloaded=1,
135 speed=1,
136 eta=1,
137 progress=1,
138 size=1,
139 )
140 assert manager.get_entry("/downloads/missing.mp4") is None
141
142 def test_set_status_missing_entry_no_crash(self):
143 manager = DownloadManager()
144 manager.set_status("/downloads/missing.mp4", "paused")
145 assert manager.get_entry("/downloads/missing.mp4") is None
146
147 def test_register_with_thread(self):
148 manager = DownloadManager()
149 mock_thread = MagicMock()
150 entry = manager.register(
151 name="test.mp4",
152 dest_path="/downloads/test.mp4",
153 url="https://example.com/test.mp4",
154 thread=mock_thread,
155 )
156 assert entry.thread is mock_thread
157
158 def test_clear_removes_all_entries(self):
159 manager = DownloadManager()
160 manager.register(
161 name="a.mp4", dest_path="/downloads/a.mp4", url="https://example.com/a.mp4"
162 )
163 manager.clear()
164 assert manager.list_entries() == []
165
166 def test_concurrent_register_no_exceptions(self):
167 manager = DownloadManager()
168 errors = []
169
170 def worker(i):
171 try:
172 manager.register(
173 name=f"file{i}.mp4",
174 dest_path=f"/downloads/file{i}.mp4",
175 url=f"https://example.com/file{i}.mp4",
176 )
177 except Exception as e:
178 errors.append(e)
179
180 threads = [threading.Thread(target=worker, args=(i,)) for i in range(20)]
181 for t in threads:
182 t.start()
183 for t in threads:
184 t.join()
185
186 assert not errors
187 assert len(manager.registry) == 20
188
189 def test_concurrent_update_no_exceptions(self):
190 manager = DownloadManager()
191 manager.register(
192 name="test.mp4",
193 dest_path="/downloads/test.mp4",
194 url="https://example.com/test.mp4",
195 )
196 errors = []
197
198 def worker(i):
199 try:
200 manager.update_progress(
201 "/downloads/test.mp4",
202 downloaded=i * 1_000_000,
203 speed=1_000_000,
204 eta=60,
205 progress=i * 5,
206 size=20_000_000,
207 )
208 except Exception as e:
209 errors.append(e)
210
211 threads = [threading.Thread(target=worker, args=(i,)) for i in range(20)]
212 for t in threads:
213 t.start()
214 for t in threads:
215 t.join()
216
217 assert not errors
218 entry = manager.get_entry("/downloads/test.mp4")
219 assert entry is not None
plugin.video.jacktook/tests/unit/test_download_manager_window.py +318 -0
……@@ -0,0 +1,318 @@
1import threading
2from unittest.mock import MagicMock, patch
3
4from lib.download_manager import DownloadManager
5from lib.gui.download_manager_window import DownloadManagerWindow
6
7
8class TestDownloadManagerWindow:
9 def setup_method(self):
10 DownloadManager().clear()
11
12 def test_on_init_sets_empty_message_when_no_entries(self):
13 window = DownloadManagerWindow("download_manager.xml", "")
14 window.setProperty = MagicMock()
15 window.getControl = MagicMock(return_value=MagicMock())
16
17 window.onInit()
18
19 window.setProperty.assert_any_call("downloads_empty", "true")
20 assert isinstance(window._poll_thread, threading.Thread)
21
22 def test_on_init_starts_polling_thread(self):
23 window = DownloadManagerWindow("download_manager.xml", "")
24 window.setProperty = MagicMock()
25 window.getControl = MagicMock(return_value=MagicMock())
26 window._monitor = MagicMock()
27 window._monitor.abortRequested.return_value = False
28
29 with patch("lib.gui.download_manager_window.xbmc.sleep") as mock_sleep:
30 call_count = [0]
31
32 def sleep_side_effect(ms):
33 call_count[0] += 1
34 if call_count[0] >= 1:
35 window._closed = True
36
37 mock_sleep.side_effect = sleep_side_effect
38 window.onInit()
39 window._poll_thread.join(timeout=1)
40
41 assert call_count[0] >= 1
42
43 def test_rebuild_list_sets_empty_when_no_entries(self):
44 window = DownloadManagerWindow("download_manager.xml", "")
45 window.setProperty = MagicMock()
46 mock_list = MagicMock()
47 window.getControl = MagicMock(return_value=mock_list)
48
49 window._rebuild_list()
50
51 window.setProperty.assert_any_call("downloads_empty", "true")
52 mock_list.reset.assert_called_once()
53
54 def test_rebuild_list_adds_items_for_entries(self):
55 manager = DownloadManager()
56 manager.register(name="a.mkv", dest_path="/dl/a.mkv", url="https://example.com/a.mkv")
57 manager.register(name="b.mkv", dest_path="/dl/b.mkv", url="https://example.com/b.mkv")
58
59 window = DownloadManagerWindow("download_manager.xml", "")
60 window.setProperty = MagicMock()
61 mock_list = MagicMock()
62 window.getControl = MagicMock(return_value=mock_list)
63
64 window._rebuild_list()
65
66 assert mock_list.addItem.call_count == 2
67 window.setProperty.assert_any_call("downloads_empty", "false")
68
69 def test_rebuild_list_preserves_focus_position(self):
70 manager = DownloadManager()
71 manager.register(name="a.mkv", dest_path="/dl/a.mkv", url="https://example.com/a.mkv")
72 manager.register(name="b.mkv", dest_path="/dl/b.mkv", url="https://example.com/b.mkv")
73
74 window = DownloadManagerWindow("download_manager.xml", "")
75 window.setProperty = MagicMock()
76 mock_list = MagicMock()
77 mock_list.getSelectedPosition.return_value = 1
78 mock_list.size.return_value = 2
79 window.getControl = MagicMock(return_value=mock_list)
80
81 window._rebuild_list()
82
83 mock_list.reset.assert_called_once()
84 assert mock_list.addItem.call_count == 2
85 mock_list.selectItem.assert_called_once_with(1)
86
87 def test_rebuild_list_does_not_select_out_of_range(self):
88 manager = DownloadManager()
89 manager.register(name="a.mkv", dest_path="/dl/a.mkv", url="https://example.com/a.mkv")
90
91 window = DownloadManagerWindow("download_manager.xml", "")
92 window.setProperty = MagicMock()
93 mock_list = MagicMock()
94 mock_list.getSelectedPosition.return_value = 5
95 mock_list.size.return_value = 1
96 window.getControl = MagicMock(return_value=mock_list)
97
98 window._rebuild_list()
99
100 mock_list.selectItem.assert_not_called()
101
102 def test_on_click_pause_sets_cancel_flag(self):
103 manager = DownloadManager()
104 manager.register(name="a.mkv", dest_path="/dl/a.mkv", url="https://example.com/a.mkv")
105
106 window = DownloadManagerWindow("download_manager.xml", "")
107 mock_list = MagicMock()
108 mock_list.getSelectedItem.return_value.getProperty.return_value = "/dl/a.mkv"
109 window.getControl = MagicMock(return_value=mock_list)
110 window._rebuild_list = MagicMock()
111
112 window.onClick(14003) # Pause
113
114 entry = manager.get_entry("/dl/a.mkv")
115 assert entry.cancel_flag is True
116
117 def test_on_click_pause_passes_refresh_false(self):
118 manager = DownloadManager()
119 manager.register(name="a.mkv", dest_path="/dl/a.mkv", url="https://example.com/a.mkv")
120
121 window = DownloadManagerWindow("download_manager.xml", "")
122 mock_list = MagicMock()
123 mock_list.getSelectedItem.return_value.getProperty.return_value = "/dl/a.mkv"
124 window.getControl = MagicMock(return_value=mock_list)
125 window._rebuild_list = MagicMock()
126
127 with patch("lib.gui.download_manager_window.handle_pause_download") as mock_handle_pause:
128 window.onClick(14003) # Pause
129 mock_handle_pause.assert_called_once()
130 assert mock_handle_pause.call_args.kwargs.get("refresh") is False
131
132 def test_on_click_resume_starts_download(self):
133 manager = DownloadManager()
134 manager.register(name="a.mkv", dest_path="/dl/a.mkv", url="https://example.com/a.mkv")
135 manager.set_status("/dl/a.mkv", "paused")
136
137 window = DownloadManagerWindow("download_manager.xml", "")
138 mock_list = MagicMock()
139 mock_list.getSelectedItem.return_value.getProperty.return_value = "/dl/a.mkv"
140 window.getControl = MagicMock(return_value=mock_list)
141 window._rebuild_list = MagicMock()
142
143 with patch("lib.gui.download_manager_window.Downloader") as mock_dl:
144 window.onClick(14004) # Resume
145 mock_dl.assert_called_once()
146 mock_dl.return_value.run.assert_called_once()
147
148 def test_on_click_cancel_sets_status_cancelled(self):
149 manager = DownloadManager()
150 manager.register(name="a.mkv", dest_path="/dl/a.mkv", url="https://example.com/a.mkv")
151
152 window = DownloadManagerWindow("download_manager.xml", "")
153 mock_list = MagicMock()
154 mock_list.getSelectedItem.return_value.getProperty.return_value = "/dl/a.mkv"
155 window.getControl = MagicMock(return_value=mock_list)
156 window._rebuild_list = MagicMock()
157
158 window.onClick(14005) # Cancel
159
160 entry = manager.get_entry("/dl/a.mkv")
161 assert entry.status == "cancelled"
162
163 def test_on_click_delete_removes_entry(self):
164 manager = DownloadManager()
165 manager.register(name="a.mkv", dest_path="/dl/a.mkv", url="https://example.com/a.mkv")
166
167 window = DownloadManagerWindow("download_manager.xml", "")
168 mock_list = MagicMock()
169 mock_list.getSelectedItem.return_value.getProperty.return_value = "/dl/a.mkv"
170 window.getControl = MagicMock(return_value=mock_list)
171 window._rebuild_list = MagicMock()
172
173 with patch("lib.gui.download_manager_window.xbmcvfs"):
174 window.onClick(14006) # Delete
175 assert manager.get_entry("/dl/a.mkv") is None
176
177 def test_on_click_clear_completed_removes_only_completed(self):
178 manager = DownloadManager()
179 manager.register(name="a.mkv", dest_path="/dl/a.mkv", url="https://example.com/a.mkv")
180 manager.register(name="b.mkv", dest_path="/dl/b.mkv", url="https://example.com/b.mkv")
181 manager.set_status("/dl/b.mkv", "completed")
182
183 window = DownloadManagerWindow("download_manager.xml", "")
184 window._rebuild_list = MagicMock()
185
186 window.onClick(14007) # Clear Completed
187
188 assert manager.get_entry("/dl/a.mkv") is not None
189 assert manager.get_entry("/dl/b.mkv") is None
190
191 def test_poll_loop_updates_selected_properties(self):
192 manager = DownloadManager()
193 manager.register(name="a.mkv", dest_path="/dl/a.mkv", url="https://example.com/a.mkv")
194 manager.update_progress(
195 "/dl/a.mkv", downloaded=50, speed=1000, eta=60, progress=50, size=100
196 )
197
198 window = DownloadManagerWindow("download_manager.xml", "")
199 window.setProperty = MagicMock()
200 mock_selected_item = MagicMock()
201 mock_selected_item.getProperty.return_value = "/dl/a.mkv"
202 mock_list = MagicMock()
203 mock_list.getSelectedItem.return_value = mock_selected_item
204 window.getControl = MagicMock(return_value=mock_list)
205 window._focused_item_id = "/dl/a.mkv"
206 window._monitor = MagicMock()
207 window._monitor.abortRequested.return_value = False
208
209 with patch("lib.gui.download_manager_window.xbmc.sleep") as mock_sleep:
210 call_count = [0]
211
212 def sleep_side_effect(ms):
213 call_count[0] += 1
214 if call_count[0] >= 1:
215 window._closed = True
216
217 mock_sleep.side_effect = sleep_side_effect
218 window._poll_loop()
219
220 window.setProperty.assert_any_call("selected_progress", "50")
221 window.setProperty.assert_any_call("selected_speed", "1e+03 B/s")
222 window.setProperty.assert_any_call("selected_eta", "1m 0s")
223 window.setProperty.assert_any_call("selected_size", "100 B")
224
225 def test_on_action_sets_closed_flag(self):
226 window = DownloadManagerWindow("download_manager.xml", "")
227 window._closed = False
228 mock_action = MagicMock()
229 mock_action.getId.return_value = 10 # ACTION_PREVIOUS_MENU
230
231 window.onAction(mock_action)
232
233 assert window._closed is True
234
235 def test_sync_from_disk_registers_existing_downloads(self):
236 """_sync_from_disk should register downloads found in .jacktook.json files."""
237 DownloadManager().clear()
238 window = DownloadManagerWindow("download_manager.xml", "")
239 window.setProperty = MagicMock()
240
241 meta = {
242 "title": "Completed Movie",
243 "status": "completed",
244 "progress": 100,
245 "url": "https://example.com/movie.mkv",
246 }
247 with patch("lib.gui.download_manager_window.xbmcvfs") as mock_vfs, patch(
248 "lib.gui.download_manager_window.get_download_metadata", return_value=meta
249 ), patch("lib.gui.download_manager_window._get_setting", return_value="/dl"), patch(
250 "lib.gui.download_manager_window._translatePath", side_effect=lambda x: x
251 ):
252 mock_vfs.exists.return_value = True
253 mock_vfs.listdir.return_value = ([], ["movie.mkv.jacktook.json"])
254 window._sync_from_disk()
255
256 manager = DownloadManager()
257 entries = manager.list_entries()
258 assert len(entries) == 1
259 assert entries[0].status == "completed"
260 assert entries[0].progress == 100
261
262 def test_sync_from_disk_skips_cancelled_downloads(self):
263 """_sync_from_disk should skip entries with cancelled status."""
264 DownloadManager().clear()
265 window = DownloadManagerWindow("download_manager.xml", "")
266 window.setProperty = MagicMock()
267
268 meta = {
269 "title": "Cancelled File",
270 "status": "cancelled",
271 "progress": 30,
272 "url": "https://example.com/cancel.mkv",
273 }
274 with patch("lib.gui.download_manager_window.xbmcvfs") as mock_vfs, patch(
275 "lib.gui.download_manager_window.get_download_metadata", return_value=meta
276 ), patch("lib.gui.download_manager_window._get_setting", return_value="/dl"), patch(
277 "lib.gui.download_manager_window._translatePath", side_effect=lambda x: x
278 ):
279 mock_vfs.exists.return_value = True
280 mock_vfs.listdir.return_value = ([], ["cancel.mkv.jacktook.json"])
281 window._sync_from_disk()
282
283 manager = DownloadManager()
284 assert len(manager.list_entries()) == 0
285
286 def test_sync_from_disk_skips_already_registered(self):
287 """_sync_from_disk should not re-register entries already in the registry."""
288 DownloadManager().clear()
289 download_dir = "/dl"
290 manager = DownloadManager()
291 # Pre-register an entry at the path that _sync_from_disk would compute
292 existing_dest = f"{download_dir}/existing.mkv"
293 manager.register(
294 name="Existing",
295 dest_path=existing_dest,
296 url="https://example.com/existing.mkv",
297 )
298
299 window = DownloadManagerWindow("download_manager.xml", "")
300 window.setProperty = MagicMock()
301
302 meta = {
303 "title": "Existing",
304 "status": "completed",
305 "progress": 100,
306 "url": "https://example.com/existing.mkv",
307 }
308 with patch("lib.gui.download_manager_window.xbmcvfs") as mock_vfs, patch(
309 "lib.gui.download_manager_window.get_download_metadata", return_value=meta
310 ), patch("lib.gui.download_manager_window._get_setting", return_value=download_dir), patch(
311 "lib.gui.download_manager_window._translatePath", side_effect=lambda x: x
312 ):
313 mock_vfs.exists.return_value = True
314 mock_vfs.listdir.return_value = ([], ["existing.mkv.jacktook.json"])
315 window._sync_from_disk()
316
317 # Should still be only 1 entry, not duplicated
318 assert len(manager.list_entries()) == 1
plugin.video.jacktook/tests/unit/test_downloader.py +627 -2
……@@ -1,5 +1,8 @@
11import importlib
2import json
3import os
24import sys
5import tempfile
36from unittest.mock import MagicMock, patch
47
58
……@@ -12,7 +15,9 @@ def _load_downloader_module():
1215def test_normalize_file_name_preserves_valid_video_extension():
1316 downloader = _load_downloader_module()
1417
15 assert downloader.normalize_file_name("Movie.mp4", "https://example.com/Movie.mkv") == "Movie.mp4"
18 assert (
19 downloader.normalize_file_name("Movie.mp4", "https://example.com/Movie.mkv") == "Movie.mp4"
20 )
1621
1722
1823def test_normalize_file_name_uses_url_extension_when_title_has_none():
……@@ -49,7 +54,12 @@ def test_handle_download_file_passes_url_to_normalizer():
4954 downloader, "normalize_file_name", return_value="Movie.mkv"
5055 ) as normalize_file_name, patch.object(
5156 downloader, "Downloader", return_value=downloader_instance
52 ) as downloader_cls, patch.object(downloader.cancel_flag_cache, "set") as cache_set:
57 ) as downloader_cls, patch.object(
58 downloader, "DownloadManager"
59 ) as mock_manager_cls, patch.object(downloader.cancel_flag_cache, "set") as cache_set:
60 mock_manager = MagicMock()
61 mock_manager.register.return_value = MagicMock()
62 mock_manager_cls.return_value = mock_manager
5363 downloader.handle_download_file(
5464 {
5565 "destination": "/downloads",
……@@ -63,6 +73,621 @@ def test_handle_download_file_passes_url_to_normalizer():
6373 url="https://example.com/Movie.mkv",
6474 destination="/downloads",
6575 name="Movie.mkv",
76 registry_id="/downloads/Movie.mkv",
6677 )
6778 cache_set.assert_called_once_with("/downloads/Movie.mkv", False)
6879 downloader_instance.run.assert_called_once_with()
80
81
82# --- Tests for get_destination_path ---
83
84
85def test_get_destination_path_flat_when_disabled():
86 downloader = _load_downloader_module()
87
88 with patch.object(
89 downloader,
90 "get_setting",
91 side_effect=lambda key, default=False: False if key == "organize_downloads" else default,
92 ), patch.object(downloader, "translatePath", return_value="/downloads"), patch.object(
93 downloader.xbmcvfs, "mkdirs"
94 ) as mock_mkdirs:
95 result = downloader.get_destination_path({"title": "Movie", "mode": "movies"})
96
97 assert result == "/downloads"
98 mock_mkdirs.assert_not_called()
99
100
101def test_get_destination_path_movies_when_enabled():
102 downloader = _load_downloader_module()
103
104 with patch.object(
105 downloader,
106 "get_setting",
107 side_effect=lambda key, default="": {
108 "organize_downloads": True,
109 "download_dir": "/downloads",
110 "download_folder_movies": "Movies",
111 "download_folder_tvshows": "TV Shows",
112 }.get(key, default),
113 ), patch.object(downloader, "translatePath", return_value="/downloads"), patch.object(
114 downloader.xbmcvfs, "mkdirs"
115 ) as mock_mkdirs:
116 result = downloader.get_destination_path({"title": "Movie", "mode": "movies"})
117
118 assert result == "/downloads/Movies"
119 mock_mkdirs.assert_called_once_with("/downloads/Movies")
120
121
122def test_get_destination_path_tv_when_enabled():
123 downloader = _load_downloader_module()
124
125 with patch.object(
126 downloader,
127 "get_setting",
128 side_effect=lambda key, default="": {
129 "organize_downloads": True,
130 "download_dir": "/downloads",
131 "download_folder_movies": "Movies",
132 "download_folder_tvshows": "TV Shows",
133 }.get(key, default),
134 ), patch.object(downloader, "translatePath", return_value="/downloads"), patch.object(
135 downloader.xbmcvfs, "mkdirs"
136 ) as mock_mkdirs:
137 result = downloader.get_destination_path(
138 {
139 "title": "Episode",
140 "mode": "tv",
141 "tv_data": {"name": "Breaking Bad", "season": 2},
142 }
143 )
144
145 assert result == "/downloads/TV Shows/Breaking Bad/Season 02"
146 mock_mkdirs.assert_called_once_with("/downloads/TV Shows/Breaking Bad/Season 02")
147
148
149def test_get_destination_path_fallback_to_title_for_show_name():
150 downloader = _load_downloader_module()
151
152 with patch.object(
153 downloader,
154 "get_setting",
155 side_effect=lambda key, default="": {
156 "organize_downloads": True,
157 "download_dir": "/downloads",
158 "download_folder_movies": "Movies",
159 "download_folder_tvshows": "TV Shows",
160 }.get(key, default),
161 ), patch.object(downloader, "translatePath", return_value="/downloads"), patch.object(
162 downloader.xbmcvfs, "mkdirs"
163 ):
164 result = downloader.get_destination_path(
165 {
166 "title": "Some Show",
167 "mode": "tv",
168 "tv_data": {"season": 1},
169 }
170 )
171
172 assert result == "/downloads/TV Shows/Some Show/Season 01"
173
174
175# --- Tests for get_download_metadata ---
176
177
178def test_get_download_metadata_returns_defaults_when_missing():
179 downloader = _load_downloader_module()
180
181 with tempfile.TemporaryDirectory() as tmpdir:
182 path = os.path.join(tmpdir, "video.mkv")
183 result = downloader.get_download_metadata(path)
184
185 assert result == {"status": "unknown", "progress": 0, "title": ""}
186
187
188def test_get_download_metadata_reads_existing_json():
189 downloader = _load_downloader_module()
190
191 with tempfile.TemporaryDirectory() as tmpdir:
192 path = os.path.join(tmpdir, "video.mkv")
193 meta_path = os.path.join(tmpdir, "video.mkv.jacktook.json")
194 with open(meta_path, "w") as f:
195 json.dump({"status": "paused", "progress": 42, "title": "Video"}, f)
196
197 with patch.object(
198 downloader.xbmcvfs, "exists", side_effect=lambda p: os.path.exists(p)
199 ), patch.object(downloader, "open_file", side_effect=lambda p, m="r": open(p, m)):
200 result = downloader.get_download_metadata(path)
201
202 assert result == {"status": "paused", "progress": 42, "title": "Video"}
203
204
205# --- Tests for handle_pause_download ---
206
207
208def test_handle_pause_download_sets_cancel_flag_and_metadata():
209 downloader = _load_downloader_module()
210
211 with tempfile.TemporaryDirectory() as tmpdir:
212 path = os.path.join(tmpdir, "video.mkv")
213 meta_path = os.path.join(tmpdir, "video.mkv.jacktook.json")
214 with open(meta_path, "w") as f:
215 json.dump({"status": "downloading", "progress": 50, "title": "Video"}, f)
216
217 with patch.object(downloader.cancel_flag_cache, "set") as cache_set, patch.object(
218 downloader.xbmcvfs, "exists", side_effect=lambda p: os.path.exists(p)
219 ), patch.object(downloader, "open_file", side_effect=lambda p, m="r": open(p, m)):
220 downloader.handle_pause_download({"file_path": json.dumps(path)})
221
222 cache_set.assert_called_once_with(path, True)
223 with open(meta_path) as f:
224 meta = json.load(f)
225 assert meta["status"] == "paused"
226
227
228# --- Tests for resume_download ---
229
230
231def test_resume_download_clears_flag_and_starts_downloader():
232 downloader = _load_downloader_module()
233
234 with tempfile.TemporaryDirectory() as tmpdir:
235 path = os.path.join(tmpdir, "video.mkv")
236 meta_path = os.path.join(tmpdir, "video.mkv.jacktook.json")
237 open(path, "a").close()
238 with open(meta_path, "w") as f:
239 json.dump(
240 {
241 "status": "paused",
242 "progress": 50,
243 "title": "Video",
244 "url": "https://example.com/video.mkv",
245 },
246 f,
247 )
248
249 downloader_instance = MagicMock()
250 with patch.object(downloader.cancel_flag_cache, "set") as cache_set, patch.object(
251 downloader, "Downloader", return_value=downloader_instance
252 ) as downloader_cls, patch.object(
253 downloader.xbmcvfs, "exists", side_effect=lambda p: os.path.exists(p)
254 ), patch.object(downloader, "open_file", side_effect=lambda p, m="r": open(p, m)):
255 downloader.resume_download({"file_path": json.dumps(path)})
256
257 cache_set.assert_called_once_with(path, False)
258 downloader_cls.assert_called_once_with(
259 url="https://example.com/video.mkv",
260 destination=os.path.dirname(path),
261 name="video.mkv",
262 )
263 downloader_instance.run.assert_called_once_with()
264
265
266# --- Tests for download_video with organization ---
267
268
269def test_get_destination_path_tv_with_custom_folder_names():
270 downloader = _load_downloader_module()
271
272 with patch.object(
273 downloader,
274 "get_setting",
275 side_effect=lambda key, default="": {
276 "organize_downloads": True,
277 "download_dir": "/downloads",
278 "download_folder_movies": "Films",
279 "download_folder_tvshows": "Series",
280 }.get(key, default),
281 ), patch.object(downloader, "translatePath", return_value="/downloads"), patch.object(
282 downloader.xbmcvfs, "mkdirs"
283 ):
284 result = downloader.get_destination_path(
285 {
286 "title": "Episode",
287 "mode": "tv",
288 "tv_data": {"name": "Show", "season": 5},
289 }
290 )
291
292 assert result == "/downloads/Series/Show/Season 05"
293
294
295def test_get_download_metadata_handles_corrupt_json():
296 downloader = _load_downloader_module()
297
298 with tempfile.TemporaryDirectory() as tmpdir:
299 path = os.path.join(tmpdir, "video.mkv")
300 meta_path = os.path.join(tmpdir, "video.mkv.jacktook.json")
301 with open(meta_path, "w") as f:
302 f.write("not json")
303
304 result = downloader.get_download_metadata(path)
305
306 assert result == {"status": "unknown", "progress": 0, "title": ""}
307
308
309def test_download_video_uses_organized_destination():
310 downloader = _load_downloader_module()
311
312 with patch.object(
313 downloader,
314 "get_destination_path",
315 return_value="/downloads/Movies",
316 ) as mock_get_dest, patch.object(
317 downloader, "handle_download_file"
318 ) as mock_handle, patch.object(downloader, "normalize_file_name", return_value="Movie.mkv"):
319 data = {
320 "title": "Movie",
321 "mode": "movies",
322 "url": "https://example.com/Movie.mkv",
323 }
324 downloader.download_video({"data": json.dumps(data)})
325
326 mock_get_dest.assert_called_once_with(data)
327 mock_handle.assert_called_once_with(
328 {
329 "destination": "/downloads/Movies",
330 "file_name": "Movie",
331 "url": "https://example.com/Movie.mkv",
332 }
333 )
334
335
336# --- Tests for Downloader registry integration ---
337
338
339def test_downloader_accepts_registry_id():
340 downloader = _load_downloader_module()
341
342 dl = downloader.Downloader(
343 url="https://example.com/Movie.mkv",
344 destination="/downloads",
345 name="Movie.mkv",
346 registry_id="/downloads/Movie.mkv",
347 )
348 assert dl.registry_id == "/downloads/Movie.mkv"
349
350
351def test_downloader_registry_id_defaults_to_none():
352 downloader = _load_downloader_module()
353
354 dl = downloader.Downloader(
355 url="https://example.com/Movie.mkv",
356 destination="/downloads",
357 name="Movie.mkv",
358 )
359 assert dl.registry_id is None
360
361
362def test_start_download_updates_registry_per_chunk():
363 downloader = _load_downloader_module()
364
365 from lib.download_manager import DownloadManager
366
367 manager = DownloadManager()
368 manager.clear()
369 manager.register(
370 name="Movie.mkv",
371 dest_path="/downloads/Movie.mkv",
372 url="https://example.com/Movie.mkv",
373 )
374
375 dl = downloader.Downloader(
376 url="https://example.com/Movie.mkv",
377 destination="/downloads",
378 name="Movie.mkv",
379 registry_id="/downloads/Movie.mkv",
380 )
381 dl.file_size = 3 * 1024 * 1024 # 3 MB
382
383 mock_response = MagicMock()
384 mock_response.read.side_effect = [
385 b"x" * (1024 * 1024),
386 b"x" * (1024 * 1024),
387 b"x" * (1024 * 1024),
388 b"",
389 ]
390 mock_response.headers = {"Content-Length": str(3 * 1024 * 1024)}
391
392 with tempfile.TemporaryDirectory() as tmpdir:
393 dl.destination = tmpdir
394 dl.dest_path = os.path.join(tmpdir, "Movie.mkv")
395 dl.temp_path = dl.dest_path + ".part"
396 dl.meta_path = dl.dest_path + ".jacktook.json"
397
398 with patch.object(downloader, "KodiProgressHandler") as mock_handler_cls, patch.object(
399 downloader, "urlopen", return_value=mock_response
400 ), patch.object(downloader.xbmcvfs, "exists", return_value=False), patch.object(
401 downloader.xbmcvfs, "rename"
402 ):
403 mock_handler = MagicMock()
404 mock_handler_cls.return_value = mock_handler
405 mock_handler.cancelled.return_value = False
406 dl.monitor = MagicMock()
407 dl.monitor.abortRequested.return_value = False
408 dl._start_download()
409
410 entry = manager.get_entry("/downloads/Movie.mkv")
411 assert entry is not None
412 assert entry.progress == 100
413 assert entry.downloaded == 3 * 1024 * 1024
414 assert entry.status == "completed"
415
416
417def test_start_download_persists_live_metrics_to_metadata_when_paused():
418 downloader = _load_downloader_module()
419
420 dl = downloader.Downloader(
421 url="https://example.com/Movie.mkv",
422 destination="/downloads",
423 name="Movie.mkv",
424 registry_id="/downloads/Movie.mkv",
425 )
426 dl.file_size = 3 * 1024 * 1024
427
428 mock_response = MagicMock()
429 mock_response.read.side_effect = [b"x" * (1024 * 1024), b""]
430
431 with tempfile.TemporaryDirectory() as tmpdir:
432 dl.destination = tmpdir
433 dl.dest_path = os.path.join(tmpdir, "Movie.mkv")
434 dl.temp_path = dl.dest_path + ".part"
435 dl.meta_path = dl.dest_path + ".jacktook.json"
436
437 with patch.object(downloader, "KodiProgressHandler") as mock_handler_cls, patch.object(
438 downloader, "urlopen", return_value=mock_response
439 ), patch.object(downloader.xbmcvfs, "exists", return_value=False), patch.object(
440 downloader.xbmcvfs, "rename"
441 ), patch.object(
442 downloader, "open_file", side_effect=lambda p, m="r": open(p, m)
443 ), patch.object(downloader.time, "time", side_effect=[0, 1, 1]):
444 mock_handler = MagicMock()
445 mock_handler_cls.return_value = mock_handler
446 mock_handler.cancelled.return_value = True
447 dl.monitor = MagicMock()
448 dl.monitor.abortRequested.return_value = False
449 dl._start_download()
450
451 with open(dl.meta_path) as f:
452 meta = json.load(f)
453
454 assert meta["status"] == "paused"
455 assert meta["progress"] == 33
456 assert meta["size"] == 3 * 1024 * 1024
457 assert meta["downloaded"] == 1024 * 1024
458 assert meta["speed"] == 1024 * 1024
459 assert meta["eta"] == 2
460
461
462def test_start_download_sets_registry_status_paused_on_cancel():
463 downloader = _load_downloader_module()
464
465 from lib.download_manager import DownloadManager
466
467 manager = DownloadManager()
468 manager.clear()
469 manager.register(
470 name="Movie.mkv",
471 dest_path="/downloads/Movie.mkv",
472 url="https://example.com/Movie.mkv",
473 )
474
475 dl = downloader.Downloader(
476 url="https://example.com/Movie.mkv",
477 destination="/downloads",
478 name="Movie.mkv",
479 registry_id="/downloads/Movie.mkv",
480 )
481 dl.file_size = 10 * 1024 * 1024 # 10 MB
482
483 mock_response = MagicMock()
484 mock_response.read.side_effect = [b"x" * (1024 * 1024), b"x" * (1024 * 1024), b""]
485 mock_response.headers = {"Content-Length": str(10 * 1024 * 1024)}
486
487 with tempfile.TemporaryDirectory() as tmpdir:
488 dl.destination = tmpdir
489 dl.dest_path = os.path.join(tmpdir, "Movie.mkv")
490 dl.temp_path = dl.dest_path + ".part"
491 dl.meta_path = dl.dest_path + ".jacktook.json"
492
493 with patch.object(downloader, "KodiProgressHandler") as mock_handler_cls, patch.object(
494 downloader, "urlopen", return_value=mock_response
495 ), patch.object(downloader.xbmcvfs, "exists", return_value=False), patch.object(
496 downloader.xbmcvfs, "rename"
497 ):
498 mock_handler = MagicMock()
499 mock_handler_cls.return_value = mock_handler
500 # Cancel after first chunk
501 call_count = [0]
502
503 def cancelled_side_effect():
504 call_count[0] += 1
505 return call_count[0] > 1
506
507 mock_handler.cancelled.side_effect = cancelled_side_effect
508 dl.monitor = MagicMock()
509 dl.monitor.abortRequested.return_value = False
510 dl._start_download()
511
512 entry = manager.get_entry("/downloads/Movie.mkv")
513 assert entry is not None
514 assert entry.status == "paused"
515
516
517def test_start_download_respects_registry_cancel_flag():
518 downloader = _load_downloader_module()
519
520 from lib.download_manager import DownloadManager
521
522 manager = DownloadManager()
523 manager.clear()
524 entry = manager.register(
525 name="Movie.mkv",
526 dest_path="/downloads/Movie.mkv",
527 url="https://example.com/Movie.mkv",
528 )
529 entry.cancel_flag = True
530
531 dl = downloader.Downloader(
532 url="https://example.com/Movie.mkv",
533 destination="/downloads",
534 name="Movie.mkv",
535 registry_id="/downloads/Movie.mkv",
536 )
537 dl.file_size = 10 * 1024 * 1024
538
539 mock_response = MagicMock()
540 mock_response.read.side_effect = [b"x" * (1024 * 1024), b""]
541 mock_response.headers = {"Content-Length": str(10 * 1024 * 1024)}
542
543 with tempfile.TemporaryDirectory() as tmpdir:
544 dl.destination = tmpdir
545 dl.dest_path = os.path.join(tmpdir, "Movie.mkv")
546 dl.temp_path = dl.dest_path + ".part"
547 dl.meta_path = dl.dest_path + ".jacktook.json"
548
549 with patch.object(downloader, "KodiProgressHandler") as mock_handler_cls, patch.object(
550 downloader, "urlopen", return_value=mock_response
551 ), patch.object(downloader.xbmcvfs, "exists", return_value=False), patch.object(
552 downloader.xbmcvfs, "rename"
553 ):
554 mock_handler = MagicMock()
555 mock_handler_cls.return_value = mock_handler
556 mock_handler.cancelled.return_value = False
557 dl.monitor = MagicMock()
558 dl.monitor.abortRequested.return_value = False
559 dl._start_download()
560
561 entry = manager.get_entry("/downloads/Movie.mkv")
562 assert entry is not None
563 assert entry.status == "paused"
564
565
566def test_start_download_sets_registry_status_error_on_exception():
567 downloader = _load_downloader_module()
568
569 from lib.download_manager import DownloadManager
570
571 manager = DownloadManager()
572 manager.clear()
573 manager.register(
574 name="Movie.mkv",
575 dest_path="/downloads/Movie.mkv",
576 url="https://example.com/Movie.mkv",
577 )
578
579 dl = downloader.Downloader(
580 url="https://example.com/Movie.mkv",
581 destination="/downloads",
582 name="Movie.mkv",
583 registry_id="/downloads/Movie.mkv",
584 )
585 dl.file_size = 10 * 1024 * 1024
586
587 with tempfile.TemporaryDirectory() as tmpdir:
588 dl.destination = tmpdir
589 dl.dest_path = os.path.join(tmpdir, "Movie.mkv")
590 dl.temp_path = dl.dest_path + ".part"
591 dl.meta_path = dl.dest_path + ".jacktook.json"
592
593 with patch.object(downloader, "KodiProgressHandler") as mock_handler_cls, patch.object(
594 downloader, "urlopen", side_effect=Exception("network error")
595 ), patch.object(downloader.xbmcvfs, "exists", return_value=False):
596 mock_handler = MagicMock()
597 mock_handler_cls.return_value = mock_handler
598 dl._start_download()
599
600 entry = manager.get_entry("/downloads/Movie.mkv")
601 assert entry is not None
602 assert entry.status == "error"
603
604
605def test_update_registry_calculates_speed_and_eta():
606 downloader = _load_downloader_module()
607
608 from lib.download_manager import DownloadManager
609
610 manager = DownloadManager()
611 manager.clear()
612 manager.register(
613 name="Movie.mkv",
614 dest_path="/downloads/Movie.mkv",
615 url="https://example.com/Movie.mkv",
616 )
617
618 dl = downloader.Downloader(
619 url="https://example.com/Movie.mkv",
620 destination="/downloads",
621 name="Movie.mkv",
622 registry_id="/downloads/Movie.mkv",
623 )
624 dl.file_size = 10_000_000
625 dl._start_time = 0
626
627 with patch.object(downloader, "time") as mock_time:
628 mock_time.time.return_value = 5
629 dl._update_registry(downloaded=5_000_000, percent=50)
630
631 entry = manager.get_entry("/downloads/Movie.mkv")
632 assert entry.speed == 1_000_000
633 assert entry.eta == 5
634
635
636def test_update_registry_speed_zero_when_no_start_time():
637 downloader = _load_downloader_module()
638
639 from lib.download_manager import DownloadManager
640
641 manager = DownloadManager()
642 manager.clear()
643 manager.register(
644 name="Movie.mkv",
645 dest_path="/downloads/Movie.mkv",
646 url="https://example.com/Movie.mkv",
647 )
648
649 dl = downloader.Downloader(
650 url="https://example.com/Movie.mkv",
651 destination="/downloads",
652 name="Movie.mkv",
653 registry_id="/downloads/Movie.mkv",
654 )
655 dl.file_size = 10_000_000
656 dl._start_time = None
657
658 dl._update_registry(downloaded=5_000_000, percent=50)
659
660 entry = manager.get_entry("/downloads/Movie.mkv")
661 assert entry.speed == 0
662 assert entry.eta == 0
663
664
665def test_update_registry_eta_zero_when_download_complete():
666 downloader = _load_downloader_module()
667
668 from lib.download_manager import DownloadManager
669
670 manager = DownloadManager()
671 manager.clear()
672 manager.register(
673 name="Movie.mkv",
674 dest_path="/downloads/Movie.mkv",
675 url="https://example.com/Movie.mkv",
676 )
677
678 dl = downloader.Downloader(
679 url="https://example.com/Movie.mkv",
680 destination="/downloads",
681 name="Movie.mkv",
682 registry_id="/downloads/Movie.mkv",
683 )
684 dl.file_size = 10_000_000
685 dl._start_time = 0
686
687 with patch.object(downloader, "time") as mock_time:
688 mock_time.time.return_value = 5
689 dl._update_registry(downloaded=10_000_000, percent=100)
690
691 entry = manager.get_entry("/downloads/Movie.mkv")
692 assert entry.speed == 2_000_000
693 assert entry.eta == 0
plugin.video.jacktook/tests/unit/test_excluded_languages.py +84 -0
……@@ -0,0 +1,84 @@
1from unittest.mock import MagicMock, patch
2
3from lib.clients.tmdb.utils.utils import (
4 filter_excluded_languages,
5 get_excluded_languages,
6)
7
8
9class TestGetExcludedLanguages:
10 def test_returns_empty_list_by_default(self):
11 with patch("lib.clients.tmdb.utils.utils.get_setting", return_value="[]"):
12 assert get_excluded_languages() == []
13
14 def test_returns_parsed_json(self):
15 with patch("lib.clients.tmdb.utils.utils.get_setting", return_value='["ja", "ko"]'):
16 assert get_excluded_languages() == ["ja", "ko"]
17
18 def test_returns_empty_on_invalid_json(self):
19 with patch("lib.clients.tmdb.utils.utils.get_setting", return_value="not-json"):
20 assert get_excluded_languages() == []
21
22
23class TestFilterExcludedLanguages:
24 def test_returns_all_when_none_excluded(self):
25 results = [
26 MagicMock(original_language="en"),
27 MagicMock(original_language="ja"),
28 ]
29 with patch("lib.clients.tmdb.utils.utils.get_excluded_languages", return_value=[]):
30 filtered = filter_excluded_languages(results)
31 assert len(filtered) == 2
32
33 def test_filters_excluded_languages(self):
34 results = [
35 MagicMock(original_language="en"),
36 MagicMock(original_language="ja"),
37 MagicMock(original_language="ko"),
38 ]
39 with patch(
40 "lib.clients.tmdb.utils.utils.get_excluded_languages",
41 return_value=["ja", "ko"],
42 ):
43 filtered = filter_excluded_languages(results)
44 assert len(filtered) == 1
45 assert filtered[0].original_language == "en"
46
47 def test_allows_force_allow_lang(self):
48 results = [
49 MagicMock(original_language="en"),
50 MagicMock(original_language="ja"),
51 ]
52 with patch(
53 "lib.clients.tmdb.utils.utils.get_excluded_languages",
54 return_value=["ja"],
55 ):
56 filtered = filter_excluded_languages(results, force_allow_lang="ja")
57 assert len(filtered) == 2
58 assert filtered[0].original_language == "en"
59 assert filtered[1].original_language == "ja"
60
61 def test_handles_dict_results(self):
62 results = [
63 {"original_language": "en"},
64 {"original_language": "ja"},
65 ]
66 with patch(
67 "lib.clients.tmdb.utils.utils.get_excluded_languages",
68 return_value=["ja"],
69 ):
70 filtered = filter_excluded_languages(results)
71 assert len(filtered) == 1
72 assert filtered[0]["original_language"] == "en"
73
74 def test_handles_none_original_language(self):
75 results = [
76 MagicMock(original_language="en"),
77 MagicMock(original_language=None),
78 ]
79 with patch(
80 "lib.clients.tmdb.utils.utils.get_excluded_languages",
81 return_value=["ja"],
82 ):
83 filtered = filter_excluded_languages(results)
84 assert len(filtered) == 2
plugin.video.jacktook/tests/unit/test_external_scraper.py +480 -0
……@@ -0,0 +1,480 @@
1"""Unit tests for the external scraper client integration.
2
3These tests verify the data-mapping, provider-filtering and result-parsing
4logic without requiring the ``script.module.magneto`` addon to be installed.
5"""
6
7from unittest.mock import MagicMock, patch
8
9import pytest
10
11from lib.clients.external_scraper import _BYTES_PER_GB, ExternalScraperClient
12from lib.domain.torrent import TorrentStream
13from lib.utils.general.utils import Indexer
14
15# ---------------------------------------------------------------------------
16# Fixtures
17# ---------------------------------------------------------------------------
18
19
20@pytest.fixture
21def external_scraper_client():
22 """Return a ExternalScraperClient whose providers are not loaded yet."""
23 with patch.object(ExternalScraperClient, "_init_providers", return_value=False):
24 client = ExternalScraperClient(
25 "script.module.magneto", "Magneto Module", notification=MagicMock()
26 )
27 return client
28
29
30@pytest.fixture
31def external_scraper_client_with_providers():
32 """Return a ExternalScraperClient with a mocked provider list."""
33 with patch.object(ExternalScraperClient, "_resolve_addon_path", return_value="/fake/magneto"):
34 with patch.object(ExternalScraperClient, "_init_providers", return_value=True):
35 client = ExternalScraperClient(
36 "script.module.magneto", "Magneto Module", notification=MagicMock()
37 )
38 client.initialized = True
39 client._providers = []
40 return client
41
42
43class FakeProvider:
44 """A fake external provider that returns configurable results."""
45
46 hasMovies = True
47 hasEpisodes = True
48 pack_capable = False
49
50 def __init__(self, results=None, pack_results=None):
51 self._results = results or []
52 self._pack_results = pack_results or []
53
54 def sources(self, data, hostDict):
55 return list(self._results)
56
57 def sources_packs(self, data, hostDict):
58 return list(self._pack_results)
59
60
61def make_provider(results=None, pack_results=None, **kwargs):
62 """Return a *class* that produces FakeProvider instances with the given data."""
63
64 class _ConfiguredProvider(FakeProvider):
65 def __init__(self):
66 super().__init__(results=results, pack_results=pack_results)
67
68 for key, value in kwargs.items():
69 setattr(_ConfiguredProvider, key, value)
70 return _ConfiguredProvider
71
72
73# ---------------------------------------------------------------------------
74# _build_data
75# ---------------------------------------------------------------------------
76
77
78class TestBuildData:
79 def test_movie_data_dict(self, external_scraper_client):
80 data = external_scraper_client._build_data(
81 query="Inception",
82 mode="movies",
83 season=None,
84 episode=None,
85 imdb_id="tt1375666",
86 year="2010",
87 )
88 assert data["title"] == "Inception"
89 assert data["imdb"] == "tt1375666"
90 assert data["year"] == "2010"
91 assert data["aliases"] == []
92 assert "tvshowtitle" not in data
93 assert "season" not in data
94 assert "episode" not in data
95
96 def test_tv_data_dict(self, external_scraper_client):
97 data = external_scraper_client._build_data(
98 query="Breaking Bad",
99 mode="tv",
100 season=1,
101 episode=1,
102 imdb_id="tt0903747",
103 tvdb_id="81189",
104 year="2008",
105 )
106 assert data["tvshowtitle"] == "Breaking Bad"
107 assert data["title"] == "" # episode name not available
108 assert data["imdb"] == "tt0903747"
109 assert data["tvdb"] == "81189"
110 assert data["year"] == "2008"
111 assert data["season"] == "1"
112 assert data["episode"] == "1"
113 assert "tvshowtitle" in data
114
115 def test_empty_ids_omitted(self, external_scraper_client):
116 data = external_scraper_client._build_data(
117 query="Test", mode="movies", season=None, episode=None
118 )
119 assert "imdb" not in data
120 assert "tvdb" not in data
121 assert data["year"] == ""
122
123 def test_episode_mode_treated_as_tv(self, external_scraper_client):
124 data = external_scraper_client._build_data(
125 query="Show", mode="episode", season=2, episode=5
126 )
127 assert data["tvshowtitle"] == "Show"
128 assert data["season"] == "2"
129 assert data["episode"] == "5"
130
131
132# ---------------------------------------------------------------------------
133# _map_results
134# ---------------------------------------------------------------------------
135
136
137class TestMapResults:
138 def test_basic_movie_result(self, external_scraper_client):
139 scraper_results = [
140 {
141 "name": "Inception 2010 1080p BluRay",
142 "hash": "ABC123DEF456",
143 "url": "",
144 "size": 1.5,
145 "seeders": 100,
146 "quality": "1080p",
147 "provider": "torrentio",
148 }
149 ]
150 mapped = external_scraper_client._map_results(scraper_results, "torrentio")
151
152 assert len(mapped) == 1
153 res = mapped[0]
154 assert isinstance(res, TorrentStream)
155 assert res.title == "Inception 2010 1080p BluRay"
156 assert res.infoHash == "abc123def456" # lower-cased
157 assert res.url == "magnet:?xt=urn:btih:abc123def456"
158 assert res.size == int(1.5 * _BYTES_PER_GB)
159 assert res.seeders == 100
160 assert res.quality == "1080p"
161 assert res.indexer == Indexer.EXTERNAL_SCRAPER
162 assert res.subindexer == "torrentio"
163 assert res.provider == "torrentio"
164 assert res.isPack is False
165
166 def test_result_with_existing_magnet_url(self, external_scraper_client):
167 scraper_results = [
168 {
169 "name": "Movie",
170 "hash": "HASH123",
171 "url": "magnet:?xt=urn:btih:HASH123&dn=Movie",
172 "size": 0,
173 "seeders": 0,
174 "quality": "",
175 }
176 ]
177 mapped = external_scraper_client._map_results(scraper_results, "provider_x")
178 assert mapped[0].url == "magnet:?xt=urn:btih:HASH123&dn=Movie"
179
180 def test_pack_result(self, external_scraper_client):
181 scraper_results = [
182 {
183 "name": "Show S01 COMPLETE",
184 "hash": "PACKHASH",
185 "url": "",
186 "size": 4.0,
187 "seeders": 50,
188 "quality": "1080p",
189 "package": "season",
190 }
191 ]
192 mapped = external_scraper_client._map_results(scraper_results, "torrentio")
193 assert mapped[0].isPack is True
194
195 def test_show_pack_result(self, external_scraper_client):
196 scraper_results = [
197 {
198 "name": "Show COMPLETE SERIES",
199 "hash": "SHOWPACK",
200 "url": "",
201 "size": 20.0,
202 "seeders": 10,
203 "package": "show",
204 }
205 ]
206 mapped = external_scraper_client._map_results(scraper_results, "torrentio")
207 assert mapped[0].isPack is True
208
209 def test_explicit_is_pack_override(self, external_scraper_client):
210 """When is_pack=True is passed, force isPack even without package key."""
211 scraper_results = [{"name": "Pack", "hash": "H", "url": "", "size": 1.0, "seeders": 1}]
212 mapped = external_scraper_client._map_results(scraper_results, "provider", is_pack=True)
213 assert mapped[0].isPack is True
214
215 def test_size_conversion(self, external_scraper_client):
216 scraper_results = [{"name": "Tiny", "hash": "H", "url": "", "size": 0.5, "seeders": 1}]
217 mapped = external_scraper_client._map_results(scraper_results, "p")
218 assert mapped[0].size == int(0.5 * _BYTES_PER_GB)
219
220 def test_zero_size_when_missing(self, external_scraper_client):
221 scraper_results = [{"name": "NoSize", "hash": "H", "url": "", "size": 0, "seeders": 1}]
222 mapped = external_scraper_client._map_results(scraper_results, "p")
223 assert mapped[0].size == 0
224
225 def test_seeders_default_to_zero(self, external_scraper_client):
226 scraper_results = [{"name": "NoSeeders", "hash": "H", "url": "", "size": 1.0}]
227 mapped = external_scraper_client._map_results(scraper_results, "p")
228 assert mapped[0].seeders == 0
229
230 def test_quality_defaults_to_na(self, external_scraper_client):
231 scraper_results = [{"name": "NoQuality", "hash": "H", "url": "", "size": 1.0, "seeders": 1}]
232 mapped = external_scraper_client._map_results(scraper_results, "p")
233 assert mapped[0].quality == "N/A"
234
235 def test_invalid_size_does_not_crash(self, external_scraper_client):
236 scraper_results = [
237 {
238 "name": "BadSize",
239 "hash": "H",
240 "url": "",
241 "size": "not_a_number",
242 "seeders": 1,
243 }
244 ]
245 mapped = external_scraper_client._map_results(scraper_results, "p")
246 assert mapped[0].size == 0
247
248 def test_empty_results_returns_empty_list(self, external_scraper_client):
249 assert external_scraper_client._map_results([], "p") == []
250
251
252# ---------------------------------------------------------------------------
253# Provider filtering
254# ---------------------------------------------------------------------------
255
256
257class TestProviderFiltering:
258 def test_tv_skips_movie_only_providers(self, external_scraper_client_with_providers):
259 client = external_scraper_client_with_providers
260
261 client._providers = [
262 ("movie_only", make_provider(hasEpisodes=False)),
263 (
264 "tv_prov",
265 make_provider(results=[{"name": "Ep1", "hash": "H", "size": 1}]),
266 ),
267 ]
268
269 results = client.search("", "Show", "tv", "tv", season=1, episode=1)
270 assert len(results) == 1
271 assert results[0].title == "Ep1"
272
273 def test_movie_skips_tv_only_providers(self, external_scraper_client_with_providers):
274 client = external_scraper_client_with_providers
275
276 client._providers = [
277 ("tv_only", make_provider(hasMovies=False)),
278 (
279 "movie_prov",
280 make_provider(results=[{"name": "Movie", "hash": "H", "size": 1}]),
281 ),
282 ]
283
284 results = client.search("", "Movie", "movies", "movies", season=0, episode=0)
285 assert len(results) == 1
286 assert results[0].title == "Movie"
287
288 def test_missing_has_flags_defaults_to_included(self, external_scraper_client_with_providers):
289 """Providers without hasMovies/hasEpisodes are not filtered out."""
290 client = external_scraper_client_with_providers
291
292 class NoFlagsProvider:
293 pack_capable = False
294
295 def __init__(self):
296 pass
297
298 def sources(self, data, hostDict):
299 return [{"name": "Generic", "hash": "H", "size": 1}]
300
301 client._providers = [("generic", NoFlagsProvider)]
302
303 results = client.search("", "Title", "movies", "movies", season=0, episode=0)
304 assert len(results) == 1
305
306
307# ---------------------------------------------------------------------------
308# sources_packs integration
309# ---------------------------------------------------------------------------
310
311
312class TestPackIntegration:
313 def test_tv_calls_sources_packs_when_capable(self, external_scraper_client_with_providers):
314 client = external_scraper_client_with_providers
315
316 client._providers = [
317 (
318 "packer",
319 make_provider(
320 results=[{"name": "Ep1", "hash": "E1", "size": 1}],
321 pack_results=[
322 {
323 "name": "S01 Pack",
324 "hash": "S01",
325 "size": 10,
326 "package": "season",
327 }
328 ],
329 pack_capable=True,
330 ),
331 )
332 ]
333
334 results = client.search("", "Show", "tv", "tv", season=1, episode=1)
335 titles = [r.title for r in results]
336 assert "Ep1" in titles
337 assert "S01 Pack" in titles
338 assert any(r.isPack for r in results)
339
340 def test_tv_skips_sources_packs_when_not_capable(self, external_scraper_client_with_providers):
341 client = external_scraper_client_with_providers
342
343 client._providers = [
344 (
345 "non_packer",
346 make_provider(results=[{"name": "Ep1", "hash": "E1", "size": 1}]),
347 )
348 ]
349
350 results = client.search("", "Show", "tv", "tv", season=1, episode=1)
351 assert len(results) == 1
352 assert results[0].title == "Ep1"
353
354 def test_movie_never_calls_sources_packs(self, external_scraper_client_with_providers):
355 client = external_scraper_client_with_providers
356
357 client._providers = [
358 (
359 "packer",
360 make_provider(
361 results=[{"name": "Movie", "hash": "M", "size": 1}],
362 pack_results=[{"name": "ShouldNotAppear", "hash": "X", "size": 1}],
363 pack_capable=True,
364 ),
365 )
366 ]
367
368 results = client.search("", "Movie", "movies", "movies", season=0, episode=0)
369 assert len(results) == 1
370 assert results[0].title == "Movie"
371
372
373# ---------------------------------------------------------------------------
374# Error handling
375# ---------------------------------------------------------------------------
376
377
378class TestErrorHandling:
379 def test_provider_exception_gracefully_ignored(self, external_scraper_client_with_providers):
380 client = external_scraper_client_with_providers
381
382 class ExplodingProvider:
383 hasMovies = True
384 hasEpisodes = True
385 pack_capable = False
386
387 def sources(self, data, hostDict):
388 raise RuntimeError("boom")
389
390 client._providers = [
391 ("boom", ExplodingProvider()),
392 ("good", make_provider(results=[{"name": "Safe", "hash": "S", "size": 1}])),
393 ]
394 results = client.search("", "Title", "movies", "movies", season=0, episode=0)
395 assert len(results) == 1
396 assert results[0].title == "Safe"
397
398 def test_sources_packs_exception_gracefully_ignored(
399 self, external_scraper_client_with_providers
400 ):
401 client = external_scraper_client_with_providers
402
403 class BadPacksProvider(FakeProvider):
404 pack_capable = True
405
406 def sources_packs(self, data, hostDict):
407 raise RuntimeError("packs boom")
408
409 client._providers = [
410 (
411 "bad_packs",
412 make_provider(results=[{"name": "Ep", "hash": "E", "size": 1}]),
413 )
414 ]
415 results = client.search("", "Show", "tv", "tv", season=1, episode=1)
416 assert len(results) == 1 # sources() still works
417 assert results[0].title == "Ep"
418
419 def test_map_results_skips_invalid_items(self, external_scraper_client):
420 """If one item fails to map, the rest are still processed."""
421 results = [
422 {"name": "Good", "hash": "H", "url": "", "size": 1, "seeders": 1},
423 {
424 "name": "Bad",
425 "hash": None,
426 "url": None,
427 "size": "crash",
428 "seeders": None,
429 },
430 {"name": "AlsoGood", "hash": "G", "url": "", "size": 2, "seeders": 2},
431 ]
432 mapped = external_scraper_client._map_results(results, "p")
433 assert len(mapped) == 3 # None hash is handled gracefully
434
435 def test_uninitialized_client_returns_empty(self):
436 with patch.object(ExternalScraperClient, "_init_providers", return_value=False):
437 client = ExternalScraperClient(
438 "script.module.magneto", "Magneto Module", notification=MagicMock()
439 )
440 assert client.search("", "Title", "movies", "movies", season=0, episode=0) == []
441
442
443# ---------------------------------------------------------------------------
444# Addon resolution
445# ---------------------------------------------------------------------------
446
447
448class TestAddonResolution:
449 @patch("xbmcaddon.Addon")
450 def test_resolve_via_xbmcaddon(self, mock_addon_class):
451 mock_addon = MagicMock()
452 mock_addon.getAddonInfo.return_value = "/path/to/magneto"
453 mock_addon_class.return_value = mock_addon
454
455 client = ExternalScraperClient(
456 "script.module.magneto", "Magneto Module", notification=MagicMock()
457 )
458 path = client._resolve_addon_path()
459 assert path == "/path/to/magneto"
460
461 @patch("xbmcaddon.Addon", side_effect=Exception("not installed"))
462 @patch("xbmc.executeJSONRPC")
463 def test_resolve_via_jsonrpc_fallback(self, mock_rpc, mock_addon):
464 mock_rpc.return_value = '{"result": {"addon": {"path": "/rpc/magneto"}}}'
465
466 client = ExternalScraperClient(
467 "script.module.magneto", "Magneto Module", notification=MagicMock()
468 )
469 path = client._resolve_addon_path()
470 assert path == "/rpc/magneto"
471
472 @patch("xbmcaddon.Addon", side_effect=Exception("not installed"))
473 @patch("xbmc.executeJSONRPC", return_value='{"error": {}}')
474 def test_resolve_returns_none_on_failure(self, mock_rpc, mock_addon):
475 client = ExternalScraperClient(
476 "script.module.magneto", "Magneto Module", notification=MagicMock()
477 )
478 path = client._resolve_addon_path()
479 assert path is None
480 assert client.initialized is False
plugin.video.jacktook/tests/unit/test_extras_window.py +3 -1
……@@ -18,7 +18,9 @@ def test_extras_trailer_button_uses_play_trailer_route():
1818 with patch(
1919 "lib.gui.extras_window.build_url",
2020 return_value="plugin://plugin.video.jacktook/?action=play_trailer&tmdb_id=4604&media_type=tv&title=Smallville",
21 ) as build_url_mock, patch.object(window, "close") as close_mock, patch("lib.gui.extras_window.execute_builtin") as execute_builtin:
21 ) as build_url_mock, patch.object(window, "close") as close_mock, patch(
22 "lib.gui.extras_window.execute_builtin"
23 ) as execute_builtin:
2224 window._handle_click(11)
2325
2426 build_url_mock.assert_called_once_with(
plugin.video.jacktook/tests/unit/test_general_utils.py +299 -7
……@@ -1,15 +1,24 @@
1from unittest.mock import MagicMock, patch
2
13import pytest
4
25from lib.utils.general.utils import (
3 is_url,
4 is_magnet_link,
5 info_hash_to_magnet,
6 supported_video_extensions,
6 TMDB_IMAGE_SIZES,
7 build_media_metadata,
78 extract_publish_date,
89 extract_release_group,
9 unicode_flag_to_country_code,
10 is_video,
11 get_random_color,
1210 format_season_episode,
11 get_image_size,
12 get_random_color,
13 get_rpdb_poster,
14 info_hash_to_magnet,
15 is_magnet_link,
16 is_url,
17 is_video,
18 normalize_tv_data,
19 set_listitem_artwork,
20 supported_video_extensions,
21 unicode_flag_to_country_code,
1322)
1423
1524
……@@ -93,3 +102,286 @@ def test_get_random_color():
93102)
94103def test_format_season_episode(season, episode, expected):
95104 assert format_season_episode(season, episode) == expected
105
106
107def test_normalize_tv_data_decodes_episode_name_and_preserves_number_coercion():
108 tv_data = {
109 "name": "The%20Scales%20%26%20the%20Sword",
110 "season": "2",
111 "episode": "3",
112 }
113
114 normalized = normalize_tv_data(tv_data)
115
116 assert normalized["name"] == "The Scales & the Sword"
117 assert normalized["season"] == 2
118 assert normalized["episode"] == 3
119
120
121def test_normalize_tv_data_keeps_decoded_episode_name_stable():
122 normalized = normalize_tv_data(
123 {"name": "The Scales & the Sword", "season": 2, "episode": 3}
124 )
125
126 assert normalized["name"] == "The Scales & the Sword"
127 assert normalized["season"] == 2
128 assert normalized["episode"] == 3
129
130
131class TestGetImageSize:
132 @pytest.mark.parametrize(
133 "setting_value, expected_sizes",
134 [
135 ("0", TMDB_IMAGE_SIZES["low"]),
136 ("1", TMDB_IMAGE_SIZES["medium"]),
137 ("2", TMDB_IMAGE_SIZES["high"]),
138 ("3", TMDB_IMAGE_SIZES["original"]),
139 ],
140 )
141 def test_maps_setting_to_tier(self, setting_value, expected_sizes):
142 with patch("lib.utils.general.utils.get_setting_fresh", return_value=setting_value):
143 for image_type in ("poster", "thumb", "profile", "fanart"):
144 assert get_image_size(image_type) == expected_sizes[image_type]
145
146 def test_invalid_setting_defaults_to_high(self):
147 with patch("lib.utils.general.utils.get_setting_fresh", return_value="99"):
148 assert get_image_size("poster") == TMDB_IMAGE_SIZES["high"]["poster"]
149 assert get_image_size("fanart") == TMDB_IMAGE_SIZES["high"]["fanart"]
150
151 def test_unknown_image_type_returns_empty_string(self):
152 with patch("lib.utils.general.utils.get_setting_fresh", return_value="2"):
153 assert get_image_size("unknown_type") == ""
154
155
156class TestSetListitemArtwork:
157 def _make_mock_item(self):
158 item = MagicMock()
159 item.setArt = MagicMock()
160 return item
161
162 def _extract_url(self, call_args, key):
163 art_dict = call_args[0][0]
164 return art_dict.get(key, "")
165
166 @pytest.mark.parametrize(
167 "setting_value, poster_size, fanart_size",
168 [
169 ("0", "w185", "w300"),
170 ("1", "w342", "w780"),
171 ("2", "w780", "w1280"),
172 ("3", "original", "original"),
173 ],
174 )
175 def test_uses_resolution_tier(self, setting_value, poster_size, fanart_size):
176 item = self._make_mock_item()
177 data = {
178 "poster_path": "/poster.jpg",
179 "backdrop_path": "/backdrop.jpg",
180 }
181 with patch("lib.utils.general.utils.get_setting_fresh", return_value=setting_value):
182 set_listitem_artwork(item, data, {})
183
184 assert item.setArt.called
185 art_call = item.setArt.call_args
186 assert poster_size in self._extract_url(art_call, "poster")
187 assert fanart_size in self._extract_url(art_call, "fanart")
188 assert poster_size in self._extract_url(art_call, "thumb")
189
190 def test_falls_back_when_no_paths(self):
191 item = self._make_mock_item()
192 data = {}
193 with patch("lib.utils.general.utils.get_setting_fresh", return_value="2"):
194 set_listitem_artwork(item, data, {})
195
196 assert item.setArt.called
197 art_call = item.setArt.call_args
198 assert self._extract_url(art_call, "poster") == ""
199 assert self._extract_url(art_call, "fanart") == ""
200
201
202class TestGetRpdbPoster:
203 def test_cache_hit_returns_cached_value(self):
204 cached_url = "https://cdn.ratingposterdb.com/poster.jpg"
205 with patch("lib.utils.general.utils.cache") as mock_cache:
206 mock_cache.get.return_value = cached_url
207 result = get_rpdb_poster("tt1234567", "test_key")
208 assert result == cached_url
209 mock_cache.get.assert_called_once_with("rpdb_poster|tt1234567")
210 mock_cache.set.assert_not_called()
211
212 def test_cache_miss_success_with_poster_field(self):
213 rpdb_url = "https://cdn.ratingposterdb.com/poster.jpg"
214 response = MagicMock()
215 response.status_code = 200
216 response.json.return_value = {"poster": rpdb_url}
217
218 with patch("lib.utils.general.utils.cache") as mock_cache:
219 mock_cache.get.return_value = None
220 with patch("requests.get", return_value=response) as mock_get:
221 result = get_rpdb_poster("tt1234567", "test_key")
222 assert result == rpdb_url
223 mock_get.assert_called_once_with(
224 "https://api.ratingposterdb.com/test_key/imdb/tt1234567?lang=en",
225 timeout=10,
226 )
227 mock_cache.set.assert_called_once()
228 call_args = mock_cache.set.call_args
229 assert call_args[0][0] == "rpdb_poster|tt1234567"
230 assert call_args[0][1] == rpdb_url
231
232 def test_cache_miss_success_with_poster_large_field(self):
233 rpdb_url = "https://cdn.ratingposterdb.com/poster_large.jpg"
234 response = MagicMock()
235 response.status_code = 200
236 response.json.return_value = {"poster_large": rpdb_url}
237
238 with patch("lib.utils.general.utils.cache") as mock_cache:
239 mock_cache.get.return_value = None
240 with patch("requests.get", return_value=response):
241 result = get_rpdb_poster("tt1234567", "test_key")
242 assert result == rpdb_url
243
244 def test_cache_miss_api_error_returns_none(self):
245 import requests as req_mod
246
247 with patch("lib.utils.general.utils.cache") as mock_cache:
248 mock_cache.get.return_value = None
249 with patch(
250 "requests.get",
251 side_effect=req_mod.exceptions.RequestException("timeout"),
252 ):
253 result = get_rpdb_poster("tt1234567", "test_key")
254 assert result is None
255 mock_cache.set.assert_not_called()
256
257 def test_cache_miss_non_200_status_returns_none(self):
258 response = MagicMock()
259 response.status_code = 403
260
261 with patch("lib.utils.general.utils.cache") as mock_cache:
262 mock_cache.get.return_value = None
263 with patch("requests.get", return_value=response):
264 result = get_rpdb_poster("tt1234567", "test_key")
265 assert result is None
266 mock_cache.set.assert_not_called()
267
268 def test_cache_miss_empty_response_returns_none(self):
269 response = MagicMock()
270 response.status_code = 200
271 response.json.return_value = {}
272
273 with patch("lib.utils.general.utils.cache") as mock_cache:
274 mock_cache.get.return_value = None
275 with patch("requests.get", return_value=response):
276 result = get_rpdb_poster("tt1234567", "test_key")
277 assert result is None
278
279
280class TestBuildMediaMetadataRpdb:
281 @staticmethod
282 def _make_tmdb_details(poster_path="/tmdb_poster.jpg"):
283 details = MagicMock()
284 details.poster_path = poster_path
285 details.overview = "Overview"
286 details.title = "Title"
287 details.name = ""
288 details.original_title = "Original Title"
289 details.original_name = ""
290 details.release_date = "2023-01-01"
291 details.runtime = 120
292 details.vote_average = 7.5
293 details.vote_count = 100
294 details.popularity = 50
295 details.backdrop_path = "/backdrop.jpg"
296 return details
297
298 def test_rpdb_enabled_overrides_poster(self):
299 ids = {"tmdb_id": "123", "imdb_id": "tt1234567"}
300 rpdb_url = "https://cdn.ratingposterdb.com/poster.jpg"
301
302 with patch(
303 "lib.utils.general.utils.get_setting_fresh",
304 side_effect=lambda key, default=None: {
305 "rpdb_enabled": True,
306 "rpdb_api_key": "test_key",
307 }.get(key, default),
308 ):
309 with patch("lib.utils.general.utils.get_rpdb_poster", return_value=rpdb_url):
310 with patch(
311 "lib.clients.tmdb.utils.utils.get_tmdb_media_details",
312 return_value=self._make_tmdb_details(),
313 ):
314 with patch(
315 "lib.utils.general.utils.get_fanart_details",
316 return_value={},
317 ):
318 metadata = build_media_metadata(ids, "movies")
319 assert metadata["poster"] == rpdb_url
320
321 def test_rpdb_enabled_fallback_to_tmdb_when_rpdb_none(self):
322 ids = {"tmdb_id": "123", "imdb_id": "tt1234567"}
323
324 with patch(
325 "lib.utils.general.utils.get_setting_fresh",
326 side_effect=lambda key, default=None: {
327 "rpdb_enabled": True,
328 "rpdb_api_key": "test_key",
329 }.get(key, default),
330 ):
331 with patch("lib.utils.general.utils.get_rpdb_poster", return_value=None):
332 with patch(
333 "lib.clients.tmdb.utils.utils.get_tmdb_media_details",
334 return_value=self._make_tmdb_details(),
335 ):
336 with patch(
337 "lib.utils.general.utils.get_fanart_details",
338 return_value={},
339 ):
340 metadata = build_media_metadata(ids, "movies")
341 assert "w780" in metadata["poster"]
342
343 def test_rpdb_disabled_uses_tmdb_poster(self):
344 ids = {"tmdb_id": "123", "imdb_id": "tt1234567"}
345
346 with patch(
347 "lib.utils.general.utils.get_setting_fresh",
348 side_effect=lambda key, default=None: {
349 "rpdb_enabled": False,
350 "rpdb_api_key": "",
351 }.get(key, default),
352 ):
353 with patch("lib.utils.general.utils.get_rpdb_poster") as mock_get_rpdb:
354 with patch(
355 "lib.clients.tmdb.utils.utils.get_tmdb_media_details",
356 return_value=self._make_tmdb_details(),
357 ):
358 with patch(
359 "lib.utils.general.utils.get_fanart_details",
360 return_value={},
361 ):
362 metadata = build_media_metadata(ids, "movies")
363 mock_get_rpdb.assert_not_called()
364 assert "w780" in metadata["poster"]
365
366 def test_rpdb_enabled_no_imdb_id_uses_tmdb_poster(self):
367 ids = {"tmdb_id": "123"}
368
369 with patch(
370 "lib.utils.general.utils.get_setting_fresh",
371 side_effect=lambda key, default=None: {
372 "rpdb_enabled": True,
373 "rpdb_api_key": "test_key",
374 }.get(key, default),
375 ):
376 with patch("lib.utils.general.utils.get_rpdb_poster") as mock_get_rpdb:
377 with patch(
378 "lib.clients.tmdb.utils.utils.get_tmdb_media_details",
379 return_value=self._make_tmdb_details(),
380 ):
381 with patch(
382 "lib.utils.general.utils.get_fanart_details",
383 return_value={},
384 ):
385 metadata = build_media_metadata(ids, "movies")
386 mock_get_rpdb.assert_not_called()
387 assert "w780" in metadata["poster"]
plugin.video.jacktook/tests/unit/test_jackett.py +5 -6
……@@ -1,15 +1,14 @@
1import pytest
2from unittest.mock import MagicMock, patch
31import os
2from unittest.mock import MagicMock, patch
3
4import pytest
45
56from lib.clients.jackett import Jackett
67from lib.domain.torrent import TorrentStream
78
89# Load fixture data
9FIXTURE_PATH = os.path.join(
10 os.path.dirname(__file__), "../fixtures/jackett_response.xml"
11)
12with open(FIXTURE_PATH, "r") as f:
10FIXTURE_PATH = os.path.join(os.path.dirname(__file__), "../fixtures/jackett_response.xml")
11with open(FIXTURE_PATH) as f:
1312 XML_RESPONSE = f.read()
1413
1514
plugin.video.jacktook/tests/unit/test_kodi_utils.py +64 -12
……@@ -1,44 +1,96 @@
1import pytest
12from unittest.mock import patch
23
34from lib.utils.kodi import utils
45
56
67def test_is_youtube_addon_enabled_returns_false_when_addon_is_absent():
7 with patch.object(utils.xbmc, "getCondVisibility", return_value=False) as get_cond_visibility, patch.object(
8 with patch.object(
9 utils.xbmc, "getCondVisibility", return_value=False
10 ) as get_cond_visibility, patch.object(
811 utils.xbmcaddon,
912 "Addon",
1013 ) as addon_cls:
1114 assert utils.is_youtube_addon_enabled() is False
1215
13 get_cond_visibility.assert_called_once_with(
14 f"System.HasAddon({utils.YOUTUBE_ADDON_ID})"
15 )
16 get_cond_visibility.assert_called_once_with(f"System.HasAddon({utils.YOUTUBE_ADDON_ID})")
1617 addon_cls.assert_not_called()
1718
1819
1920def test_is_youtube_addon_enabled_returns_true_when_addon_is_enabled():
20 with patch.object(utils.xbmc, "getCondVisibility", return_value=True) as get_cond_visibility, patch.object(
21 with patch.object(
22 utils.xbmc, "getCondVisibility", return_value=True
23 ) as get_cond_visibility, patch.object(
2124 utils.xbmcaddon,
2225 "Addon",
2326 return_value=object(),
2427 ) as addon_cls:
2528 assert utils.is_youtube_addon_enabled() is True
2629
27 get_cond_visibility.assert_called_once_with(
28 f"System.HasAddon({utils.YOUTUBE_ADDON_ID})"
29 )
30 get_cond_visibility.assert_called_once_with(f"System.HasAddon({utils.YOUTUBE_ADDON_ID})")
3031 addon_cls.assert_called_once_with(utils.YOUTUBE_ADDON_ID)
3132
3233
3334def test_is_youtube_addon_enabled_returns_false_when_addon_is_disabled():
34 with patch.object(utils.xbmc, "getCondVisibility", return_value=True) as get_cond_visibility, patch.object(
35 with patch.object(
36 utils.xbmc, "getCondVisibility", return_value=True
37 ) as get_cond_visibility, patch.object(
3538 utils.xbmcaddon,
3639 "Addon",
3740 side_effect=RuntimeError,
3841 ) as addon_cls:
3942 assert utils.is_youtube_addon_enabled() is False
4043
41 get_cond_visibility.assert_called_once_with(
42 f"System.HasAddon({utils.YOUTUBE_ADDON_ID})"
43 )
44 get_cond_visibility.assert_called_once_with(f"System.HasAddon({utils.YOUTUBE_ADDON_ID})")
4445 addon_cls.assert_called_once_with(utils.YOUTUBE_ADDON_ID)
46
47
48@pytest.mark.parametrize(
49 ("input_str", "expected"),
50 [
51 # Standard English units
52 ("1 GB", 1 * 1024**3),
53 ("2.5 GB", int(2.5 * 1024**3)),
54 ("500 MB", 500 * 1024**2),
55 ("100 KB", 100 * 1024),
56 ("42 B", 42),
57 # Case insensitivity
58 ("1.5 gb", int(1.5 * 1024**3)),
59 ("200 mb", 200 * 1024**2),
60 ("50 kb", 50 * 1024),
61 # European units (Go = GB, Mo = MB, Ko = KB)
62 ("1 Go", 1 * 1024**3),
63 ("1.5 Go", int(1.5 * 1024**3)),
64 ("500 Mo", 500 * 1024**2),
65 ("100 Ko", 100 * 1024),
66 # Case insensitivity for European units
67 ("2 go", 2 * 1024**3),
68 ("300 mo", 300 * 1024**2),
69 ("75 ko", 75 * 1024),
70 # European decimal comma → normalised to dot
71 ("1,5 GB", int(1.5 * 1024**3)),
72 ("2,5 Go", int(2.5 * 1024**3)),
73 ("500,5 MB", int(500.5 * 1024**2)),
74 # Edge cases
75 ("0 GB", 0),
76 ("0 MB", 0),
77 ("0 B", 0),
78 # Precision edge case – int truncation, not rounding
79 ("1.9 GB", int(1.9 * 1024**3)),
80 ],
81)
82def test_convert_size_to_bytes(input_str, expected):
83 assert utils.convert_size_to_bytes(input_str) == expected
84
85
86@pytest.mark.parametrize(
87 ("input_str",),
88 [
89 ("",),
90 ("not a size",),
91 ("ABC",),
92 ("1.5",), # missing unit
93 ],
94)
95def test_convert_size_to_bytes_invalid(input_str):
96 assert utils.convert_size_to_bytes(input_str) == 0
plugin.video.jacktook/tests/unit/test_library_menu_clear.py +36 -8
……@@ -18,19 +18,37 @@ def test_library_menu_adds_clear_context_menu(monkeypatch):
1818 monkeypatch.setattr(
1919 library_history,
2020 "translation",
21 lambda value: {90202: "My TvShows", 90203: "My Movies", 90201: "Library", 90021: "Upcoming Episodes", 90690: "Clear All Items"}.get(value, str(value)),
21 lambda value: {
22 90202: "My TvShows",
23 90203: "My Movies",
24 90201: "Library",
25 90021: "Upcoming Episodes",
26 90690: "Clear All Items",
27 }.get(value, str(value)),
2228 )
2329 monkeypatch.setattr(library_history, "set_pluging_category", lambda *args, **kwargs: None)
24 monkeypatch.setattr(library_history, "end_of_directory", lambda *args, **kwargs: end_calls.append(kwargs))
30 monkeypatch.setattr(
31 library_history,
32 "end_of_directory",
33 lambda *args, **kwargs: end_calls.append(kwargs),
34 )
2535 monkeypatch.setattr(
2636 library_history,
2737 "container_update",
28 lambda action, **params: "Container.Update(plugin://plugin.video.jacktook?action={}{})".format(
29 action,
30 "".join("&{}={}".format(key, value) for key, value in params.items()),
38 lambda action, **params: (
39 "Container.Update(plugin://plugin.video.jacktook?action={}{})".format(
40 action,
41 "".join(f"&{key}={value}" for key, value in params.items()),
42 )
43 ),
44 )
45 monkeypatch.setattr(
46 library_history,
47 "build_url",
48 lambda action, **params: "plugin://plugin.video.jacktook?action={}{}".format(
49 action, "".join(f"&{key}={value}" for key, value in params.items())
3150 ),
3251 )
33 monkeypatch.setattr(library_history, "build_url", lambda action, **params: "plugin://plugin.video.jacktook?action={}{}".format(action, "".join("&{}={}".format(key, value) for key, value in params.items())))
3452 monkeypatch.setattr(
3553 library_history,
3654 "add_directory_items_batch",
……@@ -41,8 +59,18 @@ def test_library_menu_adds_clear_context_menu(monkeypatch):
4159
4260 assert added[0].label == "My TvShows"
4361 assert added[1].label == "My Movies"
44 assert added[0].context_menu == [("Clear All Items", "Container.Update(plugin://plugin.video.jacktook?action=library_shows&clear=1)")]
45 assert added[1].context_menu == [("Clear All Items", "Container.Update(plugin://plugin.video.jacktook?action=library_movies&clear=1)")]
62 assert added[0].context_menu == [
63 (
64 "Clear All Items",
65 "Container.Update(plugin://plugin.video.jacktook?action=library_shows&clear=1)",
66 )
67 ]
68 assert added[1].context_menu == [
69 (
70 "Clear All Items",
71 "Container.Update(plugin://plugin.video.jacktook?action=library_movies&clear=1)",
72 )
73 ]
4674 assert end_calls == [{"cache": False}]
4775
4876
plugin.video.jacktook/tests/unit/test_library_stremio.py +19 -5
……@@ -8,7 +8,9 @@ def test_get_library_entries_prioritizes_stremio_metadata(monkeypatch):
88 monkeypatch.setattr(library.cache, "set", lambda *args, **kwargs: None)
99
1010 tmdb_calls = []
11 monkeypatch.setattr(library, "tmdb_get", lambda *args, **kwargs: tmdb_calls.append((args, kwargs)))
11 monkeypatch.setattr(
12 library, "tmdb_get", lambda *args, **kwargs: tmdb_calls.append((args, kwargs))
13 )
1214
1315 entries = library._get_library_entries(
1416 [
……@@ -101,16 +103,26 @@ def test_show_library_items_renders_stremio_entry_without_tmdb(monkeypatch):
101103 def setProperty(self, key, value):
102104 self.properties[key] = value
103105
104 monkeypatch.setattr(library, "make_list_item", lambda label="", path="", offscreen=True: _ListItem(label=label))
106 monkeypatch.setattr(
107 library,
108 "make_list_item",
109 lambda label="", path="", offscreen=True: _ListItem(label=label),
110 )
105111 monkeypatch.setattr(library, "set_pluging_category", lambda *args, **kwargs: None)
106112 monkeypatch.setattr(library, "setContent", lambda *args, **kwargs: None)
107113 monkeypatch.setattr(library, "end_of_directory", lambda *args, **kwargs: None)
108 monkeypatch.setattr(library, "translation", lambda value: {90202: "My Shows", 90204: "Remove from Library"}.get(value, str(value)))
114 monkeypatch.setattr(
115 library,
116 "translation",
117 lambda value: {90202: "My Shows", 90204: "Remove from Library"}.get(value, str(value)),
118 )
109119 monkeypatch.setattr(library, "set_media_infoTag", lambda *args, **kwargs: None)
110120 monkeypatch.setattr(
111121 library,
112122 "add_directory_items_batch",
113 lambda items: added.extend((url, listitem.label, is_folder) for url, listitem, is_folder in items),
123 lambda items: added.extend(
124 (url, listitem.label, is_folder) for url, listitem, is_folder in items
125 ),
114126 )
115127 monkeypatch.setattr(
116128 library.PickleDatabase,
……@@ -146,7 +158,9 @@ def test_library_treats_entries_with_stremio_route_fields_as_stremio(monkeypatch
146158 monkeypatch.setattr(library.cache, "set", lambda *args, **kwargs: None)
147159
148160 tmdb_calls = []
149 monkeypatch.setattr(library, "tmdb_get", lambda *args, **kwargs: tmdb_calls.append((args, kwargs)))
161 monkeypatch.setattr(
162 library, "tmdb_get", lambda *args, **kwargs: tmdb_calls.append((args, kwargs))
163 )
150164
151165 entries = library._get_library_entries(
152166 [
plugin.video.jacktook/tests/unit/test_library_view.py +2 -2
……@@ -1,7 +1,7 @@
1from unittest.mock import MagicMock, patch
1from unittest.mock import patch
22
3from lib.utils.views import library
43from lib.utils.general.utils import add_to_library, remove_from_library
4from lib.utils.views import library
55
66
77def test_get_library_entries_uses_cached_materialized_list():
plugin.video.jacktook/tests/unit/test_navigation_debrid_module.py +16 -4
……@@ -6,10 +6,9 @@ from lib.nav import debrid as debrid_navigation
66def test_cloud_details_uses_registry_actions_for_realdebrid():
77 with patch("lib.nav.debrid.addDirectoryItem") as add_directory_item, patch(
88 "lib.nav.debrid.end_of_directory"
9 ), patch(
10 "lib.nav.debrid.build_url", side_effect=lambda action, **kwargs: action
11 ), patch(
12 "lib.nav.debrid.build_list_item", side_effect=lambda label, *_args, **_kwargs: label
9 ), patch("lib.nav.debrid.build_url", side_effect=lambda action, **kwargs: action), patch(
10 "lib.nav.debrid.build_list_item",
11 side_effect=lambda label, *_args, **_kwargs: label,
1312 ):
1413 debrid_navigation.cloud_details({"debrid_name": debrid_navigation.DebridType.RD})
1514
……@@ -17,6 +16,19 @@ def test_cloud_details_uses_registry_actions_for_realdebrid():
1716 assert add_directory_item.call_args_list[0].args[1] == "get_rd_downloads"
1817
1918
19def test_cloud_details_uses_registry_actions_for_offcloud():
20 with patch("lib.nav.debrid.addDirectoryItem") as add_directory_item, patch(
21 "lib.nav.debrid.end_of_directory"
22 ), patch("lib.nav.debrid.build_url", side_effect=lambda action, **kwargs: action), patch(
23 "lib.nav.debrid.build_list_item",
24 side_effect=lambda label, *_args, **_kwargs: label,
25 ):
26 debrid_navigation.cloud_details({"debrid_name": debrid_navigation.DebridType.OC})
27
28 assert len(add_directory_item.call_args_list) == 1
29 assert add_directory_item.call_args_list[0].args[1] == "get_oc_downloads"
30
31
2032def test_download_notifies_on_unknown_debrid_type():
2133 with patch("lib.nav.debrid.notification") as notify, patch(
2234 "lib.nav.debrid.translation", return_value="Unsupported debrid type"
plugin.video.jacktook/tests/unit/test_navigation_library_history_module.py +3 -4
……@@ -6,12 +6,11 @@ from lib.nav import library_history
66def test_history_menu_renders_history_items():
77 with patch("lib.nav.library_history.set_pluging_category") as set_category, patch(
88 "lib.nav.library_history.add_directory_items_batch"
9 ) as add_directory_items_batch, patch(
10 "lib.nav.library_history.end_of_directory"
11 ), patch(
9 ) as add_directory_items_batch, patch("lib.nav.library_history.end_of_directory"), patch(
1210 "lib.nav.library_history.build_url", side_effect=lambda action, **kwargs: action
1311 ), patch(
14 "lib.nav.library_history.build_list_item", side_effect=lambda label, *_args, **_kwargs: label
12 "lib.nav.library_history.build_list_item",
13 side_effect=lambda label, *_args, **_kwargs: label,
1514 ):
1615 library_history.history_menu({})
1716
plugin.video.jacktook/tests/unit/test_offcloud.py +441 -0
……@@ -0,0 +1,441 @@
1from unittest.mock import MagicMock, patch
2
3import requests
4
5from lib.api.debrid.offcloud import Offcloud
6from lib.clients.debrid.offcloud import OffcloudHelper
7from lib.domain.torrent import TorrentStream
8from lib.services.debrid.auth import run_offcloud_auth
9from lib.utils.general.utils import DebridType, IndexerType
10
11
12class FakeResponse:
13 def __init__(self, payload, status_code=200):
14 self._payload = payload
15 self.status_code = status_code
16 self.headers = {"Content-Type": "application/json"}
17 self.text = ""
18 self.url = "https://offcloud.com/api/test"
19
20 @property
21 def ok(self):
22 return 200 <= self.status_code < 400
23
24 def json(self):
25 return self._payload
26
27 def raise_for_status(self):
28 if not self.ok:
29 error = requests.RequestException("HTTP error")
30 error.response = self
31 raise error
32
33
34def test_offcloud_client_uses_bearer_auth_and_cache_info_payload():
35 session = MagicMock()
36 session.request.return_value = FakeResponse([{"cached": True}])
37 client = Offcloud("token-123", session=session)
38
39 result = client.get_cache_info(["magnet:?xt=urn:btih:abc"], include_files=True)
40
41 assert result == [{"cached": True}]
42 session.request.assert_called_once_with(
43 method="POST",
44 url="https://offcloud.com/api/cache/info",
45 params={},
46 data=None,
47 json={"urls": ["magnet:?xt=urn:btih:abc"], "includeFiles": True},
48 headers={
49 "Accept": "application/json",
50 "Content-Type": "application/json",
51 "Authorization": "Bearer token-123",
52 },
53 timeout=15,
54 )
55
56
57def test_offcloud_client_flattens_nested_cloud_status():
58 session = MagicMock()
59 session.request.return_value = FakeResponse(
60 {"status": {"status": "downloaded", "requestId": "req-1"}}
61 )
62 client = Offcloud("token-123", session=session)
63
64 assert client.get_cloud_status("req-1") == {"status": "downloaded", "requestId": "req-1"}
65
66
67def test_offcloud_client_requests_oauth_device_code_without_query_token():
68 session = MagicMock()
69 session.request.return_value = FakeResponse(
70 {
71 "device_code": "device-1",
72 "user_code": "ABCD-EFGH",
73 "verification_uri": "https://offcloud.com/activate",
74 "interval": 5,
75 "expires_in": 600,
76 }
77 )
78 client = Offcloud("", session=session)
79
80 result = client.get_device_code()
81
82 assert result["device_code"] == "device-1"
83 session.request.assert_called_once_with(
84 method="POST",
85 url="https://offcloud.com/oauth/device/code",
86 params={},
87 data=None,
88 json={},
89 headers={"Accept": "application/json", "Content-Type": "application/json"},
90 timeout=15,
91 )
92
93
94def test_offcloud_client_requests_oauth_without_existing_bearer_auth():
95 session = MagicMock()
96 session.request.return_value = FakeResponse(
97 {
98 "device_code": "device-1",
99 "user_code": "ABCD-EFGH",
100 "verification_uri": "https://offcloud.com/activate",
101 "interval": 5,
102 "expires_in": 600,
103 }
104 )
105 client = Offcloud("old-token", session=session)
106
107 result = client.get_device_code()
108
109 assert result["device_code"] == "device-1"
110 session.request.assert_called_once_with(
111 method="POST",
112 url="https://offcloud.com/oauth/device/code",
113 params={},
114 data=None,
115 json={},
116 headers={"Accept": "application/json", "Content-Type": "application/json"},
117 timeout=15,
118 )
119 assert client.headers["Authorization"] == "Bearer old-token"
120
121
122def test_offcloud_client_authorize_returns_expected_oauth_error_payload():
123 session = MagicMock()
124 session.request.return_value = FakeResponse({"error": "authorization_pending"}, status_code=400)
125 client = Offcloud("", session=session)
126
127 result = client.authorize("device-1")
128
129 assert result == {"error": "authorization_pending"}
130 session.request.assert_called_once_with(
131 method="POST",
132 url="https://offcloud.com/oauth/token",
133 params={},
134 data=None,
135 json={
136 "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
137 "device_code": "device-1",
138 },
139 headers={"Accept": "application/json", "Content-Type": "application/json"},
140 timeout=15,
141 )
142
143
144def test_offcloud_helper_check_cached_preserves_response_order_mapping():
145 helper = OffcloudHelper()
146 helper.client = MagicMock()
147 helper.client.get_cache_info.return_value = [{"cached": False}, {"cached": True}]
148 results = [TorrentStream(infoHash="hash1"), TorrentStream(infoHash="hash2")]
149 cached_results = []
150 uncached_results = []
151 lock = MagicMock()
152 lock.__enter__.return_value = None
153 lock.__exit__.return_value = None
154
155 with patch("lib.clients.debrid.offcloud.debrid_dialog_update"):
156 helper.check_cached(results, cached_results, uncached_results, 2, MagicMock(), lock)
157
158 assert [item.infoHash for item in cached_results] == ["hash2"]
159 assert [item.infoHash for item in uncached_results] == ["hash1"]
160 assert cached_results[0].type == IndexerType.DEBRID
161 assert cached_results[0].debridType == DebridType.OC
162
163
164def test_offcloud_helper_get_link_uses_cache_download_and_filters_tv_episode():
165 helper = OffcloudHelper()
166 helper.client = MagicMock()
167 helper.client.create_cache_download.return_value = [
168 {"filename": "show.s01e01.mkv", "size": 10, "url": "https://wrong"},
169 {"filename": "show.s01e02.mkv", "size": 20, "url": "https://right"},
170 ]
171
172 data = helper.get_link(
173 "abc123",
174 {"tv_data": {"season": 1, "episode": 2}},
175 )
176
177 assert data is not None
178 assert data["url"] == "https://right"
179
180
181def test_offcloud_helper_get_cloud_downloads_uses_detailed_explore_files():
182 helper = OffcloudHelper()
183 helper.client = MagicMock()
184 helper.client.get_cloud_history.return_value = [
185 {"requestId": "req-1", "fileName": "Release", "status": "downloaded", "createdOn": "2026"},
186 {"requestId": "req-2", "fileName": "Pending", "status": "created"},
187 ]
188 helper.client.explore_cloud_download.return_value = {
189 "files": [
190 {"path": "Release/sample.txt", "size": 1, "url": "https://text"},
191 {"path": "Release/video.mkv", "size": 100, "url": "https://video"},
192 ]
193 }
194
195 assert helper.get_cloud_downloads() == [
196 {
197 "name": "Release/video.mkv",
198 "request_id": "req-1",
199 "url": "https://video",
200 "created_at": "2026",
201 }
202 ]
203 helper.client.explore_cloud_download.assert_called_once_with("req-1", detailed=True)
204
205
206@patch(
207 "lib.services.debrid.auth.translation",
208 side_effect=lambda value: "%s" if value == 90604 else str(value),
209)
210@patch("lib.services.debrid.auth.set_setting")
211@patch("lib.services.debrid.auth.copy2clip")
212@patch("lib.services.debrid.auth.make_qrcode", return_value="/tmp/qr.png")
213@patch("lib.services.debrid.auth.ksleep")
214@patch("lib.services.debrid.auth.time", side_effect=[0, 0])
215@patch("lib.services.debrid.auth.QRProgressDialog")
216@patch("lib.services.debrid.auth.dialog_ok")
217def test_run_offcloud_auth_stores_access_token_and_account_email(
218 mock_dialog_ok,
219 mock_dialog_cls,
220 mock_time,
221 mock_sleep,
222 mock_make_qrcode,
223 mock_copy2clip,
224 mock_set_setting,
225 mock_translation,
226):
227 client = MagicMock()
228 client.get_device_code.return_value = {
229 "device_code": "device-1",
230 "user_code": "ABCD-EFGH",
231 "verification_uri": "https://offcloud.com/activate",
232 "verification_uri_complete": "https://offcloud.com/activate?code=ABCD-EFGH",
233 "interval": 5,
234 "expires_in": 600,
235 }
236 client.authorize.return_value = {"access_token": "api-key", "token_type": "Bearer"}
237 client.get_account_info.return_value = {"email": "tester@example.com"}
238 dialog = MagicMock()
239 dialog.iscanceled = False
240 mock_dialog_cls.return_value = dialog
241
242 run_offcloud_auth(client)
243
244 assert client.token == "api-key"
245 client.authorize.assert_called_once_with("device-1")
246 client.initialize_headers.assert_called_once_with()
247 mock_sleep.assert_called_once_with(5000)
248 mock_make_qrcode.assert_called_once_with("https://offcloud.com/activate?code=ABCD-EFGH")
249 mock_copy2clip.assert_called_once_with("https://offcloud.com/activate?code=ABCD-EFGH")
250 mock_set_setting.assert_any_call("offcloud_token", "api-key")
251 mock_set_setting.assert_any_call("offcloud_authorized", "true")
252 mock_set_setting.assert_any_call("offcloud_user", "tester@example.com")
253 dialog.setup.assert_called_once_with(
254 DebridType.OC,
255 "/tmp/qr.png",
256 "https://offcloud.com/activate",
257 "ABCD-EFGH",
258 DebridType.OC,
259 )
260 dialog.update_progress.assert_called_once_with(100, "90545")
261 dialog.close_dialog.assert_called_once_with()
262 mock_dialog_ok.assert_called_once_with("90544", "90545")
263
264
265@patch(
266 "lib.services.debrid.auth.translation",
267 side_effect=lambda value: "%s" if value == 90604 else str(value),
268)
269@patch("lib.services.debrid.auth.set_setting")
270@patch("lib.services.debrid.auth.copy2clip")
271@patch("lib.services.debrid.auth.make_qrcode", return_value="/tmp/qr.png")
272@patch("lib.services.debrid.auth.ksleep")
273@patch("lib.services.debrid.auth.time", side_effect=[0, 0, 1, 2])
274@patch("lib.services.debrid.auth.QRProgressDialog")
275def test_run_offcloud_auth_slow_down_increases_poll_interval(
276 mock_dialog_cls,
277 mock_time,
278 mock_sleep,
279 mock_make_qrcode,
280 mock_copy2clip,
281 mock_set_setting,
282 mock_translation,
283):
284 client = MagicMock()
285 client.get_device_code.return_value = {
286 "device_code": "device-1",
287 "user_code": "ABCD-EFGH",
288 "verification_uri": "https://offcloud.com/activate",
289 "interval": 5,
290 "expires_in": 600,
291 }
292 client.authorize.side_effect = [
293 {"error": "slow_down"},
294 {"access_token": "api-key", "token_type": "Bearer"},
295 ]
296 client.get_account_info.return_value = {}
297 dialog = MagicMock()
298 dialog.iscanceled = False
299 mock_dialog_cls.return_value = dialog
300
301 run_offcloud_auth(client)
302
303 assert [call.args[0] for call in mock_sleep.call_args_list] == [5000, 10000]
304 assert client.token == "api-key"
305 assert client.authorize.call_count == 2
306
307
308@patch(
309 "lib.services.debrid.auth.translation",
310 side_effect=lambda value: "%s" if value == 90604 else str(value),
311)
312@patch("lib.services.debrid.auth.set_setting")
313@patch("lib.services.debrid.auth.copy2clip")
314@patch("lib.services.debrid.auth.make_qrcode", return_value="/tmp/qr.png")
315@patch("lib.services.debrid.auth.ksleep")
316@patch("lib.services.debrid.auth.time", side_effect=[0, 0, 1, 1])
317@patch("lib.services.debrid.auth.QRProgressDialog")
318def test_run_offcloud_auth_authorization_pending_keeps_polling(
319 mock_dialog_cls,
320 mock_time,
321 mock_sleep,
322 mock_make_qrcode,
323 mock_copy2clip,
324 mock_set_setting,
325 mock_translation,
326):
327 client = MagicMock()
328 client.get_device_code.return_value = {
329 "device_code": "device-1",
330 "user_code": "ABCD-EFGH",
331 "verification_uri": "https://offcloud.com/activate",
332 "interval": 5,
333 "expires_in": 600,
334 }
335 client.authorize.side_effect = [
336 {"error": "authorization_pending"},
337 {"access_token": "api-key", "token_type": "Bearer"},
338 ]
339 client.get_account_info.return_value = {}
340 dialog = MagicMock()
341 dialog.iscanceled = False
342 mock_dialog_cls.return_value = dialog
343
344 run_offcloud_auth(client)
345
346 assert client.authorize.call_count == 2
347 assert [call.args[0] for call in mock_sleep.call_args_list] == [5000, 5000]
348 assert client.token == "api-key"
349 dialog.update_progress.assert_any_call(0)
350
351
352@patch(
353 "lib.services.debrid.auth.translation",
354 side_effect=lambda value: "%s" if value == 90604 else str(value),
355)
356@patch("lib.services.debrid.auth.set_setting")
357@patch("lib.services.debrid.auth.copy2clip")
358@patch("lib.services.debrid.auth.make_qrcode", return_value="/tmp/qr.png")
359@patch("lib.services.debrid.auth.ksleep")
360@patch("lib.services.debrid.auth.time", side_effect=[0, 0])
361@patch("lib.services.debrid.auth.QRProgressDialog")
362@patch("lib.services.debrid.auth.dialog_ok")
363def test_run_offcloud_auth_expired_token_stops_polling(
364 mock_dialog_ok,
365 mock_dialog_cls,
366 mock_time,
367 mock_sleep,
368 mock_make_qrcode,
369 mock_copy2clip,
370 mock_set_setting,
371 mock_translation,
372):
373 client = MagicMock()
374 client.get_device_code.return_value = {
375 "device_code": "device-1",
376 "user_code": "ABCD-EFGH",
377 "verification_uri": "https://offcloud.com/activate",
378 "interval": 5,
379 "expires_in": 600,
380 }
381 client.authorize.return_value = {
382 "error": "expired_token",
383 "error_description": "The device code has expired",
384 }
385 dialog = MagicMock()
386 dialog.iscanceled = False
387 mock_dialog_cls.return_value = dialog
388
389 run_offcloud_auth(client)
390
391 client.authorize.assert_called_once_with("device-1")
392 mock_sleep.assert_called_once_with(5000)
393 client.initialize_headers.assert_not_called()
394 dialog.close_dialog.assert_called_once_with()
395 mock_dialog_ok.assert_called_once_with("90548", "The device code has expired")
396
397
398@patch(
399 "lib.services.debrid.auth.translation",
400 side_effect=lambda value: "%s" if value == 90604 else str(value),
401)
402@patch("lib.services.debrid.auth.set_setting")
403@patch("lib.services.debrid.auth.copy2clip")
404@patch("lib.services.debrid.auth.make_qrcode", return_value="/tmp/qr.png")
405@patch("lib.services.debrid.auth.ksleep")
406@patch("lib.services.debrid.auth.time", side_effect=[0, 0])
407@patch("lib.services.debrid.auth.QRProgressDialog")
408@patch("lib.services.debrid.auth.dialog_ok")
409def test_run_offcloud_auth_access_denied_stops_polling(
410 mock_dialog_ok,
411 mock_dialog_cls,
412 mock_time,
413 mock_sleep,
414 mock_make_qrcode,
415 mock_copy2clip,
416 mock_set_setting,
417 mock_translation,
418):
419 client = MagicMock()
420 client.get_device_code.return_value = {
421 "device_code": "device-1",
422 "user_code": "ABCD-EFGH",
423 "verification_uri": "https://offcloud.com/activate",
424 "interval": 5,
425 "expires_in": 600,
426 }
427 client.authorize.return_value = {
428 "error": "access_denied",
429 "error_description": "The user denied authorization",
430 }
431 dialog = MagicMock()
432 dialog.iscanceled = False
433 mock_dialog_cls.return_value = dialog
434
435 run_offcloud_auth(client)
436
437 client.authorize.assert_called_once_with("device-1")
438 mock_sleep.assert_called_once_with(5000)
439 client.initialize_headers.assert_not_called()
440 dialog.close_dialog.assert_called_once_with()
441 mock_dialog_ok.assert_called_once_with("90548", "The user denied authorization")
plugin.video.jacktook/tests/unit/test_player.py +664 -5
……@@ -1,13 +1,19 @@
1from pathlib import Path
1import importlib
22import re
3
3import sys
4from datetime import date
5from pathlib import Path
6from types import SimpleNamespace
7from unittest.mock import MagicMock, call
48
59PLAYER_PATH = Path(__file__).resolve().parents[2] / "lib" / "player.py"
610
711
812def test_monitor_finally_uses_non_destructive_cleanup():
913 source = PLAYER_PATH.read_text()
10 monitor_match = re.search(r"def monitor\(self\):(?P<body>.*?)def handle_subtitles", source, re.S)
14 monitor_match = re.search(
15 r"def monitor\(self\):(?P<body>.*?)def handle_subtitles", source, re.S
16 )
1117
1218 assert monitor_match is not None
1319
……@@ -17,11 +23,10 @@ def test_monitor_finally_uses_non_destructive_cleanup():
1723 assert finally_match is not None
1824
1925 finally_body = finally_match.group("body")
20 assert "self._cleanup_playback_session()" in finally_body
2126 assert "self.cancel_playback()" not in finally_body
2227
2328
24def test_cancel_playback_still_invalidates_resolved_url():
29def test_cancel_playback_signals_kodi_and_stops_player():
2530 source = PLAYER_PATH.read_text()
2631 cancel_match = re.search(
2732 r"def cancel_playback\(self\):(?P<body>.*?)def _cleanup_playback_session",
……@@ -33,4 +38,658 @@ def test_cancel_playback_still_invalidates_resolved_url():
3338
3439 cancel_body = cancel_match.group("body")
3540 assert "self._cleanup_playback_session()" in cancel_body
41 # Must signal Kodi to avoid spinners (setResolvedUrl with False)
3642 assert "setResolvedUrl(ADDON_HANDLE, False, ListItem(offscreen=True))" in cancel_body
43 # Must actually stop playback started by Player.play()
44 assert "self.stop()" in cancel_body
45
46
47def test_play_video_always_uses_player_play_not_resolved_url(monkeypatch):
48 player_module = _player_module(monkeypatch)
49 test_player = _player_for_episode(player_module)
50 test_player.url = "http://stream"
51 test_player.play = MagicMock()
52 test_player.monitor = MagicMock()
53 test_player.cancel_playback = MagicMock()
54 test_player._check_volume = MagicMock(return_value=True)
55 test_player._handle_trakt_scrobble = MagicMock()
56 test_player.handle_subtitles = MagicMock()
57 list_item = MagicMock()
58
59 test_player.play_video(list_item)
60
61 test_player.play.assert_called_once_with("http://stream", list_item)
62 test_player.monitor.assert_called_once_with()
63
64
65def test_run_does_not_add_next_episode_to_kodi_playlist():
66 source = PLAYER_PATH.read_text()
67 run_match = re.search(r"def run\(self, data=None\):(?P<body>.*?)def _drain_nextep_queue", source, re.S)
68
69 assert run_match is not None
70
71 run_body = run_match.group("body")
72 assert "self.build_playlist()" not in run_body
73
74
75def _episode(number, name="Episode", air_date="2020-01-01"):
76 return SimpleNamespace(episode_number=number, name=name, air_date=air_date)
77
78
79def _player_module(monkeypatch):
80 class FakeXbmcPlayer:
81 pass
82
83 monkeypatch.setattr(sys.modules["xbmc"], "Player", FakeXbmcPlayer)
84 from lib import player as player_module
85
86 return importlib.reload(player_module)
87
88
89def _player_for_episode(player_module, season=1, episode=1):
90 JacktookPLayer = player_module.JacktookPLayer
91
92 player = JacktookPLayer.__new__(JacktookPLayer)
93 player.data = {
94 "mode": "tv",
95 "title": "Test Show",
96 "ids": {"tmdb_id": 123, "imdb_id": "tt123"},
97 "tv_data": {"season": season, "episode": episode},
98 }
99 return player
100
101
102def _player_instance():
103 from lib.player import JacktookPLayer
104
105 player = object.__new__(JacktookPLayer)
106 player.data = {
107 "mode": "tv",
108 "title": "Show",
109 "ids": {"tmdb_id": "123"},
110 "tv_data": {"season": 1, "episode": 1},
111 }
112 player.playnext = MagicMock()
113 player.play = MagicMock()
114 player.run = MagicMock()
115 player.PLAYLIST = MagicMock()
116 return player
117
118
119class FakeThread:
120 def __init__(self, *args, **kwargs):
121 self.target = kwargs.get("target")
122 self.args = kwargs.get("args", ())
123 self.daemon = False
124
125 def start(self):
126 pass
127
128
129def test_get_next_episode_data_same_season(monkeypatch):
130 player_module = _player_module(monkeypatch)
131 test_player = _player_for_episode(player_module, season=1, episode=1)
132 details = SimpleNamespace(number_of_seasons=1)
133 season_details = SimpleNamespace(episodes=[_episode(1, "Pilot"), _episode(2, "Second")])
134
135 def fake_tmdb_get(path, params=None):
136 if path == "tv_details":
137 return details
138 if path == "season_details":
139 return season_details
140 return None
141
142 monkeypatch.setattr(player_module, "tmdb_get", fake_tmdb_get)
143
144 assert test_player._get_next_episode_data() == {
145 "name": "Second",
146 "episode": 2,
147 "season": 1,
148 }
149
150
151def test_get_next_episode_data_season_boundary(monkeypatch):
152 player_module = _player_module(monkeypatch)
153 test_player = _player_for_episode(player_module, season=1, episode=10)
154 details = SimpleNamespace(number_of_seasons=2)
155 season_one = SimpleNamespace(episodes=[_episode(number) for number in range(1, 11)])
156 season_two = SimpleNamespace(episodes=[_episode(1, "Season Two Premiere")])
157
158 def fake_tmdb_get(path, params=None):
159 if path == "tv_details":
160 return details
161 if path == "season_details" and params["season"] == 1:
162 return season_one
163 if path == "season_details" and params["season"] == 2:
164 return season_two
165 return None
166
167 monkeypatch.setattr(player_module, "tmdb_get", fake_tmdb_get)
168
169 assert test_player._get_next_episode_data() == {
170 "name": "Season Two Premiere",
171 "episode": 1,
172 "season": 2,
173 }
174
175
176def test_get_next_episode_data_accepts_string_season_episode(monkeypatch):
177 player_module = _player_module(monkeypatch)
178 test_player = _player_for_episode(player_module, season="1", episode="1")
179 details = SimpleNamespace(number_of_seasons=1)
180 season_details = SimpleNamespace(episodes=[_episode(1, "Pilot"), _episode(2, "Second")])
181
182 def fake_tmdb_get(path, params=None):
183 if path == "tv_details":
184 return details
185 if path == "season_details":
186 assert params["season"] == 1
187 return season_details
188 return None
189
190 monkeypatch.setattr(player_module, "tmdb_get", fake_tmdb_get)
191
192 assert test_player._get_next_episode_data() == {
193 "name": "Second",
194 "episode": 2,
195 "season": 1,
196 }
197
198
199def _make_fake_date(fixed_today):
200 """Create a fake date module with a fixed today() for deterministic testing."""
201
202 class FakeDate(date):
203 @classmethod
204 def today(cls):
205 return fixed_today
206
207 # Replace date's __new__ to return FakeDate instances for specific dates
208 # while keeping date() construction working normally
209 original_new = date.__new__
210
211 class PatchedDate(FakeDate):
212 def __new__(cls, year, month, day):
213 return original_new(cls, year, month, day)
214
215 return PatchedDate
216
217
218def test_get_next_episode_data_future_air_date_returns_none(monkeypatch):
219 player_module = _player_module(monkeypatch)
220 test_player = _player_for_episode(player_module, season=1, episode=1)
221 details = SimpleNamespace(number_of_seasons=1)
222 season_details = SimpleNamespace(
223 episodes=[_episode(1, "Pilot"), _episode(2, "Future", "2999-01-01")]
224 )
225
226 def fake_tmdb_get(path, params=None):
227 if path == "tv_details":
228 return details
229 if path == "season_details":
230 return season_details
231 return None
232
233 monkeypatch.setattr(player_module, "tmdb_get", fake_tmdb_get)
234 monkeypatch.setattr(player_module, "date", _make_fake_date(date(2020, 6, 15)))
235
236 assert test_player._get_next_episode_data() is None
237
238
239def test_get_next_episode_data_past_air_date_returns_episode(monkeypatch):
240 """Task 2.6: Past air date returns episode with mocked today()."""
241 player_module = _player_module(monkeypatch)
242 test_player = _player_for_episode(player_module, season=1, episode=1)
243 details = SimpleNamespace(number_of_seasons=1)
244 season_details = SimpleNamespace(
245 episodes=[_episode(1, "Pilot", "2020-01-01"), _episode(2, "Aired", "2020-01-15")]
246 )
247
248 def fake_tmdb_get(path, params=None):
249 if path == "tv_details":
250 return details
251 if path == "season_details":
252 return season_details
253 return None
254
255 monkeypatch.setattr(player_module, "tmdb_get", fake_tmdb_get)
256 monkeypatch.setattr(player_module, "date", _make_fake_date(date(2020, 6, 15)))
257
258 assert test_player._get_next_episode_data() == {
259 "name": "Aired",
260 "episode": 2,
261 "season": 1,
262 }
263
264
265def test_queue_from_autoscrape_cache_missing_id_returns_false(monkeypatch):
266 """Missing id_value returns False early."""
267 player_module = _player_module(monkeypatch)
268 test_player = _player_for_episode(player_module)
269 ids = {}
270 next_tv_data = {"season": 1, "episode": 2}
271
272 result = test_player._queue_from_autoscrape_cache(next_tv_data, ids)
273
274 assert result is False
275
276
277def test_queue_from_autoscrape_cache_cache_miss_returns_false(monkeypatch):
278 """Cache miss returns False."""
279 player_module = _player_module(monkeypatch)
280 test_player = _player_for_episode(player_module)
281 ids = {"tmdb_id": 123}
282 next_tv_data = {"season": 1, "episode": 2}
283
284 monkeypatch.setattr(player_module.cache, "get", lambda key: None)
285
286 result = test_player._queue_from_autoscrape_cache(next_tv_data, ids)
287
288 assert result is False
289
290
291def test_queue_from_autoscrape_cache_cache_hit_autoplay_enabled(monkeypatch):
292 """Cache hit with autoplay enabled: queues data, stops player, returns True."""
293 player_module = _player_module(monkeypatch)
294 player_module.JacktookPLayer._nextep_queue.clear()
295 test_player = _player_for_episode(player_module)
296 ids = {"tmdb_id": 123}
297 next_tv_data = {"season": 1, "episode": 2}
298 cached_data = {"url": "http://cached", "title": "Cached Ep"}
299 test_player.stop = MagicMock()
300
301 monkeypatch.setattr(player_module.cache, "get", lambda key: cached_data)
302 monkeypatch.setattr("lib.utils.kodi.settings.auto_play_enabled", lambda: True)
303 clear_mock = MagicMock()
304 monkeypatch.setattr(player_module, "clear_property", clear_mock)
305
306 result = test_player._queue_from_autoscrape_cache(next_tv_data, ids)
307
308 assert result is True
309 assert len(player_module.JacktookPLayer._nextep_queue) == 1
310 entry = player_module.JacktookPLayer._nextep_queue[0]
311 assert entry["data"]["autoplay"] is True
312 assert entry["data"]["playnext_context"] is True
313 test_player.stop.assert_called_once_with()
314 clear_mock.assert_called_once_with("jacktook_next_dialog_action")
315 player_module.JacktookPLayer._nextep_queue.clear()
316
317
318def test_queue_from_autoscrape_cache_cache_hit_autoplay_disabled(monkeypatch):
319 """Cache hit with autoplay disabled: queues with results, stops player, returns True."""
320 player_module = _player_module(monkeypatch)
321 player_module.JacktookPLayer._nextep_queue.clear()
322 test_player = _player_for_episode(player_module)
323 ids = {"tmdb_id": 123}
324 next_tv_data = {"season": 1, "episode": 2}
325 cached_data = {"url": "http://cached", "title": "Cached Ep"}
326 test_player.stop = MagicMock()
327
328 monkeypatch.setattr(player_module.cache, "get", lambda key: cached_data)
329 monkeypatch.setattr("lib.utils.kodi.settings.auto_play_enabled", lambda: False)
330 clear_mock = MagicMock()
331 monkeypatch.setattr(player_module, "clear_property", clear_mock)
332
333 result = test_player._queue_from_autoscrape_cache(next_tv_data, ids)
334
335 assert result is True
336 assert len(player_module.JacktookPLayer._nextep_queue) == 1
337 test_player.stop.assert_called_once_with()
338 clear_mock.assert_called_once_with("jacktook_next_dialog_action")
339 player_module.JacktookPLayer._nextep_queue.clear()
340
341
342def test_drain_nextep_queue_clears_empty_queue(monkeypatch):
343 """Empty queue is handled without error."""
344 player_module = _player_module(monkeypatch)
345 player_module.JacktookPLayer._nextep_queue.clear()
346 test_player = _player_for_episode(player_module)
347
348 test_player._drain_nextep_queue() # Should not raise or hang
349
350
351def test_check_still_watching_threshold_disabled(monkeypatch):
352 """Task 2.2: Threshold is zero skips dialog immediately."""
353 from lib.player import JacktookPLayer
354
355 player = _player_instance()
356 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: 0)
357
358 result = JacktookPLayer._check_still_watching_threshold(player)
359
360 assert result is False
361
362
363def test_check_still_watching_threshold_below_threshold(monkeypatch):
364 """Task 2.2: Count below threshold increments counter."""
365 from lib.player import JacktookPLayer
366
367 player = _player_instance()
368 set_property_mock = MagicMock()
369
370 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: 3)
371 monkeypatch.setattr("lib.player.get_property", lambda key: "1")
372 monkeypatch.setattr("lib.player.set_property", set_property_mock)
373
374 result = JacktookPLayer._check_still_watching_threshold(player)
375
376 assert result is False
377 set_property_mock.assert_called_once_with("jacktook_consecutive_autoplays", "2")
378
379
380def test_check_still_watching_threshold_reached_user_continues(monkeypatch):
381 """Task 2.2: Count reaches threshold, user continues, resets to 1."""
382 import xbmcgui
383
384 from lib.player import JacktookPLayer
385
386 player = _player_instance()
387 set_property_mock = MagicMock()
388 clear_property_mock = MagicMock()
389 dialog = MagicMock()
390 dialog.yesno.return_value = True
391
392 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: 3)
393 monkeypatch.setattr("lib.player.get_property", lambda key: "3")
394 monkeypatch.setattr("lib.player.set_property", set_property_mock)
395 monkeypatch.setattr("lib.player.clear_property", clear_property_mock)
396 monkeypatch.setattr(xbmcgui, "Dialog", lambda: dialog)
397
398 result = JacktookPLayer._check_still_watching_threshold(player)
399
400 assert result is False
401 set_property_mock.assert_called_once_with("jacktook_consecutive_autoplays", "1")
402
403
404def test_check_still_watching_threshold_reached_user_stops(monkeypatch):
405 """Task 2.2: Count reaches threshold, user stops, returns True."""
406 import xbmcgui
407
408 from lib.player import JacktookPLayer
409
410 player = _player_instance()
411 set_property_mock = MagicMock()
412 clear_property_mock = MagicMock()
413 dialog = MagicMock()
414 dialog.yesno.return_value = False
415
416 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: 3)
417 monkeypatch.setattr("lib.player.get_property", lambda key: "3")
418 monkeypatch.setattr("lib.player.set_property", set_property_mock)
419 monkeypatch.setattr("lib.player.clear_property", clear_property_mock)
420 monkeypatch.setattr(xbmcgui, "Dialog", lambda: dialog)
421
422 result = JacktookPLayer._check_still_watching_threshold(player)
423
424 assert result is True
425 set_property_mock.assert_not_called()
426 assert clear_property_mock.call_args_list == [
427 call("jacktook_consecutive_autoplays"),
428 call("jacktook_next_dialog_action"),
429 ]
430
431
432def test_check_still_watching_threshold_invalid_count_str(monkeypatch):
433 """Task 2.2: Invalid count_str defaults to 1, increments correctly."""
434 from lib.player import JacktookPLayer
435
436 player = _player_instance()
437 set_property_mock = MagicMock()
438
439 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: 2)
440 monkeypatch.setattr("lib.player.get_property", lambda key: "abc")
441 monkeypatch.setattr("lib.player.set_property", set_property_mock)
442
443 result = JacktookPLayer._check_still_watching_threshold(player)
444
445 assert result is False
446 set_property_mock.assert_called_once_with("jacktook_consecutive_autoplays", "2")
447
448
449def test_build_playlist_adds_next_episode(monkeypatch):
450 """Task 2.3: Has next episode data adds to PLAYLIST."""
451 player_module = _player_module(monkeypatch)
452 test_player = _player_for_episode(player_module)
453 test_player.preferred_group = "GRP"
454 next_data = {"name": "Next Ep", "episode": 2, "season": 1}
455
456 monkeypatch.setattr(test_player, "_get_next_episode_data", lambda: next_data)
457 monkeypatch.setattr(
458 player_module,
459 "get_setting",
460 lambda key, default=None: True if key == "autoscrape_next_episode" else default,
461 )
462 monkeypatch.setattr(
463 player_module,
464 "build_url",
465 lambda action, **kw: "plugin://play_autoscraped",
466 )
467 playlist = MagicMock()
468 playlist.size.return_value = 0
469 test_player.PLAYLIST = playlist
470
471 test_player.build_playlist()
472
473 playlist.add.assert_called_once()
474 call_url = playlist.add.call_args[1]["url"]
475 assert call_url == "plugin://play_autoscraped"
476
477
478def test_build_playlist_no_next_episode(monkeypatch):
479 """Task 2.3: No next episode data returns early."""
480 player_module = _player_module(monkeypatch)
481 test_player = _player_for_episode(player_module)
482 test_player.preferred_group = "GRP"
483
484 monkeypatch.setattr(test_player, "_get_next_episode_data", lambda: None)
485 playlist = MagicMock()
486 test_player.PLAYLIST = playlist
487
488 test_player.build_playlist()
489
490 playlist.add.assert_not_called()
491
492
493def test_build_playlist_deduplicates(monkeypatch):
494 """Task 2.3: Duplicate URL in playlist is skipped."""
495 player_module = _player_module(monkeypatch)
496 test_player = _player_for_episode(player_module)
497 test_player.preferred_group = "GRP"
498 next_data = {"name": "Next Ep", "episode": 2, "season": 1}
499
500 monkeypatch.setattr(test_player, "_get_next_episode_data", lambda: next_data)
501 monkeypatch.setattr(
502 player_module,
503 "get_setting",
504 lambda key, default=None: True if key == "autoscrape_next_episode" else default,
505 )
506 monkeypatch.setattr(
507 player_module,
508 "build_url",
509 lambda action, **kw: "plugin://play_autoscraped",
510 )
511 # Playlist already has the URL
512 mock_item = MagicMock()
513 mock_item.getPath.return_value = "plugin://play_autoscraped"
514 playlist = MagicMock()
515 playlist.size.return_value = 1
516 playlist.__getitem__.return_value = mock_item
517 test_player.PLAYLIST = playlist
518
519 test_player.build_playlist()
520
521 playlist.add.assert_not_called()
522
523
524def test_build_playlist_search_fallback(monkeypatch):
525 """Task 2.3: Uses search URL format when autoscrape disabled."""
526 player_module = _player_module(monkeypatch)
527 test_player = _player_for_episode(player_module)
528 test_player.preferred_group = "GRP"
529 next_data = {"name": "Next Ep", "episode": 2, "season": 1}
530
531 monkeypatch.setattr(test_player, "_get_next_episode_data", lambda: next_data)
532 monkeypatch.setattr(
533 player_module,
534 "get_setting",
535 lambda key, default=None: False if key == "autoscrape_next_episode" else default,
536 )
537 monkeypatch.setattr(
538 player_module,
539 "build_url",
540 lambda action, **kw: "plugin://search",
541 )
542 playlist = MagicMock()
543 playlist.size.return_value = 0
544 test_player.PLAYLIST = playlist
545
546 test_player.build_playlist()
547
548 playlist.add.assert_called_once()
549 call_url = playlist.add.call_args[1]["url"]
550 assert call_url == "plugin://search"
551
552
553def test_handle_subtitles_skips_on_autoplay(monkeypatch):
554 """Task 2.4: Autoplay flag set skips subtitle handling."""
555 player_module = _player_module(monkeypatch)
556 test_player = _player_for_episode(player_module)
557 test_player.data["autoplay"] = True
558 test_player.showSubtitles = MagicMock()
559
560 list_item = MagicMock()
561 test_player.handle_subtitles(list_item)
562
563 test_player.showSubtitles.assert_not_called()
564
565
566def test_advance_across_season_boundary_series_end(monkeypatch):
567 """Task 2.5: At series end (last ep of last season) returns None."""
568 player_module = _player_module(monkeypatch)
569 test_player = _player_for_episode(player_module)
570 episodes = [SimpleNamespace(episode_number=10)]
571 tv_details = SimpleNamespace(number_of_seasons=3)
572
573 result = test_player._advance_across_season_boundary(
574 "123", season=3, episode=10, episodes=episodes, tv_details=tv_details
575 )
576
577 assert result is None
578
579
580def test_advance_across_season_boundary_single_season(monkeypatch):
581 """Task 2.5: Single season show advances within season."""
582 player_module = _player_module(monkeypatch)
583 test_player = _player_for_episode(player_module)
584 episodes = [
585 SimpleNamespace(episode_number=1),
586 SimpleNamespace(episode_number=2),
587 SimpleNamespace(episode_number=3),
588 ]
589 tv_details = SimpleNamespace(number_of_seasons=1)
590
591 result = test_player._advance_across_season_boundary(
592 "123", season=1, episode=1, episodes=episodes, tv_details=tv_details
593 )
594
595 assert result == (1, 2, episodes)
596
597
598def test_advance_across_season_boundary_crosses_season(monkeypatch):
599 """Task 2.5: Season boundary crossing fetches next season episodes."""
600 player_module = _player_module(monkeypatch)
601 test_player = _player_for_episode(player_module)
602 current_episodes = [SimpleNamespace(episode_number=8)]
603 next_episodes = [SimpleNamespace(episode_number=1)]
604 tv_details = SimpleNamespace(number_of_seasons=2)
605
606 monkeypatch.setattr(
607 test_player,
608 "_fetch_season_episodes",
609 lambda tmdb_id, season: next_episodes,
610 )
611
612 result = test_player._advance_across_season_boundary(
613 "123",
614 season=1,
615 episode=8,
616 episodes=current_episodes,
617 tv_details=tv_details,
618 )
619
620 assert result == (2, 1, next_episodes)
621
622
623def test_advance_across_season_boundary_fetch_fails(monkeypatch):
624 """Task 2.5: Next season episodes fetch fails returns None."""
625 player_module = _player_module(monkeypatch)
626 test_player = _player_for_episode(player_module)
627 current_episodes = [SimpleNamespace(episode_number=8)]
628 tv_details = SimpleNamespace(number_of_seasons=2)
629
630 monkeypatch.setattr(
631 test_player,
632 "_fetch_season_episodes",
633 lambda tmdb_id, season: None,
634 )
635
636 result = test_player._advance_across_season_boundary(
637 "123",
638 season=1,
639 episode=8,
640 episodes=current_episodes,
641 tv_details=tv_details,
642 )
643
644 assert result is None
645
646
647def test_handle_next_dialog_action_fallback_uses_season_boundary_episode(monkeypatch):
648 """Without cache, fires background search thread with season-crossing TV data."""
649 player_module = _player_module(monkeypatch)
650 player_module.JacktookPLayer._nextep_queue.clear()
651
652 class FakePlaylist:
653 def size(self):
654 return -1
655
656 def getposition(self):
657 return -1
658
659 def clear(self):
660 self.cleared = True
661
662 test_player = _player_for_episode(player_module, season=1, episode=10)
663 test_player.PLAYLIST = FakePlaylist()
664 threads = []
665
666 class CapturingThread(FakeThread):
667 def __init__(self, *args, **kwargs):
668 super().__init__(*args, **kwargs)
669 threads.append(self)
670
671 monkeypatch.setattr(player_module, "Thread", CapturingThread)
672
673 def fake_get_setting(key, default=None):
674 if key == "playnext_threshold":
675 return 0
676 return default
677
678 monkeypatch.setattr(player_module, "get_setting", fake_get_setting)
679 monkeypatch.setattr(player_module, "get_property", lambda key: "")
680 monkeypatch.setattr(player_module, "clear_property", lambda key: None)
681 monkeypatch.setattr(player_module.cache, "get", lambda key: None)
682 monkeypatch.setattr(
683 test_player,
684 "_get_next_episode_data",
685 lambda: {"name": "Season Two Premiere", "episode": 1, "season": 2},
686 )
687
688 test_player._handle_next_dialog_action()
689
690 assert len(threads) == 1
691 target, args = threads[0].target, threads[0].args
692 assert target == test_player._background_search_and_queue
693 item_data, next_tv_data = args
694 assert item_data["mode"] == "tv"
695 assert next_tv_data == {"name": "Season Two Premiere", "episode": 1, "season": 2}
plugin.video.jacktook/tests/unit/test_player_utils.py +105 -0
……@@ -0,0 +1,105 @@
1from unittest.mock import patch
2from urllib.parse import quote
3
4
5def test_elementum_prefers_url_when_url_and_magnet_exist():
6 from lib.utils.player import utils
7
8 magnet = "magnet:?xt=urn:btih:PRIVATEHASH"
9 url = "https://jackett.example/dl/private-tracker?id=123&apikey=token"
10
11 with patch.object(utils, "is_elementum_addon", return_value=True):
12 elementum_url = utils.get_elementum_url(
13 magnet,
14 url,
15 "movie",
16 {"tmdb_id": "100"},
17 )
18
19 assert f"uri={quote(url)}" in elementum_url
20 assert quote(magnet) not in elementum_url
21
22
23def test_elementum_client_prefers_url_when_url_and_magnet_exist():
24 from lib.utils.general.utils import Players
25 from lib.utils.player import utils
26
27 magnet = "magnet:?xt=urn:btih:PRIVATEHASH"
28 url = "https://jackett.example/dl/private-tracker?id=123&apikey=token"
29
30 with patch.object(utils, "is_elementum_addon", return_value=True):
31 elementum_url = utils.get_torrent_url_for_client(
32 magnet,
33 url,
34 "movie",
35 {"tmdb_id": "100"},
36 client=Players.ELEMENTUM,
37 )
38
39 assert f"uri={quote(url)}" in elementum_url
40 assert quote(magnet) not in elementum_url
41
42
43def test_elementum_uses_magnet_when_only_magnet_exists():
44 from lib.utils.player import utils
45
46 magnet = "magnet:?xt=urn:btih:ONLYMAGNET"
47
48 with patch.object(utils, "is_elementum_addon", return_value=True):
49 magnet_url = utils.get_elementum_url(magnet, "", "movie", {"tmdb_id": "100"})
50
51 assert f"uri={quote(magnet)}" in magnet_url
52
53
54def test_elementum_uses_url_when_only_url_exists():
55 from lib.utils.player import utils
56
57 url = "https://example.com/only-url.torrent"
58
59 with patch.object(utils, "is_elementum_addon", return_value=True):
60 torrent_url = utils.get_elementum_url("", url, "movie", {"tmdb_id": "100"})
61
62 assert f"uri={quote(url)}" in torrent_url
63
64
65def test_jacktorr_playback_saves_metadata_under_infohash_and_magnet_hash():
66 from lib.utils.player import utils
67
68 data = {
69 "title": "FROM",
70 "mode": "tv",
71 "ids": {"imdb_id": "tt9813792"},
72 "tv_data": {"season": 4, "episode": 2, "name": "Fray"},
73 "info_hash": "SOURCEHASH",
74 }
75
76 with patch.object(utils, "is_jacktorr_addon", return_value=True), patch(
77 "lib.utils.general.utils.get_info_hash_from_magnet", return_value="MAGNETHASH"
78 ), patch("lib.utils.torrent.torrserver_utils.save_torrent_meta") as mock_save:
79 url = utils.get_jacktorr_url("magnet:?xt=urn:btih:MAGNETHASH", "", data=data)
80
81 assert url.startswith("plugin://plugin.video.jacktorr/play_magnet")
82 saved_hashes = [call.args[0] for call in mock_save.call_args_list]
83 assert saved_hashes == ["SOURCEHASH", "MAGNETHASH"]
84 assert all(call.args[1]["ids"]["imdb_id"] == "tt9813792" for call in mock_save.call_args_list)
85 assert all(call.args[1]["mode"] == "tv" for call in mock_save.call_args_list)
86
87
88def test_jacktorr_playback_deduplicates_matching_infohash_and_magnet_hash():
89 from lib.utils.player import utils
90
91 data = {
92 "title": "FROM",
93 "mode": "tv",
94 "ids": {"imdb_id": "tt9813792"},
95 "tv_data": {"season": 4, "episode": 2},
96 "info_hash": "SAMEHASH",
97 }
98
99 with patch.object(utils, "is_jacktorr_addon", return_value=True), patch(
100 "lib.utils.general.utils.get_info_hash_from_magnet", return_value="SAMEHASH"
101 ), patch("lib.utils.torrent.torrserver_utils.save_torrent_meta") as mock_save:
102 utils.get_jacktorr_url("magnet:?xt=urn:btih:SAMEHASH", "", data=data)
103
104 mock_save.assert_called_once()
105 assert mock_save.call_args.args[0] == "SAMEHASH"
plugin.video.jacktook/tests/unit/test_playnext_behavior.py +469 -0
……@@ -0,0 +1,469 @@
1import sys
2from types import ModuleType
3from unittest.mock import MagicMock, call
4
5import pytest
6
7
8@pytest.fixture(autouse=True)
9def import_player_with_real_xbmc_player(monkeypatch):
10 import xbmc
11
12 class FakeKodiPlayer:
13 def __init__(self, *args, **kwargs):
14 pass
15
16 monkeypatch.setattr(xbmc, "Player", FakeKodiPlayer)
17 sys.modules.pop("lib.player", None)
18 yield
19 sys.modules.pop("lib.player", None)
20
21
22def _player_instance():
23 from lib.player import JacktookPLayer
24
25 player = object.__new__(JacktookPLayer)
26 player.data = {
27 "mode": "tv",
28 "title": "Show",
29 "ids": {"tmdb_id": "123"},
30 "tv_data": {"season": 1, "episode": 1},
31 }
32 player.playnext = MagicMock()
33 player.play = MagicMock()
34 player.run = MagicMock()
35 player.PLAYLIST = MagicMock()
36 return player
37
38
39def test_handle_next_dialog_action_ignores_playlist_advance_and_uses_handler(monkeypatch):
40 from lib.player import JacktookPLayer
41
42 player = _player_instance()
43 player.PLAYLIST.size.return_value = 2
44 player.PLAYLIST.getposition.return_value = 0
45 clear_property = MagicMock()
46 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: 0)
47 monkeypatch.setattr("lib.player.clear_property", clear_property)
48 monkeypatch.setattr(player, "_get_next_episode_data", lambda: None)
49
50 JacktookPLayer._handle_next_dialog_action(player)
51
52 player.playnext.assert_not_called()
53 player.play.assert_not_called()
54 clear_property.assert_called_once_with("jacktook_next_dialog_action")
55
56
57def test_handle_next_dialog_action_still_watching_cancel_clears_and_stops(monkeypatch):
58 import xbmcgui
59
60 from lib.player import JacktookPLayer
61
62 player = _player_instance()
63 player.PLAYLIST.size.return_value = 2
64 player.PLAYLIST.getposition.return_value = 0
65 clear_property = MagicMock()
66 set_property = MagicMock()
67 dialog = MagicMock()
68 dialog.yesno.return_value = False
69
70 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: 2)
71 monkeypatch.setattr("lib.player.get_property", lambda key: "2")
72 monkeypatch.setattr("lib.player.clear_property", clear_property)
73 monkeypatch.setattr("lib.player.set_property", set_property)
74 monkeypatch.setattr(xbmcgui, "Dialog", lambda: dialog)
75
76 JacktookPLayer._handle_next_dialog_action(player)
77
78 player.playnext.assert_not_called()
79 set_property.assert_not_called()
80 assert clear_property.call_args_list == [
81 call("jacktook_consecutive_autoplays"),
82 call("jacktook_next_dialog_action"),
83 ]
84
85
86def test_check_next_dialog_time_mode_triggers_at_time_left_threshold(monkeypatch):
87 from lib.player import JacktookPLayer
88
89 player = _player_instance()
90 player.total_time = 600
91 player.current_time = 550
92 player.watched_percentage = 91.7
93 player.playback_started_properly = True
94 player.next_dialog = True
95 player.playing_next_time = 50
96 execute_builtin = MagicMock()
97
98 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: False)
99 monkeypatch.setattr("lib.player.action_url_run", lambda **kwargs: "RunPlugin(test)")
100 monkeypatch.setattr("lib.player.xbmc.executebuiltin", execute_builtin)
101
102 JacktookPLayer.check_next_dialog(player)
103
104 execute_builtin.assert_called_once_with("RunPlugin(test)")
105 assert player.next_dialog is False
106
107
108def test_check_next_dialog_passes_authoritative_next_tv_data(monkeypatch):
109 from lib.player import JacktookPLayer
110
111 player = _player_instance()
112 player.data["tv_data"] = {"season": 1, "episode": 3}
113 player.total_time = 600
114 player.current_time = 550
115 player.watched_percentage = 91.7
116 player.playback_started_properly = True
117 player.next_dialog = True
118 player.playing_next_time = 50
119 next_tv_data = {"season": 1, "episode": 4, "name": "Immediate Next"}
120 captured = {}
121
122 monkeypatch.setattr(player, "_get_next_episode_data", MagicMock(return_value=next_tv_data))
123 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: False)
124
125 def fake_action_url_run(**kwargs):
126 captured.update(kwargs)
127 return "RunPlugin(test)"
128
129 execute_builtin = MagicMock()
130 monkeypatch.setattr("lib.player.action_url_run", fake_action_url_run)
131 monkeypatch.setattr("lib.player.xbmc.executebuiltin", execute_builtin)
132
133 JacktookPLayer.check_next_dialog(player)
134
135 execute_builtin.assert_called_once_with("RunPlugin(test)")
136 assert captured["name"] == "run_next_dialog"
137 assert captured["item_info"]["next_tv_data"] == next_tv_data
138 assert player.data["next_tv_data"] == next_tv_data
139 player._get_next_episode_data.assert_called_once_with()
140
141
142def test_check_next_dialog_percentage_mode_triggers_at_percentage_threshold(monkeypatch):
143 from lib.player import JacktookPLayer
144
145 player = _player_instance()
146 player.total_time = 600
147 player.current_time = 570
148 player.watched_percentage = 95.0
149 player.playback_started_properly = True
150 player.next_dialog = True
151 player.playing_next_time = 10
152 execute_builtin = MagicMock()
153
154 def get_setting(key, default=None):
155 return {"playnext_use_percentage": True, "playnext_percentage": 95}.get(key, default)
156
157 monkeypatch.setattr("lib.player.get_setting", get_setting)
158 monkeypatch.setattr("lib.player.action_url_run", lambda **kwargs: "RunPlugin(test)")
159 monkeypatch.setattr("lib.player.xbmc.executebuiltin", execute_builtin)
160
161 JacktookPLayer.check_next_dialog(player)
162
163 execute_builtin.assert_called_once_with("RunPlugin(test)")
164 assert player.next_dialog is False
165
166
167def test_check_next_dialog_does_not_trigger_before_halfway_guard(monkeypatch):
168 from lib.player import JacktookPLayer
169
170 player = _player_instance()
171 player.total_time = 600
172 player.current_time = 299
173 player.watched_percentage = 99.0
174 player.playback_started_properly = True
175 player.next_dialog = True
176 player.playing_next_time = 400
177 execute_builtin = MagicMock()
178
179 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: True)
180 monkeypatch.setattr("lib.player.xbmc.executebuiltin", execute_builtin)
181
182 JacktookPLayer.check_next_dialog(player)
183
184 execute_builtin.assert_not_called()
185 assert player.next_dialog is True
186
187
188def test_play_window_background_tasks_ignores_missing_progress_bar(monkeypatch):
189 from lib.gui.play_window import PlayWindow
190
191 class TestPlayWindow(PlayWindow):
192 def smart_play_action(self):
193 self.smart_play_called = True
194
195 window = object.__new__(TestPlayWindow)
196 window.closed = False
197 window.playing_file = "file.mkv"
198 window.smart_play_called = False
199 window.getControl = MagicMock(side_effect=RuntimeError("missing"))
200 window.getTotalTime = MagicMock(side_effect=[3, 1, 1])
201 window.getTime = MagicMock(return_value=0)
202 window.getPlayingFile = MagicMock(return_value="file.mkv")
203 window.setProperty = MagicMock()
204 window.close = MagicMock()
205 monkeypatch.setattr("lib.gui.play_window.xbmc.sleep", MagicMock())
206
207 window.background_tasks()
208
209 window.close.assert_called_once_with()
210 assert window.smart_play_called is True
211
212
213def test_build_next_episode_properties_prefers_next_tv_data():
214 from lib.gui.play_next_window import build_next_episode_properties
215
216 properties = build_next_episode_properties(
217 {
218 "title": "Current Title",
219 "query": "Show Name",
220 "poster": "poster.jpg",
221 "fanart": "fanart.jpg",
222 "clearlogo": "logo.png",
223 "plot": "Episode plot",
224 "tv_data": {"season": 1, "episode": 1},
225 "next_tv_data": {"season": "2", "episode": "3", "name": "The Next One"},
226 }
227 )
228
229 assert properties["next.title"] == "Show Name"
230 assert properties["next.poster"] == "poster.jpg"
231 assert properties["next.fanart"] == "fanart.jpg"
232 assert properties["next.clearlogo"] == "logo.png"
233 assert properties["next.season"] == "2"
234 assert properties["next.episode"] == "3"
235 assert properties["next.episode_label"] == "S02E03"
236 assert properties["next.episode_name"] == "The Next One"
237 assert properties["next.plot"] == "Episode plot"
238
239
240def test_build_next_episode_properties_infers_next_episode_from_tv_data():
241 from lib.gui.play_next_window import build_next_episode_properties
242
243 properties = build_next_episode_properties(
244 {
245 "title": "Show Name",
246 "tv_data": {"season": "1", "episode": "1"},
247 }
248 )
249
250 assert properties["next.title"] == "Show Name"
251 assert properties["next.season"] == "1"
252 assert properties["next.episode"] == "2"
253 assert properties["next.episode_label"] == "S01E02"
254 assert properties["next.episode_name"] == "Episode 2"
255
256
257def test_build_next_episode_properties_handles_invalid_episode_data():
258 from lib.gui.play_next_window import build_next_episode_properties
259
260 properties = build_next_episode_properties(
261 {
262 "title": "Show Name",
263 "tv_data": {"season": "one", "episode": "two"},
264 }
265 )
266
267 assert properties["next.title"] == "Show Name"
268 assert properties["next.season"] == ""
269 assert properties["next.episode"] == ""
270 assert properties["next.episode_label"] == ""
271 assert properties["next.episode_name"] == ""
272
273
274def test_handle_next_dialog_action_reuses_authoritative_next_tv_data(monkeypatch):
275 from lib.player import JacktookPLayer
276
277 player = _player_instance()
278 player.data["tv_data"] = {"season": 1, "episode": 3}
279 player.data["next_tv_data"] = {"season": 1, "episode": 4, "name": "Immediate Next"}
280 player.PLAYLIST.size.return_value = 2
281 player.PLAYLIST.getposition.return_value = 0
282 recalculator = MagicMock(return_value={"season": 1, "episode": 8, "name": "Wrong Drift"})
283 cache_handler = MagicMock(return_value=True)
284
285 monkeypatch.setattr(player, "_get_next_episode_data", recalculator)
286 monkeypatch.setattr(player, "_queue_from_autoscrape_cache", cache_handler)
287 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: 0)
288 monkeypatch.setattr("lib.player.get_property", lambda key: "")
289
290 JacktookPLayer._handle_next_dialog_action(player)
291
292 player.playnext.assert_not_called()
293 recalculator.assert_not_called()
294 cache_handler.assert_called_once_with(
295 {"season": 1, "episode": 4, "name": "Immediate Next"},
296 {"tmdb_id": "123"},
297 )
298
299
300def test_handle_next_dialog_action_falls_back_when_authoritative_missing(monkeypatch):
301 from lib.player import JacktookPLayer
302
303 player = _player_instance()
304 fallback_next = {"season": 1, "episode": 2, "name": "Fallback Next"}
305 recalculator = MagicMock(return_value=fallback_next)
306 cache_handler = MagicMock(return_value=True)
307
308 monkeypatch.setattr(player, "_get_next_episode_data", recalculator)
309 monkeypatch.setattr(player, "_queue_from_autoscrape_cache", cache_handler)
310 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: 0)
311 monkeypatch.setattr("lib.player.get_property", lambda key: "")
312
313 JacktookPLayer._handle_next_dialog_action(player)
314
315 recalculator.assert_called_once_with()
316 cache_handler.assert_called_once_with(fallback_next, {"tmdb_id": "123"})
317
318
319def test_handle_next_dialog_action_falls_back_when_authoritative_invalid(monkeypatch):
320 from lib.player import JacktookPLayer
321
322 player = _player_instance()
323 player.data["next_tv_data"] = {"season": "bad", "episode": 4, "name": "Invalid"}
324 fallback_next = {"season": 1, "episode": 2, "name": "Fallback Next"}
325 recalculator = MagicMock(return_value=fallback_next)
326 cache_handler = MagicMock(return_value=True)
327
328 monkeypatch.setattr(player, "_get_next_episode_data", recalculator)
329 monkeypatch.setattr(player, "_queue_from_autoscrape_cache", cache_handler)
330 monkeypatch.setattr("lib.player.get_setting", lambda key, default=None: 0)
331 monkeypatch.setattr("lib.player.get_property", lambda key: "")
332
333 JacktookPLayer._handle_next_dialog_action(player)
334
335 recalculator.assert_called_once_with()
336 cache_handler.assert_called_once_with(fallback_next, {"tmdb_id": "123"})
337
338
339def test_drain_nextep_queue_source_select_cancel_does_not_force_container_update(monkeypatch):
340 from lib.player import JacktookPLayer
341
342 player = _player_instance()
343 JacktookPLayer._nextep_queue.clear()
344 JacktookPLayer._nextep_queue.append(
345 {
346 "data": {
347 "mode": "tv",
348 "ids": {"tmdb_id": "123"},
349 "tv_data": {"season": 1, "episode": 4},
350 "query": "Show",
351 "media_type": "tv",
352 },
353 "results": [{"name": "Source 1"}],
354 }
355 )
356 show_source_select = MagicMock(return_value=False)
357 fake_search = ModuleType("lib.search")
358 fake_search.show_source_select = show_source_select
359 execute_builtin = MagicMock()
360 close_busy_dialog = MagicMock()
361 close_all_dialog = MagicMock()
362 clear_property = MagicMock()
363
364 monkeypatch.setitem(sys.modules, "lib.search", fake_search)
365 monkeypatch.setattr("lib.utils.kodi.settings.auto_play_enabled", lambda: False)
366 monkeypatch.setattr("lib.player.xbmc.executebuiltin", execute_builtin)
367 monkeypatch.setattr("lib.player.close_busy_dialog", close_busy_dialog)
368 monkeypatch.setattr("lib.player.close_all_dialog", close_all_dialog)
369 monkeypatch.setattr("lib.player.clear_property", clear_property)
370
371 try:
372 assert JacktookPLayer._drain_nextep_queue(player) is True
373 finally:
374 JacktookPLayer._nextep_queue.clear()
375
376 show_source_select.assert_called_once()
377 assert not any(
378 call_args.args
379 and isinstance(call_args.args[0], str)
380 and "Container.Update" in call_args.args[0]
381 for call_args in execute_builtin.call_args_list
382 )
383 close_busy_dialog.assert_called_once_with()
384 close_all_dialog.assert_called_once_with()
385 clear_property.assert_called_once_with("jacktook_next_dialog_action")
386 player.PLAYLIST.clear.assert_called_once_with()
387
388
389def _make_play_next(monkeypatch, auto_timeout=10):
390 """Create a PlayNext instance without calling __init__ for testing."""
391 from lib.gui.play_next_window import PlayNext
392
393 window = object.__new__(PlayNext)
394 window.auto_timeout = auto_timeout
395 window.closed = False
396 window.playing_file = "file.mkv"
397 window.player = MagicMock()
398 window.action = None
399 window.handle_action = MagicMock()
400 window.setProperty = MagicMock()
401 window.close = MagicMock()
402 window.doModal = MagicMock()
403 window.calculate_percent = MagicMock(return_value=50.0)
404 window.getControl = MagicMock(side_effect=RuntimeError("missing"))
405 return window
406
407
408def test_play_next_background_tasks_timeout_reached(monkeypatch):
409 """Task 3.1: Countdown reaches zero triggers auto-play."""
410 monkeypatch.setattr("lib.gui.play_next_window.xbmc.sleep", lambda ms: None)
411 window = _make_play_next(monkeypatch, auto_timeout=3)
412 window.getTotalTime = MagicMock(return_value=600)
413 window.getTime = MagicMock(return_value=500)
414 window.getPlayingFile = MagicMock(return_value="file.mkv")
415
416 window.background_tasks()
417
418 window.handle_action.assert_called_once_with(7, 3001)
419 window.close.assert_called_once_with()
420
421
422def test_play_next_background_tasks_episode_ended(monkeypatch):
423 """Task 3.1: Episode ends (<= 2s remaining) triggers immediate auto-play."""
424 monkeypatch.setattr("lib.gui.play_next_window.xbmc.sleep", lambda ms: None)
425 window = _make_play_next(monkeypatch, auto_timeout=10)
426 window.getTotalTime = MagicMock(return_value=600)
427 window.getTime = MagicMock(return_value=598)
428 window.getPlayingFile = MagicMock(return_value="file.mkv")
429
430 window.background_tasks()
431
432 # handle_action called, then early return (close not called directly
433 # because handle_action is mocked; in production handle_action calls close)
434 window.handle_action.assert_called_once_with(7, 3001)
435
436
437def test_play_next_background_tasks_window_closed(monkeypatch):
438 """Task 3.1: Window closed during countdown exits without auto-play."""
439 monkeypatch.setattr("lib.gui.play_next_window.xbmc.sleep", lambda ms: None)
440 window = _make_play_next(monkeypatch, auto_timeout=10)
441 window.getTotalTime = MagicMock(return_value=600)
442 window.getTime = MagicMock(return_value=500)
443 window.getPlayingFile = MagicMock(return_value="file.mkv")
444 window.closed = True # Window already closed before loop starts
445
446 window.background_tasks()
447
448 window.handle_action.assert_not_called()
449 window.close.assert_called_once_with()
450
451
452def test_play_next_background_tasks_legacy_mode(monkeypatch):
453 """Task 3.1: Legacy mode (timeout <= 0) uses episode-remaining timer."""
454 monkeypatch.setattr("lib.gui.play_next_window.xbmc.sleep", lambda ms: None)
455 # Mock smart_play_action to avoid real implementation
456 monkeypatch.setattr(
457 "lib.gui.play_next_window.PlayNext.smart_play_action",
458 lambda self: None,
459 )
460 window = _make_play_next(monkeypatch, auto_timeout=0)
461 # Set near end so super().background_tasks() loop exits immediately
462 window.getTotalTime = MagicMock(return_value=600)
463 window.getTime = MagicMock(return_value=598)
464 window.getPlayingFile = MagicMock(return_value="file.mkv")
465
466 window.background_tasks()
467
468 window.setProperty.assert_any_call("timer_label", "Playing in {} seconds")
469 window.close.assert_called_once_with()
plugin.video.jacktook/tests/unit/test_preprocess_filters.py +71 -0
……@@ -78,3 +78,74 @@ def test_pre_process_excludes_unknown_quality_when_disabled():
7878 filtered = pre_process(results, mode="movies", episode_name="", episode=0, season=0)
7979
8080 assert [item.title for item in filtered] == ["Movie.2024.1080p.WEB-DL"]
81
82
83def test_pre_process_excludes_disabled_codec():
84 results = [
85 TorrentStream(title="Movie.2024.HEVC-GROUP", type="Torrent"),
86 TorrentStream(title="Movie.2024.H264-GROUP", type="Torrent"),
87 TorrentStream(title="Movie.2024.AV1-GROUP", type="Torrent"),
88 ]
89
90 with patch("lib.utils.general.utils.get_setting") as get_setting, patch(
91 "lib.utils.general.processors.get_setting"
92 ) as processor_get_setting:
93 get_setting.side_effect = lambda key, default=None: {
94 "unknown_enabled": True,
95 "unknown_quality_enabled": True,
96 "bluray_hd_enabled": True,
97 "web_hd_enabled": True,
98 "dvd_tv_enabled": True,
99 "cam_screener_enabled": True,
100 "filter_size_enabled": False,
101 "codec_hevc_enabled": False,
102 "codec_av1_enabled": True,
103 "codec_h264_enabled": True,
104 "dolby_vision_enabled": True,
105 "hdr10_enabled": True,
106 "hdr_enabled": True,
107 }.get(key, default)
108 processor_get_setting.side_effect = get_setting.side_effect
109
110 filtered = pre_process(results, mode="movies", episode_name="", episode=0, season=0)
111
112 assert [item.title for item in filtered] == [
113 "Movie.2024.H264-GROUP",
114 "Movie.2024.AV1-GROUP",
115 ]
116
117
118def test_pre_process_excludes_disabled_hdr():
119 results = [
120 TorrentStream(title="Movie.2024.DV.HEVC-GROUP", type="Torrent"),
121 TorrentStream(title="Movie.2024.HDR10.HEVC-GROUP", type="Torrent"),
122 TorrentStream(title="Movie.2024.HDR.H264-GROUP", type="Torrent"),
123 TorrentStream(title="Movie.2024.SDR.H264-GROUP", type="Torrent"),
124 ]
125
126 with patch("lib.utils.general.utils.get_setting") as get_setting, patch(
127 "lib.utils.general.processors.get_setting"
128 ) as processor_get_setting:
129 get_setting.side_effect = lambda key, default=None: {
130 "unknown_enabled": True,
131 "unknown_quality_enabled": True,
132 "bluray_hd_enabled": True,
133 "web_hd_enabled": True,
134 "dvd_tv_enabled": True,
135 "cam_screener_enabled": True,
136 "filter_size_enabled": False,
137 "codec_hevc_enabled": True,
138 "codec_av1_enabled": True,
139 "codec_h264_enabled": True,
140 "dolby_vision_enabled": False,
141 "hdr10_enabled": True,
142 "hdr_enabled": False,
143 }.get(key, default)
144 processor_get_setting.side_effect = get_setting.side_effect
145
146 filtered = pre_process(results, mode="movies", episode_name="", episode=0, season=0)
147
148 assert [item.title for item in filtered] == [
149 "Movie.2024.HDR10.HEVC-GROUP",
150 "Movie.2024.SDR.H264-GROUP",
151 ]
plugin.video.jacktook/tests/unit/test_processors.py +214 -0
……@@ -0,0 +1,214 @@
1from unittest.mock import patch
2
3from lib.domain.torrent import TorrentStream
4from lib.utils.general.processors import PreProcessBuilder
5
6
7def test_extract_codec_hdr_sets_fields():
8 results = [
9 TorrentStream(title="Movie.2024.HEVC.DV-GROUP"),
10 TorrentStream(title="Show.S01E01.H264-GROUP"),
11 TorrentStream(title="Movie.2024.AV1-GROUP"),
12 ]
13 builder = PreProcessBuilder(results)
14 builder.extract_codec_hdr()
15 assert builder.results[0].codec == "HEVC"
16 assert builder.results[0].hdr == "DV"
17 assert builder.results[1].codec == "H.264"
18 assert builder.results[1].hdr == ""
19 assert builder.results[2].codec == "AV1"
20 assert builder.results[2].hdr == ""
21
22
23def test_filter_by_codec_all_enabled_keeps_everything():
24 results = [
25 TorrentStream(title="Movie.HEVC", codec="HEVC"),
26 TorrentStream(title="Movie.H264", codec="H.264"),
27 TorrentStream(title="Movie.AV1", codec="AV1"),
28 TorrentStream(title="Movie.Unknown", codec=""),
29 ]
30 builder = PreProcessBuilder(results)
31
32 with patch("lib.utils.general.processors.get_setting") as get_setting:
33 get_setting.side_effect = lambda key, default=None: {
34 "codec_hevc_enabled": True,
35 "codec_av1_enabled": True,
36 "codec_h264_enabled": True,
37 }.get(key, default)
38 builder.filter_by_codec()
39
40 assert [r.title for r in builder.results] == [
41 "Movie.HEVC",
42 "Movie.H264",
43 "Movie.AV1",
44 "Movie.Unknown",
45 ]
46
47
48def test_filter_by_codec_disables_hevc():
49 results = [
50 TorrentStream(title="Movie.HEVC", codec="HEVC"),
51 TorrentStream(title="Movie.H264", codec="H.264"),
52 TorrentStream(title="Movie.AV1", codec="AV1"),
53 TorrentStream(title="Movie.Unknown", codec=""),
54 ]
55 builder = PreProcessBuilder(results)
56
57 with patch("lib.utils.general.processors.get_setting") as get_setting:
58 get_setting.side_effect = lambda key, default=None: {
59 "codec_hevc_enabled": False,
60 "codec_av1_enabled": True,
61 "codec_h264_enabled": True,
62 }.get(key, default)
63 builder.filter_by_codec()
64
65 assert [r.title for r in builder.results] == [
66 "Movie.H264",
67 "Movie.AV1",
68 "Movie.Unknown",
69 ]
70
71
72def test_filter_by_codec_disables_av1():
73 results = [
74 TorrentStream(title="Movie.HEVC", codec="HEVC"),
75 TorrentStream(title="Movie.H264", codec="H.264"),
76 TorrentStream(title="Movie.AV1", codec="AV1"),
77 TorrentStream(title="Movie.Unknown", codec=""),
78 ]
79 builder = PreProcessBuilder(results)
80
81 with patch("lib.utils.general.processors.get_setting") as get_setting:
82 get_setting.side_effect = lambda key, default=None: {
83 "codec_hevc_enabled": True,
84 "codec_av1_enabled": False,
85 "codec_h264_enabled": True,
86 }.get(key, default)
87 builder.filter_by_codec()
88
89 assert [r.title for r in builder.results] == [
90 "Movie.HEVC",
91 "Movie.H264",
92 "Movie.Unknown",
93 ]
94
95
96def test_filter_by_codec_disables_h264():
97 results = [
98 TorrentStream(title="Movie.HEVC", codec="HEVC"),
99 TorrentStream(title="Movie.H264", codec="H.264"),
100 TorrentStream(title="Movie.AV1", codec="AV1"),
101 TorrentStream(title="Movie.Unknown", codec=""),
102 ]
103 builder = PreProcessBuilder(results)
104
105 with patch("lib.utils.general.processors.get_setting") as get_setting:
106 get_setting.side_effect = lambda key, default=None: {
107 "codec_hevc_enabled": True,
108 "codec_av1_enabled": True,
109 "codec_h264_enabled": False,
110 }.get(key, default)
111 builder.filter_by_codec()
112
113 assert [r.title for r in builder.results] == [
114 "Movie.HEVC",
115 "Movie.AV1",
116 "Movie.Unknown",
117 ]
118
119
120def test_filter_by_hdr_all_enabled_keeps_everything():
121 results = [
122 TorrentStream(title="Movie.DV", hdr="DV"),
123 TorrentStream(title="Movie.HDR10", hdr="HDR10"),
124 TorrentStream(title="Movie.HDR", hdr="HDR"),
125 TorrentStream(title="Movie.SDR", hdr=""),
126 ]
127 builder = PreProcessBuilder(results)
128
129 with patch("lib.utils.general.processors.get_setting") as get_setting:
130 get_setting.side_effect = lambda key, default=None: {
131 "dolby_vision_enabled": True,
132 "hdr10_enabled": True,
133 "hdr_enabled": True,
134 }.get(key, default)
135 builder.filter_by_hdr()
136
137 assert [r.title for r in builder.results] == [
138 "Movie.DV",
139 "Movie.HDR10",
140 "Movie.HDR",
141 "Movie.SDR",
142 ]
143
144
145def test_filter_by_hdr_disables_dolby_vision():
146 results = [
147 TorrentStream(title="Movie.DV", hdr="DV"),
148 TorrentStream(title="Movie.HDR10", hdr="HDR10"),
149 TorrentStream(title="Movie.HDR", hdr="HDR"),
150 TorrentStream(title="Movie.SDR", hdr=""),
151 ]
152 builder = PreProcessBuilder(results)
153
154 with patch("lib.utils.general.processors.get_setting") as get_setting:
155 get_setting.side_effect = lambda key, default=None: {
156 "dolby_vision_enabled": False,
157 "hdr10_enabled": True,
158 "hdr_enabled": True,
159 }.get(key, default)
160 builder.filter_by_hdr()
161
162 assert [r.title for r in builder.results] == [
163 "Movie.HDR10",
164 "Movie.HDR",
165 "Movie.SDR",
166 ]
167
168
169def test_filter_by_hdr_disables_hdr10():
170 results = [
171 TorrentStream(title="Movie.DV", hdr="DV"),
172 TorrentStream(title="Movie.HDR10", hdr="HDR10"),
173 TorrentStream(title="Movie.HDR", hdr="HDR"),
174 TorrentStream(title="Movie.SDR", hdr=""),
175 ]
176 builder = PreProcessBuilder(results)
177
178 with patch("lib.utils.general.processors.get_setting") as get_setting:
179 get_setting.side_effect = lambda key, default=None: {
180 "dolby_vision_enabled": True,
181 "hdr10_enabled": False,
182 "hdr_enabled": True,
183 }.get(key, default)
184 builder.filter_by_hdr()
185
186 assert [r.title for r in builder.results] == [
187 "Movie.DV",
188 "Movie.HDR",
189 "Movie.SDR",
190 ]
191
192
193def test_filter_by_hdr_disables_generic_hdr():
194 results = [
195 TorrentStream(title="Movie.DV", hdr="DV"),
196 TorrentStream(title="Movie.HDR10", hdr="HDR10"),
197 TorrentStream(title="Movie.HDR", hdr="HDR"),
198 TorrentStream(title="Movie.SDR", hdr=""),
199 ]
200 builder = PreProcessBuilder(results)
201
202 with patch("lib.utils.general.processors.get_setting") as get_setting:
203 get_setting.side_effect = lambda key, default=None: {
204 "dolby_vision_enabled": True,
205 "hdr10_enabled": True,
206 "hdr_enabled": False,
207 }.get(key, default)
208 builder.filter_by_hdr()
209
210 assert [r.title for r in builder.results] == [
211 "Movie.DV",
212 "Movie.HDR10",
213 "Movie.SDR",
214 ]
plugin.video.jacktook/tests/unit/test_prowlarr.py +3 -4
……@@ -1,6 +1,7 @@
1import pytest
21from unittest.mock import MagicMock, patch
32
3import pytest
4
45from lib.clients.prowlarr import Prowlarr
56
67
……@@ -63,9 +64,7 @@ def test_prowlarr_search_variant_mapping(prowlarr_client):
6364
6465 with patch("lib.clients.prowlarr.get_prowlarr_timeout", return_value=10):
6566 # Test with variant and year
66 prowlarr_client.search(
67 "Spirited Away", "movies", variant=None, year=2001
68 )
67 prowlarr_client.search("Spirited Away", "movies", variant=None, year=2001)
6968
7069 call_args = mock_get.call_args
7170 params = call_args[1]["params"]
plugin.video.jacktook/tests/unit/test_realdebrid.py +7 -10
……@@ -1,24 +1,21 @@
1import pytest
2from unittest.mock import MagicMock, patch
3import os
41import json
2import os
3from unittest.mock import MagicMock, patch
4
5import pytest
56
67from lib.api.debrid.realdebrid import RealDebrid
78
89# Load fixture data
9FIXTURE_PATH = os.path.join(
10 os.path.dirname(__file__), "../fixtures/realdebrid_response.json"
11)
12with open(FIXTURE_PATH, "r") as f:
10FIXTURE_PATH = os.path.join(os.path.dirname(__file__), "../fixtures/realdebrid_response.json")
11with open(FIXTURE_PATH) as f:
1312 JSON_DATA = json.load(f)
1413
1514
1615@pytest.fixture
1716def rd_client():
1817 mock_session = MagicMock()
19 with patch.object(
20 RealDebrid, "decode_token_str", return_value={"private_token": "secret"}
21 ):
18 with patch.object(RealDebrid, "decode_token_str", return_value={"private_token": "secret"}):
2219 client = RealDebrid(token="test_token", session=mock_session)
2320 client.headers = {"Authorization": f"Bearer {client.token}"}
2421 return client
plugin.video.jacktook/tests/unit/test_realdebrid_helper.py +3 -2
……@@ -1,6 +1,7 @@
1import pytest
21from unittest.mock import MagicMock
32
3import pytest
4
45from lib.api.debrid.base import ProviderException
56from lib.clients.debrid.realdebrid import RealDebridHelper
67
……@@ -19,7 +20,7 @@ def test_get_link_multi_file_movie_uses_largest_selected_file():
1920 ],
2021 }
2122 helper.client.create_download_link.side_effect = lambda link: {
22 "download": "https://download/{}".format(link)
23 "download": f"https://download/{link}"
2324 }
2425
2526 result = helper.get_link("info-hash", {})
plugin.video.jacktook/tests/unit/test_router.py +50 -3
……@@ -2,6 +2,8 @@ import importlib
22import sys
33from unittest.mock import patch
44
5import pytest
6
57
68def _load_router_module():
79 if "lib.router" in sys.modules:
……@@ -13,6 +15,8 @@ def test_get_route_handler_returns_debrid_dispatcher():
1315 router = _load_router_module()
1416
1517 assert router._get_route_handler("rd_auth") is router._route_debrid
18 assert router._get_route_handler("get_oc_downloads") is router._route_debrid
19 assert router._get_route_handler("offcloud_info") is router._route_debrid
1620
1721
1822def test_get_route_handler_returns_cache_dispatcher():
……@@ -27,6 +31,16 @@ def test_get_route_handler_returns_download_dispatcher_for_handle_download_file(
2731 assert router._get_route_handler("handle_download_file") is router._route_downloads
2832
2933
34@pytest.mark.parametrize(
35 "action",
36 ["rename_stremio_addon", "rename_stremio_catalog"],
37)
38def test_get_route_handler_returns_stremio_dispatcher_for_rename_actions(action):
39 router = _load_router_module()
40
41 assert router._get_route_handler(action) is router._route_stremio
42
43
3044def test_get_route_handler_falls_back_to_core_dispatcher():
3145 router = _load_router_module()
3246
……@@ -50,9 +64,7 @@ def test_addon_router_uses_grouped_dispatcher():
5064 "lib.router._route_debrid"
5165 ) as route_debrid, patch(
5266 "lib.router._get_route_handler", return_value=route_debrid
53 ) as get_route_handler, patch(
54 "lib.utils.tmdb_init.ensure_tmdb_init"
55 ):
67 ) as get_route_handler, patch("lib.utils.tmdb_init.ensure_tmdb_init"):
5668 router.addon_router()
5769
5870 get_route_handler.assert_called_once_with("rd_auth")
……@@ -88,3 +100,38 @@ def test_route_downloads_dispatches_handle_download_file():
88100 router._route_downloads("handle_download_file", params)
89101
90102 handle_download_file.assert_called_once_with(params)
103
104
105def test_get_route_handler_returns_source_manager_dispatcher():
106 router = _load_router_module()
107
108 assert router._get_route_handler("source_manager_toggle") is router._route_source_manager
109
110
111def test_route_source_manager_opens_dialog():
112 router = _load_router_module()
113
114 with patch("lib.gui.source_manager_dialog.open_source_manager_dialog") as mock_dialog:
115 router._route_source_manager("source_manager_toggle", {})
116
117 mock_dialog.assert_called_once_with()
118
119
120def test_route_torrserver_download_torrent_subtitles():
121 router = _load_router_module()
122
123 params = {"hash": "abc123", "meta": "{}"}
124 with patch("lib.utils.torrent.torrserver_utils.download_torrent_subtitles") as mock_dl:
125 router._route_torrserver("download_torrent_subtitles", params)
126
127 mock_dl.assert_called_once_with(params)
128
129
130def test_route_torrserver_download_and_play_subtitles():
131 router = _load_router_module()
132
133 params = {"hash": "abc123", "file_id": "1", "path": "movie.mkv", "meta": "{}"}
134 with patch("lib.utils.torrent.torrserver_utils.download_and_play_subtitles") as mock_dl:
135 router._route_torrserver("download_and_play_subtitles", params)
136
137 mock_dl.assert_called_once_with(params)
plugin.video.jacktook/tests/unit/test_search.py +522 -9
……@@ -1,7 +1,17 @@
1from unittest.mock import patch
1import json
2from unittest.mock import MagicMock, patch
23
34from lib.api.tmdbv3api.as_obj import AsObj
4from lib.search import SearchVariant, _build_title_fallback_queries, show_source_select
5from lib.search import (
6 SearchVariant,
7 _build_title_fallback_queries,
8 _check_search_caches,
9 _is_source_enabled,
10 run_search_entry,
11 search_client,
12 show_source_select,
13)
14from lib.utils.general.utils import Indexer
515
616
717def test_search_variant_values():
……@@ -23,9 +33,7 @@ def test_build_title_fallback_queries_default_variant_uses_tmdb_titles():
2333 }
2434 )
2535
26 with patch(
27 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
28 ):
36 with patch("lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details):
2937 queries = _build_title_fallback_queries(
3038 "Ataque a los Titanes",
3139 {"tmdb_id": "1429"},
……@@ -47,9 +55,7 @@ def test_build_title_fallback_queries_supports_variants_and_year():
4755 }
4856 )
4957
50 with patch(
51 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
52 ):
58 with patch("lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details):
5359 title_year_queries = _build_title_fallback_queries(
5460 "Ataque a los Titanes",
5561 {"tmdb_id": "1429"},
……@@ -71,7 +77,11 @@ def test_build_title_fallback_queries_supports_variants_and_year():
7177 year=2013,
7278 )
7379
74 assert title_year_queries == ["Ataque a los Titanes 2013"]
80 assert title_year_queries == [
81 "Ataque a los Titanes 2013",
82 "Attack on Titan 2013",
83 "Shingeki no Kyojin 2013",
84 ]
7585 assert original_title_queries == ["Shingeki no Kyojin"]
7686 assert original_title_year_queries == ["Shingeki no Kyojin 2013"]
7787
……@@ -96,3 +106,506 @@ def test_show_source_select_passes_year_from_media_metadata():
96106 item_info = source_select_mock.call_args[0][0]
97107 assert item_info["year"] == 2010
98108 assert item_info["query"] == "Inception"
109
110
111def test_show_source_select_playnext_context_sets_direct_without_autoplay():
112 results = [object()]
113
114 with patch("lib.search.build_media_metadata", return_value={}), patch(
115 "lib.search.source_select", return_value=True
116 ) as source_select_mock:
117 show_source_select(
118 results,
119 "tv",
120 {"tmdb_id": "123"},
121 {"season": 1, "episode": 2},
122 "Show",
123 "tv",
124 False,
125 autoplay_context="1",
126 )
127
128 item_info = source_select_mock.call_args[0][0]
129 assert item_info["playnext_context"] is True
130 assert item_info["direct_play"] is True
131 assert "autoplay" not in item_info
132
133
134def test_run_search_entry_source_select_cancel_skipped_on_back():
135 params = {
136 "query": "Show",
137 "mode": "tv",
138 "media_type": "tv",
139 "ids": json.dumps({"tmdb_id": "123"}),
140 "tv_data": json.dumps({"season": 1, "episode": 2}),
141 "skip_cancel_on_back": True,
142 }
143
144 with patch("lib.search._handle_super_quick_play", return_value=False), patch(
145 "lib.search.search_client", return_value=[object()]
146 ), patch(
147 "lib.search._process_search_results", return_value=[object()]
148 ), patch("lib.search.set_content_type"), patch(
149 "lib.search.set_watched_title"
150 ), patch("lib.search.auto_play_enabled", return_value=False), patch(
151 "lib.search.show_source_select", return_value=False
152 ), patch("lib.search.cancel_playback") as cancel_mock:
153 run_search_entry(params)
154
155 cancel_mock.assert_not_called()
156
157
158def test_run_search_entry_source_select_cancel_on_back_when_not_skipped():
159 params = {
160 "query": "Show",
161 "mode": "tv",
162 "media_type": "tv",
163 "ids": json.dumps({"tmdb_id": "123"}),
164 "tv_data": json.dumps({"season": 1, "episode": 2}),
165 "skip_cancel_on_back": False,
166 }
167
168 with patch("lib.search._handle_super_quick_play", return_value=False), patch(
169 "lib.search.search_client", return_value=[object()]
170 ), patch(
171 "lib.search._process_search_results", return_value=[object()]
172 ), patch("lib.search.set_content_type"), patch(
173 "lib.search.set_watched_title"
174 ), patch("lib.search.auto_play_enabled", return_value=False), patch(
175 "lib.search.show_source_select", return_value=False
176 ), patch("lib.search.cancel_playback") as cancel_mock:
177 run_search_entry(params)
178
179 cancel_mock.assert_called_once_with()
180
181
182def test_run_search_entry_passes_decoded_episode_name_to_result_processing():
183 params = {
184 "query": "Show",
185 "mode": "tv",
186 "media_type": "tv",
187 "ids": json.dumps({"tmdb_id": "123"}),
188 "tv_data": json.dumps(
189 {
190 "name": "The%20Scales%20%26%20the%20Sword",
191 "season": "2",
192 "episode": "3",
193 }
194 ),
195 "skip_cancel_on_back": True,
196 }
197
198 with patch("lib.search._handle_super_quick_play", return_value=False), patch(
199 "lib.search.search_client", return_value=[object()]
200 ), patch(
201 "lib.search._process_search_results", return_value=[object()]
202 ) as process_results_mock, patch("lib.search.set_content_type"), patch(
203 "lib.search.set_watched_title"
204 ), patch("lib.search.auto_play_enabled", return_value=False), patch(
205 "lib.search.show_source_select", return_value=False
206 ):
207 run_search_entry(params)
208
209 process_args = process_results_mock.call_args[0]
210 assert process_args[2] == "The Scales & the Sword"
211 assert process_args[3] == 3
212 assert process_args[4] == 2
213
214
215def test_is_source_enabled_returns_true_when_cache_empty():
216 with patch("lib.search.cache") as mock_cache:
217 mock_cache.get.return_value = None
218 assert _is_source_enabled(Indexer.JACKETT) is True
219 assert _is_source_enabled(Indexer.STREMIO, "some_addon") is True
220
221
222def test_is_source_enabled_returns_true_when_cache_invalid():
223 with patch("lib.search.cache") as mock_cache:
224 mock_cache.get.return_value = "not-json"
225 assert _is_source_enabled(Indexer.JACKETT) is True
226
227
228@patch("lib.search.get_setting")
229def test_is_source_enabled_checks_builtin_by_indexer_name(mock_get_setting):
230 def setting_side_effect(key):
231 return key in ("jackett_enabled", "prowlarr_enabled")
232
233 mock_get_setting.side_effect = setting_side_effect
234 with patch("lib.search.cache") as mock_cache:
235 mock_cache.get.return_value = json.dumps(["Jackett", "Prowlarr"])
236 assert _is_source_enabled(Indexer.JACKETT) is True
237 assert _is_source_enabled(Indexer.BURST) is False
238 assert _is_source_enabled(Indexer.PROWLARR) is True
239
240
241def test_is_source_enabled_checks_stremio_addon_key():
242 with patch("lib.search.cache") as mock_cache:
243 mock_cache.get.return_value = json.dumps(["Stremio:addon1|url1", "Jackett"])
244 assert _is_source_enabled(Indexer.STREMIO, "addon1|url1") is True
245 assert _is_source_enabled(Indexer.STREMIO, "addon2|url2") is False
246
247
248@patch("lib.search.get_setting")
249def test_is_source_enabled_strict_whitelist_ignores_settings(mock_get_setting):
250 """A source enabled in settings but deselected in Manage Sources must not search."""
251
252 def setting_side_effect(key):
253 return key in ("jackett_enabled", "prowlarr_enabled")
254
255 mock_get_setting.side_effect = setting_side_effect
256 with patch("lib.search.cache") as mock_cache:
257 mock_cache.get.return_value = json.dumps(["Jackett"])
258 assert _is_source_enabled(Indexer.JACKETT) is True
259 # Prowlarr is enabled in settings but was deselected in Manage Sources
260 assert _is_source_enabled(Indexer.PROWLARR) is False
261 # Burst is not enabled in settings and not selected
262 assert _is_source_enabled(Indexer.BURST) is False
263
264
265@patch("lib.search.get_setting")
266def test_is_source_enabled_external_scraper_respects_selection(mock_get_setting):
267 """External scraper must respect Manage Sources selection by module name."""
268
269 def setting_side_effect(key):
270 if key == "external_scraper_enabled":
271 return True
272 if key == "external_scraper_module_name":
273 return "cocoscrapers"
274 return False
275
276 mock_get_setting.side_effect = setting_side_effect
277 with patch("lib.search.cache") as mock_cache:
278 mock_cache.get.return_value = json.dumps(["Jackett"])
279 assert _is_source_enabled(Indexer.EXTERNAL_SCRAPER) is False
280
281 with patch("lib.search.cache") as mock_cache:
282 mock_cache.get.return_value = json.dumps(["cocoscrapers"])
283 assert _is_source_enabled(Indexer.EXTERNAL_SCRAPER) is True
284
285 # Fallback literal supports older caches
286 with patch("lib.search.cache") as mock_cache:
287 mock_cache.get.return_value = json.dumps(["External Scraper"])
288 assert _is_source_enabled(Indexer.EXTERNAL_SCRAPER) is True
289
290
291@patch("lib.search.get_setting")
292@patch("lib.search.cache")
293@patch("lib.search._perform_search_with_title_fallback")
294@patch("lib.search._perform_search")
295def test_submit_search_tasks_skips_disabled_sources(
296 mock_perform_search,
297 mock_perform_fallback,
298 mock_cache,
299 mock_get_setting,
300):
301 def setting_side_effect(key):
302 if key == "jackett_enabled":
303 return True
304 return False
305
306 mock_get_setting.side_effect = setting_side_effect
307 mock_cache.get.return_value = json.dumps(["Jackett"])
308 executor = MagicMock()
309 tasks = []
310 dialog = MagicMock()
311
312 from lib.search import _submit_search_tasks
313
314 _submit_search_tasks(
315 executor,
316 tasks,
317 dialog,
318 "query",
319 "movies",
320 "movie",
321 1,
322 1,
323 {"imdb_id": "tt123"},
324 "",
325 "123",
326 "tt123",
327 True,
328 )
329
330 # Only Jackett task should be added.
331 # Burst/Prowlarr/Jackgram/Easynews/Stremio/ExternalScraper are disabled.
332 assert executor.submit.call_count == 1
333
334
335@patch("lib.search.get_setting")
336@patch("lib.search.cache")
337@patch("lib.search.get_selected_stream_addons")
338@patch("lib.search.get_addon_display_name")
339def test_submit_search_tasks_managed_filters_stremio_addons(
340 mock_get_display_name,
341 mock_get_addons,
342 mock_cache,
343 mock_get_setting,
344):
345 mock_get_setting.return_value = True
346 mock_cache.get.return_value = json.dumps(["Stremio:addon1|url1"])
347 mock_get_display_name.side_effect = lambda addon: addon.manifest.name
348
349 addon1 = MagicMock()
350 addon1.key.return_value = "addon1|url1"
351 addon1.manifest.name = "Addon One"
352 addon1.url.return_value = "http://a.com"
353
354 addon2 = MagicMock()
355 addon2.key.return_value = "addon2|url2"
356 addon2.manifest.name = "Addon Two"
357 addon2.url.return_value = "http://b.com"
358
359 mock_get_addons.return_value = [addon1, addon2]
360
361 manager = MagicMock()
362 dialog = MagicMock()
363
364 from lib.search import _submit_search_tasks_managed
365
366 _submit_search_tasks_managed(
367 manager,
368 dialog,
369 "query",
370 "tv",
371 "tv",
372 1,
373 1,
374 {"imdb_id": "tt123"},
375 "",
376 "123",
377 "tt123",
378 )
379
380 # Only addon1 should be submitted
381 stremio_calls = [
382 call for call in manager.submit_task.call_args_list if call[0][1] == Indexer.STREMIO
383 ]
384 assert len(stremio_calls) == 1
385 assert stremio_calls[0][1]["scoped_addon_url"] == "http://a.com"
386
387
388# ---------------------------------------------------------------------------
389# _check_search_caches
390# ---------------------------------------------------------------------------
391
392
393class TestCheckSearchCaches:
394 """Cover all branches of _check_search_caches."""
395
396 def test_standard_cache_hit_returns_results(self):
397 """Standard cache hit returns cached results immediately (even empty list)."""
398 cached = [MagicMock(), MagicMock()]
399 with patch("lib.search.get_cached_results", return_value=cached):
400 result = _check_search_caches("q", {}, "movies", "movie", 0, 0, "scope")
401 assert result == cached
402
403 def test_standard_cache_hit_empty_list_returns_empty(self):
404 """Standard cache hit with ``[]`` returns empty list, not None."""
405 with patch("lib.search.get_cached_results", return_value=[]):
406 result = _check_search_caches("q", {}, "movies", "movie", 0, 0, "scope")
407 assert result == []
408
409 def test_cache_miss_non_tv_skips_autoscrape(self):
410 """Standard cache miss + non-TV mode = no autoscrape fallback."""
411 with patch("lib.search.get_cached_results", return_value=None):
412 result = _check_search_caches("q", {}, "movies", "movie", 0, 0, "scope")
413 assert result is None
414
415 def test_cache_miss_tv_no_ids_skips_autoscrape(self):
416 """TV mode but no id_value → skip autoscrape fallback."""
417 with patch("lib.search.get_cached_results", return_value=None):
418 result = _check_search_caches("q", {}, "tv", "tv", 0, 0, "scope")
419 assert result is None
420
421 def test_cache_miss_tv_no_autoscrape_data_returns_none(self):
422 """Autoscrape key exists but cache miss → return None."""
423 ids = {"tmdb_id": "123"}
424 with (
425 patch("lib.search.get_cached_results", return_value=None),
426 patch("lib.db.cached.cache.get", return_value=None),
427 patch("lib.utils.player.utils.get_autoscrape_results_cache_key", return_value="as:123_1_2"),
428 ):
429 result = _check_search_caches("q", ids, "tv", "tv", 2, 1, "scope")
430 assert result is None
431
432 def test_cache_miss_tv_autoscrape_hit_migrates_and_returns(self):
433 """Autoscrape hit → migrate to standard cache + return results."""
434 ids = {"imdb_id": "tt999"}
435 autoscrape_results = [MagicMock(), MagicMock()]
436 with (
437 patch("lib.search.get_cached_results", return_value=None),
438 patch("lib.db.cached.cache.get", return_value=autoscrape_results),
439 patch("lib.utils.player.utils.get_autoscrape_results_cache_key", return_value="as:tt999_2_3"),
440 patch("lib.search.cache_results") as mock_cache_results,
441 ):
442 result = _check_search_caches("q", ids, "tv", "tv", 3, 2, "scope")
443
444 assert result == autoscrape_results
445 mock_cache_results.assert_called_once_with(
446 autoscrape_results, "q", "tv", "tv", 3, cache_scope="scope",
447 )
448
449
450# ---------------------------------------------------------------------------
451# search_client (orchestration)
452# ---------------------------------------------------------------------------
453
454
455class TestSearchClient:
456 """Cover orchestration logic of search_client (branching, cache, search dispatch)."""
457
458 def test_rescrape_skips_cache_checks(self):
459 """rescrape=True → skip _check_search_caches entirely."""
460 with (
461 patch("lib.search.close_busy_dialog"),
462 patch("lib.search._infer_tmdb_year", return_value=2020),
463 patch("lib.search._build_title_fallback_queries", return_value=["q 2020"]),
464 patch("lib.search._build_search_cache_scope", return_value="scope"),
465 patch("lib.search._check_search_caches") as mock_check,
466 patch("lib.search.get_setting", return_value="0"),
467 patch("lib.search._run_simple_search", return_value=[]),
468 patch("lib.search.cache_results"),
469 ):
470 search_client("q", {}, "movies", "movie", rescrape=True, season=0, episode=0)
471
472 mock_check.assert_not_called()
473
474 def test_not_rescrape_cache_hit_returns_early(self):
475 """Not rescrape + cache hit → return cached, no search."""
476 cached = [MagicMock()]
477 with (
478 patch("lib.search.close_busy_dialog"),
479 patch("lib.search._infer_tmdb_year", return_value=2020),
480 patch("lib.search._build_title_fallback_queries", return_value=["q 2020"]),
481 patch("lib.search._build_search_cache_scope", return_value="scope"),
482 patch("lib.search._check_search_caches", return_value=cached),
483 patch("lib.search.get_setting") as mock_get_setting,
484 patch("lib.search._run_simple_search") as mock_simple,
485 patch("lib.search._run_detailed_search") as mock_detailed,
486 patch("lib.search.cache_results") as mock_cache_results,
487 ):
488 result = search_client("q", {}, "movies", "movie", rescrape=False, season=0, episode=0)
489
490 assert result == cached
491 mock_simple.assert_not_called()
492 mock_detailed.assert_not_called()
493 mock_cache_results.assert_not_called()
494
495 def test_cache_miss_detailed_dialog_branch(self):
496 """Cache miss + search_dialog_style=1 → calls _run_detailed_search."""
497 expected = [MagicMock()]
498 with (
499 patch("lib.search.close_busy_dialog"),
500 patch("lib.search._infer_tmdb_year", return_value=2020),
501 patch("lib.search._build_title_fallback_queries", return_value=["q 2020"]),
502 patch("lib.search._build_search_cache_scope", return_value="scope"),
503 patch("lib.search._check_search_caches", return_value=None),
504 patch("lib.search.get_setting", return_value="1"),
505 patch("lib.search._run_detailed_search", return_value=expected) as mock_detailed,
506 patch("lib.search._run_simple_search") as mock_simple,
507 patch("lib.search.cache_results") as mock_cache,
508 ):
509 result = search_client("q", {}, "movies", "movie", rescrape=False, season=0, episode=0)
510
511 assert result == expected
512 mock_detailed.assert_called_once()
513 mock_simple.assert_not_called()
514 mock_cache.assert_called_once()
515
516 def test_cache_miss_simple_dialog_branch(self):
517 """Cache miss + search_dialog_style=0 → calls _run_simple_search."""
518 expected = [MagicMock()]
519 with (
520 patch("lib.search.close_busy_dialog"),
521 patch("lib.search._infer_tmdb_year", return_value=2020),
522 patch("lib.search._build_title_fallback_queries", return_value=["q 2020"]),
523 patch("lib.search._build_search_cache_scope", return_value="scope"),
524 patch("lib.search._check_search_caches", return_value=None),
525 patch("lib.search.get_setting", return_value="0"),
526 patch("lib.search._run_detailed_search") as mock_detailed,
527 patch("lib.search._run_simple_search", return_value=expected) as mock_simple,
528 patch("lib.search.cache_results"),
529 ):
530 result = search_client("q", {}, "movies", "movie", rescrape=False, season=0, episode=0)
531
532 assert result == expected
533 mock_simple.assert_called_once()
534 mock_detailed.assert_not_called()
535
536 def test_show_dialog_false_uses_simple_branch(self):
537 """show_dialog=False → simple branch regardless of search_dialog_style."""
538 expected = [MagicMock()]
539 with (
540 patch("lib.search.close_busy_dialog"),
541 patch("lib.search._infer_tmdb_year", return_value=2020),
542 patch("lib.search._build_title_fallback_queries", return_value=["q 2020"]),
543 patch("lib.search._build_search_cache_scope", return_value="scope"),
544 patch("lib.search._check_search_caches", return_value=None),
545 patch("lib.search.get_setting", return_value="1"), # would trigger detailed, but show_dialog=False
546 patch("lib.search._run_detailed_search") as mock_detailed,
547 patch("lib.search._run_simple_search", return_value=expected) as mock_simple,
548 patch("lib.search.cache_results"),
549 ):
550 result = search_client(
551 "q", {}, "movies", "movie", rescrape=False,
552 season=0, episode=0, show_dialog=False,
553 )
554
555 assert result == expected
556 mock_simple.assert_called_once()
557 mock_detailed.assert_not_called()
558
559 def test_detailed_search_failure_falls_back_to_simple_search(self):
560 """Detailed XML UI failure → search continues through simple progress."""
561 expected = [MagicMock()]
562 with (
563 patch("lib.search.close_busy_dialog"),
564 patch("lib.search._infer_tmdb_year", return_value=2020),
565 patch("lib.search._build_title_fallback_queries", return_value=["q 2020"]),
566 patch("lib.search._build_search_cache_scope", return_value="scope"),
567 patch("lib.search._check_search_caches", return_value=None),
568 patch("lib.search.get_setting", return_value="1"),
569 patch("lib.search._run_detailed_search", side_effect=RuntimeError("xml failed")) as mock_detailed,
570 patch("lib.search._run_simple_search", return_value=expected) as mock_simple,
571 patch("lib.search.cache_results") as mock_cache,
572 ):
573 result = search_client("q", {}, "movies", "movie", rescrape=False, season=0, episode=0)
574
575 assert result == expected
576 mock_detailed.assert_called_once()
577 mock_simple.assert_called_once()
578 assert mock_simple.call_args[0][9] is True
579 mock_cache.assert_called_once_with(expected, "q", "movies", "movie", 0, cache_scope="scope")
580
581 def test_passes_year_to_infer_when_none(self):
582 """year=None → _infer_tmdb_year is called."""
583 with (
584 patch("lib.search.close_busy_dialog"),
585 patch("lib.search._infer_tmdb_year", return_value=2020) as mock_infer,
586 patch("lib.search._build_title_fallback_queries", return_value=["q"]),
587 patch("lib.search._build_search_cache_scope", return_value="scope"),
588 patch("lib.search._check_search_caches", return_value=None),
589 patch("lib.search.get_setting", return_value="0"),
590 patch("lib.search._run_simple_search", return_value=[]),
591 patch("lib.search.cache_results"),
592 ):
593 search_client("q", {"tmdb_id": "123"}, "movies", "movie", rescrape=False, season=0, episode=0)
594
595 mock_infer.assert_called_once_with({"tmdb_id": "123"}, "movies")
596
597 def test_does_not_infer_year_when_provided(self):
598 """year=2010 → _infer_tmdb_year is NOT called."""
599 with (
600 patch("lib.search.close_busy_dialog"),
601 patch("lib.search._infer_tmdb_year") as mock_infer,
602 patch("lib.search._build_title_fallback_queries", return_value=["q"]),
603 patch("lib.search._build_search_cache_scope", return_value="scope"),
604 patch("lib.search._check_search_caches", return_value=None),
605 patch("lib.search.get_setting", return_value="0"),
606 patch("lib.search._run_simple_search", return_value=[]),
607 patch("lib.search.cache_results"),
608 ):
609 search_client("q", {}, "movies", "movie", rescrape=False, season=0, episode=0, year=2010)
610
611 mock_infer.assert_not_called()
plugin.video.jacktook/tests/unit/test_search_status_window.py +68 -0
……@@ -0,0 +1,68 @@
1from concurrent.futures import Future
2from unittest.mock import MagicMock, patch
3
4from lib.gui.search_status_window import SearchStatusWindow, SearchTask, SearchTaskManager
5
6
7def test_task_manager_snapshot_tasks_returns_copied_task_state():
8 manager = SearchTaskManager(MagicMock())
9 future = Future()
10 task = SearchTask(
11 name="Jackett",
12 indexer_key="Jackett",
13 status="Completed",
14 result_count=3,
15 error="",
16 future=future,
17 )
18 manager.tasks.append(task)
19
20 snapshots = manager.snapshot_tasks()
21 snapshots[0].status = "Failed"
22
23 assert snapshots[0].name == "Jackett"
24 assert snapshots[0].result_count == 3
25 assert manager.tasks[0].status == "Completed"
26
27
28def test_search_status_window_on_init_does_not_start_background_gui_thread():
29 manager = MagicMock()
30 manager.snapshot_tasks.return_value = [
31 SearchTask(name="Jackett", indexer_key="Jackett", status="Pending")
32 ]
33 window = SearchStatusWindow("search_status.xml", "/tmp", task_manager=manager)
34 progress_control = MagicMock()
35 label_control = MagicMock()
36
37 def get_control(control_id):
38 return label_control if control_id == 13001 else progress_control
39
40 window.getControl = MagicMock(side_effect=get_control)
41 window.setProperty = MagicMock()
42
43 with patch("lib.gui.search_status_window.threading.Thread") as thread_mock:
44 window.onInit()
45
46 thread_mock.assert_not_called()
47 label_control.setLabel.assert_called_once()
48 window.setProperty.assert_any_call("task_0_name", "Jackett")
49
50
51def test_search_status_window_poll_loop_closes_from_caller_thread_when_complete():
52 manager = MagicMock()
53 manager.is_complete = True
54 manager.is_cancelled = False
55 manager.snapshot_tasks.return_value = [
56 SearchTask(name="Jackett", indexer_key="Jackett", status="Completed", result_count=2)
57 ]
58 window = SearchStatusWindow("search_status.xml", "/tmp", task_manager=manager)
59 window.setProperty = MagicMock()
60 window.getControl = MagicMock(return_value=MagicMock())
61 window.close = MagicMock()
62
63 with patch("lib.gui.search_status_window.translation", side_effect=lambda label_id: f"t:{label_id}"):
64 window.run_until_complete(poll_interval_ms=0, final_delay_ms=0)
65
66 window.setProperty.assert_any_call("task_0_status", "t:90246")
67 window.setProperty.assert_any_call("search_results_count", "t:902432")
68 window.close.assert_called_once_with()
plugin.video.jacktook/tests/unit/test_search_title_fallback.py +14 -34
……@@ -3,17 +3,17 @@ from unittest.mock import patch
33from lib.api.tmdbv3api.as_obj import AsObj
44from lib.domain.torrent import TorrentStream
55from lib.search import (
6 _build_title_fallback_queries,
7 _perform_search_with_title_fallback,
8 SearchVariant,
96 TITLE_LANGUAGE_ENGLISH_FIRST,
107 TITLE_LANGUAGE_ENGLISH_ONLY,
118 TITLE_LANGUAGE_LOCALIZED_FIRST,
9 SearchVariant,
10 _build_title_fallback_queries,
11 _perform_search_with_title_fallback,
1212)
1313
1414
1515def test_search_variant_enum_values():
16 if not hasattr(SearchVariant, '__members__'):
16 if not hasattr(SearchVariant, "__members__"):
1717 return
1818 assert SearchVariant.DEFAULT.value == "default"
1919 assert SearchVariant.TITLE_YEAR.value == "title_year"
……@@ -33,9 +33,7 @@ def test_build_title_fallback_queries_default_variant_uses_fallback_behavior():
3333 }
3434 )
3535
36 with patch(
37 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
38 ):
36 with patch("lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details):
3937 queries = _build_title_fallback_queries(
4038 "Intocable",
4139 {"tmdb_id": "77338"},
……@@ -71,9 +69,7 @@ def test_build_title_fallback_queries_original_title_uses_tmdb_original():
7169 }
7270 )
7371
74 with patch(
75 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
76 ):
72 with patch("lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details):
7773 queries = _build_title_fallback_queries(
7874 "Spirited Away",
7975 {"tmdb_id": "129"},
……@@ -96,9 +92,7 @@ def test_build_title_fallback_queries_original_title_year_appends_year():
9692 }
9793 )
9894
99 with patch(
100 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
101 ):
95 with patch("lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details):
10296 queries = _build_title_fallback_queries(
10397 "Spirited Away",
10498 {"tmdb_id": "129"},
……@@ -134,9 +128,7 @@ def test_build_title_fallback_queries_original_title_fallbacks_to_query():
134128 }
135129 )
136130
137 with patch(
138 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
139 ):
131 with patch("lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details):
140132 queries = _build_title_fallback_queries(
141133 "Some Movie",
142134 {"tmdb_id": "123"},
……@@ -163,9 +155,7 @@ def test_build_title_fallback_queries_prefers_english_translation_for_movies():
163155 }
164156 )
165157
166 with patch(
167 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
168 ):
158 with patch("lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details):
169159 queries = _build_title_fallback_queries(
170160 "Intocable",
171161 {"tmdb_id": "77338"},
……@@ -192,9 +182,7 @@ def test_build_title_fallback_queries_english_first_reorders_candidates():
192182 }
193183 )
194184
195 with patch(
196 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
197 ):
185 with patch("lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details):
198186 queries = _build_title_fallback_queries(
199187 "Intocable",
200188 {"tmdb_id": "77338"},
……@@ -217,9 +205,7 @@ def test_build_title_fallback_queries_english_first_falls_back_to_original_befor
217205 }
218206 )
219207
220 with patch(
221 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
222 ):
208 with patch("lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details):
223209 queries = _build_title_fallback_queries(
224210 "Avatar: Fuego y ceniza",
225211 {"tmdb_id": "123456"},
……@@ -246,9 +232,7 @@ def test_build_title_fallback_queries_english_only_skips_localized_query():
246232 }
247233 )
248234
249 with patch(
250 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
251 ):
235 with patch("lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details):
252236 queries = _build_title_fallback_queries(
253237 "Intocable",
254238 {"tmdb_id": "77338"},
……@@ -275,12 +259,8 @@ def test_build_title_fallback_queries_uses_original_name_when_needed_for_tv():
275259 }
276260 )
277261
278 with patch(
279 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
280 ):
281 queries = _build_title_fallback_queries(
282 "Ataque a los Titanes", {"tmdb_id": "1429"}, "tv"
283 )
262 with patch("lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details):
263 queries = _build_title_fallback_queries("Ataque a los Titanes", {"tmdb_id": "1429"}, "tv")
284264
285265 assert queries == [
286266 "Ataque a los Titanes",
plugin.video.jacktook/tests/unit/test_settings_backup.py +34 -20
……@@ -229,21 +229,23 @@ def test_apply_backup_payload_replaces_custom_stremio_addons(monkeypatch, tmp_pa
229229 assert ("trakt_token", "tok") in property_calls
230230 assert ("trakt_refresh", "ref") in property_calls
231231 assert ("trakt_expires", "123") in property_calls
232 assert fake_cache.store[settings_backup.STREMIO_USER_ADDONS] == [account_addon, new_custom]
232 assert fake_cache.store[settings_backup.STREMIO_USER_ADDONS] == [
233 account_addon,
234 new_custom,
235 ]
233236 assert settings_backup.decode_selected_ids(
234237 fake_cache.store[settings_backup.STREMIO_ADDONS_KEY]
235238 ) == [account_key, new_key]
236239 assert settings_backup.decode_selected_ids(
237240 fake_cache.store[settings_backup.STREMIO_ADDONS_CATALOGS_KEY]
238241 ) == [new_key]
239 assert settings_backup.decode_selected_ids(
240 fake_cache.store[settings_backup.STREMIO_TV_ADDONS_KEY]
241 ) == []
242 assert (
243 settings_backup.decode_selected_ids(fake_cache.store[settings_backup.STREMIO_TV_ADDONS_KEY])
244 == []
245 )
242246
243247
244def test_apply_backup_payload_clears_scrubbed_fields_and_custom_addons(
245 monkeypatch, tmp_path
246):
248def test_apply_backup_payload_clears_scrubbed_fields_and_custom_addons(monkeypatch, tmp_path):
247249 settings_path = _write_settings_xml(
248250 tmp_path,
249251 """
……@@ -319,14 +321,15 @@ def test_apply_backup_payload_clears_scrubbed_fields_and_custom_addons(
319321 assert settings_backup.decode_selected_ids(
320322 fake_cache.store[settings_backup.STREMIO_ADDONS_KEY]
321323 ) == [account_key]
322 assert settings_backup.decode_selected_ids(
323 fake_cache.store[settings_backup.STREMIO_ADDONS_CATALOGS_KEY]
324 ) == []
324 assert (
325 settings_backup.decode_selected_ids(
326 fake_cache.store[settings_backup.STREMIO_ADDONS_CATALOGS_KEY]
327 )
328 == []
329 )
325330
326331
327def test_reset_all_settings_restores_defaults_and_clears_custom_addons(
328 monkeypatch, tmp_path
329):
332def test_reset_all_settings_restores_defaults_and_clears_custom_addons(monkeypatch, tmp_path):
330333 settings_path = _write_settings_xml(
331334 tmp_path,
332335 """
……@@ -390,9 +393,12 @@ def test_reset_all_settings_restores_defaults_and_clears_custom_addons(
390393 assert settings_backup.decode_selected_ids(
391394 fake_cache.store[settings_backup.STREMIO_ADDONS_KEY]
392395 ) == [account_key]
393 assert settings_backup.decode_selected_ids(
394 fake_cache.store[settings_backup.STREMIO_ADDONS_CATALOGS_KEY]
395 ) == []
396 assert (
397 settings_backup.decode_selected_ids(
398 fake_cache.store[settings_backup.STREMIO_ADDONS_CATALOGS_KEY]
399 )
400 == []
401 )
396402
397403
398404def test_factory_reset_clears_settings_caches_and_local_database(monkeypatch, tmp_path):
……@@ -435,11 +441,19 @@ def test_factory_reset_clears_settings_caches_and_local_database(monkeypatch, tm
435441 lambda setting_id, value: property_calls.append((setting_id, value)),
436442 )
437443 monkeypatch.setattr(settings_backup, "clear_all_cache", lambda: helper_calls.append("all"))
438 monkeypatch.setattr(settings_backup, "clear_trakt_db_cache", lambda: helper_calls.append("trakt"))
444 monkeypatch.setattr(
445 settings_backup, "clear_trakt_db_cache", lambda: helper_calls.append("trakt")
446 )
439447 monkeypatch.setattr(settings_backup, "clear_tmdb_cache", lambda: helper_calls.append("tmdb"))
440 monkeypatch.setattr(settings_backup, "clear_stremio_cache", lambda: helper_calls.append("stremio"))
441 monkeypatch.setattr(settings_backup, "clear_debrid_cache", lambda: helper_calls.append("debrid"))
442 monkeypatch.setattr(settings_backup, "clear_mdblist_cache", lambda: helper_calls.append("mdblist"))
448 monkeypatch.setattr(
449 settings_backup, "clear_stremio_cache", lambda: helper_calls.append("stremio")
450 )
451 monkeypatch.setattr(
452 settings_backup, "clear_debrid_cache", lambda: helper_calls.append("debrid")
453 )
454 monkeypatch.setattr(
455 settings_backup, "clear_mdblist_cache", lambda: helper_calls.append("mdblist")
456 )
443457 monkeypatch.setattr(settings_backup, "PickleDatabase", lambda: fake_pickle_db)
444458
445459 settings_backup.factory_reset(settings_xml_path=settings_path)
plugin.video.jacktook/tests/unit/test_settings_xml.py +17 -0
……@@ -0,0 +1,17 @@
1from pathlib import Path
2import xml.etree.ElementTree as ET
3
4
5SETTINGS_XML = Path(__file__).resolve().parents[2] / "resources" / "settings.xml"
6
7
8def test_settings_xml_parses():
9 ET.parse(SETTINGS_XML)
10
11
12def test_stremio_enable_does_not_control_settings_visibility():
13 tree = ET.parse(SETTINGS_XML)
14
15 conditions = tree.findall(".//condition[@setting='stremio_enabled']")
16
17 assert conditions == []
plugin.video.jacktook/tests/unit/test_skip_intro_window.py +1 -3
……@@ -8,9 +8,7 @@ def test_on_init_starts_background_monitor_thread(mock_thread_cls):
88 thread = MagicMock()
99 mock_thread_cls.return_value = thread
1010
11 window = SkipIntroWindow(
12 "skip_intro.xml", "", segment_data={"end_sec": 30}, label="Skip Intro"
13 )
11 window = SkipIntroWindow("skip_intro.xml", "", segment_data={"end_sec": 30}, label="Skip Intro")
1412 window.setProperty = MagicMock()
1513
1614 window.onInit()
plugin.video.jacktook/tests/unit/test_source_select_download.py +169 -12
……@@ -26,19 +26,26 @@ def test_download_file_uses_episode_filename_when_tv_data_is_present():
2626 "title": "Game of Thrones",
2727 "url": "https://example.com/files/game.of.thrones.mkv",
2828 "tv_data": {"season": "1", "episode": "1"},
29 "mode": "movie",
2930 }
3031
3132 with patch.object(
3233 source_select_module.SourceSelect,
3334 "_ensure_playback_info",
3435 return_value=playback_info,
35 ), patch.object(source_select_module, "get_setting", return_value="/downloads"), patch.object(
36 source_select_module, "translatePath", return_value="/downloads"
37 ), patch.object(source_select_module, "action_url_run", return_value="builtin") as action_url_run, patch.object(
36 ), patch(
37 "lib.downloader.get_destination_path", return_value="/downloads"
38 ) as mock_get_dest, patch.object(
39 source_select_module, "action_url_run", return_value="builtin"
40 ) as action_url_run, patch.object(
3841 source_select_module.xbmc, "executebuiltin"
3942 ) as executebuiltin:
4043 source_select._download_file(selected_source)
4144
45 mock_get_dest.assert_called_once()
46 dest_call_data = mock_get_dest.call_args[0][0]
47 assert dest_call_data["title"] == "Game of Thrones"
48 assert dest_call_data["mode"] == "movies"
4249 action_url_run.assert_called_once_with(
4350 "handle_download_file",
4451 file_name="Game of Thrones - S01E01",
……@@ -61,19 +68,26 @@ def test_download_file_uses_year_for_movie_filename():
6168 playback_info = {
6269 "title": "Project Hail Mary",
6370 "url": "https://example.com/files/project.hail.mary.mkv",
71 "mode": "movie",
6472 }
6573
6674 with patch.object(
6775 source_select_module.SourceSelect,
6876 "_ensure_playback_info",
6977 return_value=playback_info,
70 ), patch.object(source_select_module, "get_setting", return_value="/downloads"), patch.object(
71 source_select_module, "translatePath", return_value="/downloads"
72 ), patch.object(source_select_module, "action_url_run", return_value="builtin") as action_url_run, patch.object(
78 ), patch(
79 "lib.downloader.get_destination_path", return_value="/downloads"
80 ) as mock_get_dest, patch.object(
81 source_select_module, "action_url_run", return_value="builtin"
82 ) as action_url_run, patch.object(
7383 source_select_module.xbmc, "executebuiltin"
7484 ) as executebuiltin:
7585 source_select._download_file(selected_source)
7686
87 mock_get_dest.assert_called_once()
88 dest_call_data = mock_get_dest.call_args[0][0]
89 assert dest_call_data["title"] == "Project Hail Mary"
90 assert dest_call_data["mode"] == "movies"
7791 action_url_run.assert_called_once_with(
7892 "handle_download_file",
7993 file_name="Project Hail Mary (2025)",
……@@ -130,13 +144,156 @@ def test_populate_sources_list_shows_debrid_type_for_stremio_debrid_sources():
130144 "badges": "",
131145 "release_group": "",
132146 },
133 ), patch.object(source_select_module, "bytes_to_human_readable", return_value="1 KB"), patch.object(
147 ), patch.object(
148 source_select_module, "bytes_to_human_readable", return_value="1 KB"
149 ), patch.object(
134150 source_select_module, "get_provider_color", side_effect=lambda value: value
135 ), patch.object(source_select_module, "get_random_color", side_effect=lambda value: value), patch.object(
136 source_select_module, "get_colored_languages", return_value=""
137 ), patch.object(source_select_module, "extract_publish_date", return_value=""), patch.object(
138 source_select_module, "get_source_status", return_value="[B]Cached[/B]"
139 ):
151 ), patch.object(
152 source_select_module, "get_random_color", side_effect=lambda value: value
153 ), patch.object(source_select_module, "get_colored_languages", return_value=""), patch.object(
154 source_select_module, "extract_publish_date", return_value=""
155 ), patch.object(source_select_module, "get_source_status", return_value="[B]Cached[/B]"):
140156 source_select.populate_sources_list()
141157
142158 assert source_select.display_list.items[0].properties["type"] == DebridType.TB
159
160
161def test_populate_sources_list_uses_current_stremio_alias_for_cached_source():
162 source_select_module = _load_source_select_module()
163 source_select = source_select_module.SourceSelect.__new__(source_select_module.SourceSelect)
164
165 class _FakeListItem:
166 def __init__(self, label=""):
167 self.label = label
168 self.properties = {}
169
170 def setProperty(self, key, value):
171 self.properties[key] = value
172
173 class _FakeDisplayList:
174 def __init__(self):
175 self.items = []
176
177 def reset(self):
178 self.items = []
179
180 def addItem(self, item):
181 self.items.append(item)
182
183 addon_key = "org.example.addon|https://example.com"
184 source_select.display_list = _FakeDisplayList()
185 source_select.filter_applied = False
186 source_select.filtered_sources = None
187 source_select.sources = [
188 TorrentStream(
189 title="Example.Source.1080p",
190 type=IndexerType.STREMIO_DEBRID,
191 addonKey=addon_key,
192 addonName="Old Alias",
193 addonInstanceLabel="Old Alias (example.com, custom)",
194 indexer="Original",
195 size=1024,
196 quality="1080p",
197 ),
198 TorrentStream(
199 title="Another.Source.1080p",
200 type=IndexerType.STREMIO_DEBRID,
201 addonKey="org.example.other|https://example.com",
202 addonInstanceLabel="Other Addon",
203 indexer="Original Other",
204 size=2048,
205 quality="1080p",
206 ),
207 ]
208
209 with patch.object(source_select_module.xbmcgui, "ListItem", _FakeListItem), patch.object(
210 source_select_module,
211 "parse_title_info",
212 return_value={
213 "clean_title": "Example Source",
214 "codec": "",
215 "audio": "",
216 "badges": "",
217 "release_group": "",
218 },
219 ), patch.object(
220 source_select_module, "bytes_to_human_readable", return_value="1 KB"
221 ), patch.object(
222 source_select_module, "get_provider_color", side_effect=lambda value: value
223 ), patch.object(
224 source_select_module, "get_random_color", side_effect=lambda value: value
225 ), patch.object(source_select_module, "get_colored_languages", return_value=""), patch.object(
226 source_select_module, "extract_publish_date", return_value=""
227 ), patch.object(source_select_module, "get_source_status", return_value="[B]Cached[/B]"), patch.object(
228 source_select_module.cache,
229 "get",
230 return_value={addon_key: "New Alias"},
231 ) as cache_get:
232 source_select.populate_sources_list()
233
234 cache_get.assert_called_once_with(source_select_module.STREMIO_ADDON_ALIASES_KEY)
235 assert (
236 source_select.display_list.items[0].properties["indexer"]
237 == "New Alias (example.com, custom)"
238 )
239 assert source_select.display_list.items[1].properties["indexer"] == "Other Addon"
240
241
242def test_torrent_context_menu_does_not_include_download_video():
243 source_select_module = _load_source_select_module()
244 source_select = source_select_module.SourceSelect.__new__(source_select_module.SourceSelect)
245 source_select.position = 0
246 source_select.list_sources = [TorrentStream(type=IndexerType.TORRENT)]
247
248 display_list = MagicMock()
249 display_list.getSelectedPosition.return_value = 0
250 source_select.display_list = display_list
251
252 labels = {
253 90365: "Download to debrid",
254 90359: "Add to TorrServer",
255 90083: "Download video",
256 90082: "Download subtitles",
257 90744: "Upload subtitle",
258 }
259 dialog = MagicMock()
260 dialog.contextmenu.return_value = -1
261
262 with patch.object(
263 source_select_module,
264 "translation",
265 side_effect=lambda key: labels.get(key, str(key)),
266 ), patch.object(source_select_module.xbmcgui, "Dialog", return_value=dialog):
267 source_select._handle_context_menu_action()
268
269 shown_items = dialog.contextmenu.call_args.args[0]
270 assert "Download video" not in shown_items
271 assert shown_items == [
272 "Download to debrid",
273 "Add to TorrServer",
274 "Download subtitles",
275 "Upload subtitle",
276 ]
277
278
279def test_torrent_context_menu_download_subtitles_resolves_for_subtitle_download():
280 source_select_module = _load_source_select_module()
281 source_select = source_select_module.SourceSelect.__new__(source_select_module.SourceSelect)
282 source_select.position = 0
283 source_select.item_information = {}
284 selected_source = TorrentStream(type=IndexerType.TORRENT)
285 source_select.list_sources = [selected_source]
286
287 display_list = MagicMock()
288 display_list.getSelectedPosition.return_value = 0
289 source_select.display_list = display_list
290
291 dialog = MagicMock()
292 dialog.contextmenu.return_value = 2
293
294 with patch.object(source_select_module.xbmcgui, "Dialog", return_value=dialog), patch.object(
295 source_select, "_resolve_item"
296 ) as resolve_item:
297 source_select._handle_context_menu_action()
298
299 resolve_item.assert_called_once_with(selected_source, is_subtitle_download=True)
plugin.video.jacktook/tests/unit/test_stinger.py +50 -0
……@@ -0,0 +1,50 @@
1from unittest.mock import MagicMock, patch
2
3from lib.clients.tmdb.utils.utils import get_movie_keywords
4
5
6@patch("lib.clients.tmdb.utils.utils.tmdb_get")
7def test_get_movie_keywords_returns_names(mock_tmdb_get):
8 mock_result = MagicMock()
9 mock_result.keywords = [
10 {"name": "aftercreditsstinger"},
11 {"name": "superhero"},
12 ]
13 mock_tmdb_get.return_value = mock_result
14
15 result = get_movie_keywords(12345)
16
17 mock_tmdb_get.assert_called_once_with("movie_keywords", 12345)
18 assert result == ["aftercreditsstinger", "superhero"]
19
20
21@patch("lib.clients.tmdb.utils.utils.tmdb_get")
22def test_get_movie_keywords_returns_empty_on_none(mock_tmdb_get):
23 mock_tmdb_get.return_value = None
24
25 result = get_movie_keywords(12345)
26
27 assert result == []
28
29
30@patch("lib.clients.tmdb.utils.utils.tmdb_get")
31def test_get_movie_keywords_returns_empty_on_exception(mock_tmdb_get):
32 mock_tmdb_get.side_effect = Exception("API error")
33
34 result = get_movie_keywords(12345)
35
36 assert result == []
37
38
39@patch("lib.clients.tmdb.utils.utils.tmdb_get")
40def test_get_movie_keywords_ignores_missing_name(mock_tmdb_get):
41 mock_result = MagicMock()
42 mock_result.keywords = [
43 {"name": "aftercreditsstinger"},
44 {"id": 999},
45 ]
46 mock_tmdb_get.return_value = mock_result
47
48 result = get_movie_keywords(12345)
49
50 assert result == ["aftercreditsstinger"]
plugin.video.jacktook/tests/unit/test_stremio_regressions.py +548 -37
……@@ -1,13 +1,17 @@
11import json
22from unittest.mock import MagicMock
33
4from lib import search
45from lib.api.stremio.addon_manager import AddonManager
56from lib.api.stremio.models import Meta, MetaPreview
6from lib.clients.stremio import helpers
7from lib.clients.stremio import addon_client, addon_selection, catalog_menus, helpers
78from lib.clients.stremio.catalog_menus import CATALOG_PAGE_SIZE
8from lib.clients.stremio import catalog_menus
9from lib.clients.stremio.constants import (
10 STREMIO_ADDON_ALIASES_KEY,
11 STREMIO_CATALOG_ALIASES_KEY,
12)
913from lib.domain.torrent import TorrentStream
10from lib import search
14from lib.utils.debrid import debrid_utils
1115
1216
1317def test_merge_addons_lists_keeps_same_manifest_id_with_different_urls():
……@@ -80,10 +84,14 @@ def test_process_search_results_bypasses_exact_addon_instance(monkeypatch):
8084
8185 processed_batches = []
8286
83 monkeypatch.setattr(search, "get_setting", lambda key, default=None: {
84 "stremio_bypass_addons": True,
85 "stremio_bypass_addon_list": "org.example.addon|https://example.com/one",
86 }.get(key, default))
87 monkeypatch.setattr(
88 search,
89 "get_setting",
90 lambda key, default=None: {
91 "stremio_bypass_addons": True,
92 "stremio_bypass_addon_list": "org.example.addon|https://example.com/one",
93 }.get(key, default),
94 )
8795 monkeypatch.setattr(search, "pre_process_results", lambda results, *args, **kwargs: results)
8896 monkeypatch.setattr(
8997 search,
……@@ -107,6 +115,56 @@ def test_process_search_results_bypasses_exact_addon_instance(monkeypatch):
107115 assert processed_batches == [[native_result]]
108116
109117
118def test_process_search_results_legacy_bypass_uses_source_name_with_alias(monkeypatch):
119 bypass_result = TorrentStream(
120 title="Bypassed",
121 indexer="Original",
122 addonKey="org.example.addon|https://example.com/one",
123 addonName="Kodi Alias",
124 addonSourceName="Original Name",
125 addonInstanceLabel="Kodi Alias (example.com, custom)",
126 )
127 native_result = TorrentStream(
128 title="Native",
129 indexer="Other",
130 addonKey="org.other.addon|https://example.com/two",
131 addonName="Other Alias",
132 addonSourceName="Other Name",
133 )
134
135 processed_batches = []
136
137 monkeypatch.setattr(
138 search,
139 "get_setting",
140 lambda key, default=None: {
141 "stremio_bypass_addons": True,
142 "stremio_bypass_addon_list": "original name",
143 }.get(key, default),
144 )
145 monkeypatch.setattr(search, "pre_process_results", lambda results, *args, **kwargs: results)
146 monkeypatch.setattr(
147 search,
148 "process_results",
149 lambda results, *args, **kwargs: processed_batches.append(results) or results,
150 )
151
152 results = search._process_search_results(
153 [native_result, bypass_result],
154 "movies",
155 "",
156 1,
157 1,
158 "",
159 "query",
160 "movies",
161 False,
162 )
163
164 assert results == [bypass_result, native_result]
165 assert processed_batches == [[native_result]]
166
167
110168def test_get_addons_uses_cached_account_addons_before_settings_refresh(monkeypatch):
111169 custom_addon = {
112170 "manifest": {"id": "custom.one", "name": "Custom One"},
……@@ -131,6 +189,269 @@ def test_get_addons_uses_cached_account_addons_before_settings_refresh(monkeypat
131189 assert addon_ids == ["custom.one", "account.one"]
132190
133191
192def test_stremio_addon_aliases_are_stored_separately_from_manifest(monkeypatch):
193 addon_manager = AddonManager(
194 [
195 {
196 "manifest": {
197 "id": "org.example.addon",
198 "name": "Original Name",
199 "resources": [],
200 "types": [],
201 },
202 "transportUrl": "https://example.com/manifest.json",
203 "transportName": "custom",
204 }
205 ]
206 )
207 addon = addon_manager.addons[0]
208 stored = {}
209
210 fake_cache = MagicMock()
211 fake_cache.get.side_effect = lambda key: stored.get(key)
212 fake_cache.set.side_effect = lambda key, value, *_args: stored.__setitem__(key, value)
213 monkeypatch.setattr(helpers, "cache", fake_cache)
214
215 helpers.set_addon_alias(addon, "Kodi Alias")
216
217 assert helpers.get_addon_display_name(addon) == "Kodi Alias"
218 assert addon.manifest.name == "Original Name"
219 assert stored[STREMIO_ADDON_ALIASES_KEY] == {addon.key(): "Kodi Alias"}
220
221 helpers.clear_addon_alias(addon)
222
223 assert helpers.get_addon_display_name(addon) == "Original Name"
224 assert stored[STREMIO_ADDON_ALIASES_KEY] == {}
225
226
227def test_managed_search_status_uses_stremio_addon_alias(monkeypatch):
228 addon_manager = AddonManager(
229 [
230 {
231 "manifest": {
232 "id": "org.example.addon",
233 "name": "Original Name",
234 "resources": [],
235 "types": [],
236 },
237 "transportUrl": "https://example.com/manifest.json",
238 "transportName": "custom",
239 }
240 ]
241 )
242 addon = addon_manager.addons[0]
243 submitted = []
244
245 class RecordingManager:
246 def submit_task(self, *args, **kwargs):
247 submitted.append((args, kwargs))
248
249 monkeypatch.setattr(
250 search,
251 "get_setting",
252 lambda key, default=None: {"stremio_enabled": True}.get(key, default),
253 )
254 monkeypatch.setattr(search, "_is_source_enabled", lambda indexer, *_args: indexer == search.Indexer.STREMIO)
255 monkeypatch.setattr(search, "get_selected_stream_addons", lambda: [addon])
256 monkeypatch.setattr(search, "get_addon_display_name", lambda _addon: "Kodi Alias")
257
258 search._submit_search_tasks_managed(
259 RecordingManager(),
260 None,
261 "query",
262 "movies",
263 "movies",
264 None,
265 None,
266 {"imdb_id": "tt1234567"},
267 "",
268 None,
269 "tt1234567",
270 )
271
272 assert submitted[0][0][0] == "Kodi Alias"
273 assert submitted[0][0][1] == search.Indexer.STREMIO
274 assert addon.manifest.name == "Original Name"
275
276
277def test_stremio_catalog_alias_uses_addon_type_and_catalog_id(monkeypatch):
278 addon_manager = AddonManager(
279 [
280 {
281 "manifest": {
282 "id": "org.example.addon",
283 "name": "Addon",
284 "catalogs": [
285 {"type": "movie", "id": "popular", "name": "Popular"},
286 {"type": "series", "id": "popular", "name": "Popular Shows"},
287 ],
288 "resources": [],
289 "types": ["movie", "series"],
290 },
291 "transportUrl": "https://example.com/manifest.json",
292 "transportName": "custom",
293 }
294 ]
295 )
296 addon = addon_manager.addons[0]
297 movie_catalog = addon.manifest.catalogs[0]
298 series_catalog = addon.manifest.catalogs[1]
299 stored = {}
300
301 fake_cache = MagicMock()
302 fake_cache.get.side_effect = lambda key: stored.get(key)
303 fake_cache.set.side_effect = lambda key, value, *_args: stored.__setitem__(key, value)
304 monkeypatch.setattr(helpers, "cache", fake_cache)
305
306 helpers.set_catalog_alias(addon, movie_catalog, "Movies Alias")
307
308 assert helpers.get_catalog_display_name(addon, movie_catalog) == "Movies Alias"
309 assert helpers.get_catalog_display_name(addon, series_catalog) == "Popular Shows"
310 assert stored[STREMIO_CATALOG_ALIASES_KEY] == {
311 f"{addon.key()}|movie|popular": "Movies Alias"
312 }
313
314
315def test_rename_stremio_addon_empty_input_keeps_alias_and_clear_is_explicit(monkeypatch):
316 addon_manager = AddonManager(
317 [
318 {
319 "manifest": {
320 "id": "org.example.addon",
321 "name": "Original Name",
322 "resources": [],
323 "types": [],
324 },
325 "transportUrl": "https://example.com/manifest.json",
326 "transportName": "custom",
327 }
328 ]
329 )
330 addon = addon_manager.addons[0]
331 stored = {STREMIO_ADDON_ALIASES_KEY: {addon.key(): "Kodi Alias"}}
332
333 fake_cache = MagicMock()
334 fake_cache.get.side_effect = lambda key: stored.get(key)
335 fake_cache.set.side_effect = lambda key, value, *_args: stored.__setitem__(key, value)
336 monkeypatch.setattr(helpers, "cache", fake_cache)
337 monkeypatch.setattr(addon_selection, "cache", fake_cache)
338 monkeypatch.setattr(addon_selection, "get_addons", lambda: addon_manager)
339
340 class _Dialog:
341 clear_alias = False
342
343 def select(self, *args, **kwargs):
344 return 0
345
346 def yesno(self, *args, **kwargs):
347 return self.clear_alias
348
349 def input(self, *args, **kwargs):
350 return ""
351
352 def ok(self, *args, **kwargs):
353 return True
354
355 monkeypatch.setattr(addon_selection.xbmcgui, "Dialog", _Dialog)
356
357 addon_selection.rename_stremio_addon()
358
359 assert stored[STREMIO_ADDON_ALIASES_KEY] == {addon.key(): "Kodi Alias"}
360
361 _Dialog.clear_alias = True
362 addon_selection.rename_stremio_addon()
363
364 assert stored[STREMIO_ADDON_ALIASES_KEY] == {}
365
366
367def test_rename_stremio_catalog_empty_input_keeps_alias_and_clear_is_explicit(monkeypatch):
368 addon_manager = AddonManager(
369 [
370 {
371 "manifest": {
372 "id": "org.example.addon",
373 "name": "Addon",
374 "catalogs": [{"type": "movie", "id": "popular", "name": "Popular"}],
375 "resources": [],
376 "types": ["movie"],
377 },
378 "transportUrl": "https://example.com/manifest.json",
379 "transportName": "custom",
380 }
381 ]
382 )
383 addon = addon_manager.addons[0]
384 catalog = addon.manifest.catalogs[0]
385 alias_key = f"{addon.key()}|movie|popular"
386 stored = {STREMIO_CATALOG_ALIASES_KEY: {alias_key: "Movies Alias"}}
387
388 fake_cache = MagicMock()
389 fake_cache.get.side_effect = lambda key: stored.get(key)
390 fake_cache.set.side_effect = lambda key, value, *_args: stored.__setitem__(key, value)
391 monkeypatch.setattr(helpers, "cache", fake_cache)
392 monkeypatch.setattr(addon_selection, "cache", fake_cache)
393 monkeypatch.setattr(addon_selection, "get_addons", lambda: addon_manager)
394
395 class _Dialog:
396 clear_alias = False
397
398 def select(self, *args, **kwargs):
399 return 0
400
401 def yesno(self, *args, **kwargs):
402 return self.clear_alias
403
404 def input(self, *args, **kwargs):
405 return ""
406
407 def ok(self, *args, **kwargs):
408 return True
409
410 monkeypatch.setattr(addon_selection.xbmcgui, "Dialog", _Dialog)
411
412 addon_selection.rename_stremio_catalog()
413
414 assert helpers.get_catalog_alias(addon, catalog) == "Movies Alias"
415
416 _Dialog.clear_alias = True
417 addon_selection.rename_stremio_catalog()
418
419 assert stored[STREMIO_CATALOG_ALIASES_KEY] == {}
420
421
422def test_remove_custom_stremio_addon_clears_alias_cache(monkeypatch):
423 custom_addon = {
424 "manifest": {"id": "org.example.addon", "name": "Example", "catalogs": []},
425 "transportUrl": "https://example.com/manifest.json",
426 "transportName": "custom",
427 }
428 addon_key = "org.example.addon|https://example.com"
429 stored = {
430 "stremio_user_addons": [custom_addon],
431 STREMIO_ADDON_ALIASES_KEY: {addon_key: "Alias"},
432 STREMIO_CATALOG_ALIASES_KEY: {f"{addon_key}|movie|popular": "Catalog Alias"},
433 }
434
435 fake_cache = MagicMock()
436 fake_cache.get.side_effect = lambda key: stored.get(key)
437 fake_cache.set.side_effect = lambda key, value, *_args: stored.__setitem__(key, value)
438 monkeypatch.setattr(addon_selection, "cache", fake_cache)
439
440 class _Dialog:
441 def multiselect(self, *args, **kwargs):
442 return [0]
443
444 def ok(self, *args, **kwargs):
445 return True
446
447 monkeypatch.setattr(addon_selection.xbmcgui, "Dialog", _Dialog)
448
449 addon_selection.remove_custom_stremio_addon()
450
451 assert stored[STREMIO_ADDON_ALIASES_KEY] == {}
452 assert stored[STREMIO_CATALOG_ALIASES_KEY] == {}
453
454
134455def test_list_catalog_locally_paginates_when_catalog_has_no_skip(monkeypatch):
135456 metas = [
136457 MetaPreview(id=f"id-{i}", type="movie", name=f"Movie {i}", poster="")
……@@ -144,9 +465,7 @@ def test_list_catalog_locally_paginates_when_catalog_has_no_skip(monkeypatch):
144465 "catalogs_get_cache",
145466 lambda *args, **kwargs: {"metas": metas},
146467 )
147 monkeypatch.setattr(
148 catalog_menus, "_catalog_supports_extra", lambda *args, **kwargs: False
149 )
468 monkeypatch.setattr(catalog_menus, "_catalog_supports_extra", lambda *args, **kwargs: False)
150469 monkeypatch.setattr(
151470 catalog_menus,
152471 "add_meta_items",
……@@ -190,9 +509,7 @@ def test_list_catalog_uses_server_skip_when_manifest_supports_it(monkeypatch):
190509 "catalogs_get_cache",
191510 lambda path, params, **kwargs: captured_kwargs.update(kwargs) or {"metas": []},
192511 )
193 monkeypatch.setattr(
194 catalog_menus, "_catalog_supports_extra", lambda *args, **kwargs: True
195 )
512 monkeypatch.setattr(catalog_menus, "_catalog_supports_extra", lambda *args, **kwargs: True)
196513 monkeypatch.setattr(catalog_menus, "setContent", lambda *args, **kwargs: None)
197514 monkeypatch.setattr(catalog_menus, "end_of_directory", lambda *args, **kwargs: None)
198515 monkeypatch.setattr(catalog_menus, "notification", lambda *args, **kwargs: None)
……@@ -221,18 +538,26 @@ def test_search_catalog_cancel_shows_previous_search_terms(monkeypatch):
221538
222539 monkeypatch.setattr(catalog_menus, "show_keyboard", lambda *args, **kwargs: None)
223540 monkeypatch.setattr(catalog_menus.cache, "get_list", lambda key: [("Naruto",), ("Bleach",)])
224 monkeypatch.setattr(catalog_menus, "translation", lambda value: {90006: "Search", 90210: "Clear All Search History"}.get(value, str(value)))
541 monkeypatch.setattr(
542 catalog_menus,
543 "translation",
544 lambda value: {90006: "Search", 90210: "Clear All Search History"}.get(value, str(value)),
545 )
225546 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": _ListItem(label))
226547 monkeypatch.setattr(
227548 catalog_menus,
228549 "addDirectoryItem",
229 lambda handle, url, listitem, isFolder=True: added_items.append((listitem.label, url, isFolder)),
550 lambda handle, url, listitem, isFolder=True: added_items.append(
551 (listitem.label, url, isFolder)
552 ),
230553 )
231554 monkeypatch.setattr(catalog_menus, "end_of_directory", lambda *args, **kwargs: None)
232555 monkeypatch.setattr(
233556 catalog_menus,
234557 "build_url",
235 lambda action, **kwargs: "{}:{}:{}".format(action, kwargs.get("query", ""), kwargs.get("is_keyboard", True)),
558 lambda action, **kwargs: "{}:{}:{}".format(
559 action, kwargs.get("query", ""), kwargs.get("is_keyboard", True)
560 ),
236561 )
237562
238563 catalog_menus.search_catalog(
……@@ -341,13 +666,29 @@ def test_list_stremio_catalogs_uses_batch_add(monkeypatch):
341666 return "https://example.com/addon"
342667
343668 monkeypatch.setattr(catalog_menus, "get_selected_catalogs_addons", lambda: [_Addon()])
344 monkeypatch.setattr(catalog_menus, "translation", lambda value: {90006: "Search"}.get(value, str(value)))
669 monkeypatch.setattr(
670 catalog_menus, "get_addon_display_name", lambda addon: addon.manifest.name
671 )
672 monkeypatch.setattr(
673 catalog_menus, "get_catalog_display_name", lambda addon, catalog: catalog.name or catalog.id
674 )
675 monkeypatch.setattr(
676 catalog_menus,
677 "translation",
678 lambda value: {90006: "Search"}.get(value, str(value)),
679 )
345680 monkeypatch.setattr(catalog_menus, "_addon_has_resource", lambda *args, **kwargs: True)
346 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": _ListItem(label=label))
681 monkeypatch.setattr(
682 catalog_menus,
683 "make_list_item",
684 lambda label="", path="": _ListItem(label=label),
685 )
347686 monkeypatch.setattr(
348687 catalog_menus,
349688 "build_url",
350 lambda action=None, **kwargs: f"{action}:{kwargs.get('catalog_id', '')}:{kwargs.get('page', '')}",
689 lambda action=None, **kwargs: (
690 f"{action}:{kwargs.get('catalog_id', '')}:{kwargs.get('page', '')}"
691 ),
351692 )
352693 monkeypatch.setattr(
353694 catalog_menus,
……@@ -357,7 +698,9 @@ def test_list_stremio_catalogs_uses_batch_add(monkeypatch):
357698 monkeypatch.setattr(
358699 catalog_menus,
359700 "addDirectoryItem",
360 lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not use addDirectoryItem")),
701 lambda *args, **kwargs: (_ for _ in ()).throw(
702 AssertionError("should not use addDirectoryItem")
703 ),
361704 )
362705
363706 catalog_menus.list_stremio_catalogs(menu_type="series", sub_menu_type="series")
……@@ -368,6 +711,114 @@ def test_list_stremio_catalogs_uses_batch_add(monkeypatch):
368711 assert labels == ["Search Trending", "Trending", "Popular"]
369712
370713
714def test_list_stremio_catalogs_uses_catalog_alias(monkeypatch):
715 added_batches = []
716
717 class _ListItem:
718 def __init__(self, label=""):
719 self.label = label
720
721 def setArt(self, *args, **kwargs):
722 pass
723
724 class _Catalog:
725 name = "Popular"
726 id = "popular"
727 type = "series"
728 extra = []
729
730 class _Manifest:
731 name = "Addon One"
732 logo = "logo.png"
733 types = ["series"]
734 catalogs = [_Catalog()]
735
736 class _Addon:
737 manifest = _Manifest()
738
739 def key(self):
740 return "addon-key"
741
742 def url(self):
743 return "https://example.com/addon"
744
745 monkeypatch.setattr(catalog_menus, "get_selected_catalogs_addons", lambda: [_Addon()])
746 monkeypatch.setattr(
747 catalog_menus, "get_addon_display_name", lambda addon: addon.manifest.name
748 )
749 monkeypatch.setattr(catalog_menus, "get_catalog_display_name", lambda *_args: "Renamed")
750 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": _ListItem(label))
751 monkeypatch.setattr(catalog_menus, "build_url", lambda action=None, **kwargs: action)
752 monkeypatch.setattr(catalog_menus, "add_directory_items_batch", lambda items: added_batches.append(items))
753
754 catalog_menus.list_stremio_catalogs(menu_type="series", sub_menu_type="series")
755
756 labels = [item[1].label for item in added_batches[0]]
757 assert labels == ["Renamed"]
758
759
760def test_stremio_addon_client_uses_addon_alias_for_result_labels(monkeypatch):
761 addon_manager = AddonManager(
762 [
763 {
764 "manifest": {
765 "id": "org.example.addon",
766 "name": "Original Name",
767 "resources": [],
768 "types": [],
769 },
770 "transportUrl": "https://example.com/manifest.json",
771 "transportName": "custom",
772 }
773 ]
774 )
775 monkeypatch.setattr(addon_client, "get_addon_display_name", lambda addon: "Kodi Alias")
776 monkeypatch.setattr(addon_client, "find_languages_in_string", lambda desc: [])
777
778 client = addon_client.StremioAddonClient(addon_manager.addons[0])
779 response = MagicMock()
780 response.json.return_value = {"streams": [{"title": "Movie 1080p", "infoHash": "a" * 40}]}
781
782 results = client.parse_response(response)
783
784 assert results[0].indexer == "Original"
785 assert results[0].addonName == "Kodi Alias"
786 assert results[0].addonInstanceLabel == "Kodi Alias (example.com, custom)"
787 assert addon_manager.addons[0].manifest.name == "Original Name"
788
789
790def test_stremio_addon_aliases_are_part_of_result_cache_scopes(monkeypatch):
791 stored = {
792 STREMIO_ADDON_ALIASES_KEY: {"org.example.addon|https://example.com": "Alias One"},
793 }
794
795 monkeypatch.setattr(search.cache, "get", lambda key: stored.get(key))
796 monkeypatch.setattr(search, "get_setting", lambda *_args: False)
797
798 first_scope = search._build_search_cache_scope()
799 stored[STREMIO_ADDON_ALIASES_KEY] = {
800 "org.example.addon|https://example.com": "Alias Two"
801 }
802
803 assert search._build_search_cache_scope() != first_scope
804
805
806def test_stremio_addon_aliases_are_part_of_debrid_cache_scope(monkeypatch):
807 stored = {
808 STREMIO_ADDON_ALIASES_KEY: {"org.example.addon|https://example.com": "Alias One"},
809 }
810
811 monkeypatch.setattr(debrid_utils.cache, "get", lambda key: stored.get(key))
812 monkeypatch.setattr(debrid_utils, "get_setting", lambda *_args: False)
813
814 first_scope = debrid_utils._build_debrid_cache_scope()
815 stored[STREMIO_ADDON_ALIASES_KEY] = {
816 "org.example.addon|https://example.com": "Alias Two"
817 }
818
819 assert debrid_utils._build_debrid_cache_scope() != first_scope
820
821
371822def test_run_search_entry_preserves_stremio_route_in_library_payload(monkeypatch):
372823 captured = {}
373824
……@@ -415,7 +866,11 @@ def test_search_catalog_backfills_missing_menu_type(monkeypatch):
415866 "catalogs_get_cache",
416867 lambda *args, **kwargs: {"metas": []},
417868 )
418 monkeypatch.setattr(catalog_menus, "add_meta_items", lambda metas, params: (_ for _ in ()).throw(AssertionError(params)))
869 monkeypatch.setattr(
870 catalog_menus,
871 "add_meta_items",
872 lambda metas, params: (_ for _ in ()).throw(AssertionError(params)),
873 )
419874 monkeypatch.setattr(catalog_menus, "show_keyboard", lambda *args, **kwargs: "paradise")
420875 monkeypatch.setattr(catalog_menus.cache, "add_to_list", lambda *args, **kwargs: None)
421876 monkeypatch.setattr(catalog_menus.PickleDatabase, "set_key", lambda self, key, value: None)
……@@ -447,7 +902,11 @@ def test_clear_stremio_search_history_clears_only_current_catalog(monkeypatch):
447902 shown = []
448903
449904 monkeypatch.setattr(catalog_menus.cache, "clear_list", lambda key: cleared.append(key))
450 monkeypatch.setattr(catalog_menus, "_show_search_catalog_history", lambda params: shown.append(params))
905 monkeypatch.setattr(
906 catalog_menus,
907 "_show_search_catalog_history",
908 lambda params: shown.append(params),
909 )
451910
452911 params = {
453912 "addon_url": "https://anime-kitsu.strem.fun",
……@@ -651,17 +1110,31 @@ def test_add_meta_items_uses_tmdb_context_menu_only_for_reliable_tmdb_ids(monkey
6511110
6521111 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": _ListItem(label))
6531112 monkeypatch.setattr(catalog_menus, "setContent", lambda *args, **kwargs: None)
654 monkeypatch.setattr(catalog_menus, "translation", lambda value: {90205: "Add to Library", 90116: "Open Settings"}.get(value, str(value)))
1113 monkeypatch.setattr(
1114 catalog_menus,
1115 "translation",
1116 lambda value: {90205: "Add to Library", 90116: "Open Settings"}.get(value, str(value)),
1117 )
6551118 monkeypatch.setattr(catalog_menus, "get_addon_by_base_url", lambda *args, **kwargs: None)
6561119 monkeypatch.setattr(catalog_menus, "addon_has_meta", lambda *args, **kwargs: False)
6571120 monkeypatch.setattr(catalog_menus, "addon_has_stream", lambda *args, **kwargs: False)
6581121 monkeypatch.setattr(catalog_menus, "build_url", lambda *args, **kwargs: "plugin://test")
659 monkeypatch.setattr(catalog_menus, "kodi_play_media", lambda **kwargs: f"PlayMedia({kwargs['name']})")
660 monkeypatch.setattr(catalog_menus, "container_update", lambda **kwargs: "Container.Update(settings)")
1122 monkeypatch.setattr(
1123 catalog_menus,
1124 "kodi_play_media",
1125 lambda **kwargs: f"PlayMedia({kwargs['name']})",
1126 )
1127 monkeypatch.setattr(
1128 catalog_menus, "container_update", lambda **kwargs: "Container.Update(settings)"
1129 )
6611130 monkeypatch.setattr(
6621131 catalog_menus,
6631132 "add_tmdb_movie_context_menu",
664 lambda **kwargs: [("Extras", "extras"), ("Play Trailer", "trailer"), ("Add to Library", "tmdb-lib")],
1133 lambda **kwargs: [
1134 ("Extras", "extras"),
1135 ("Play Trailer", "trailer"),
1136 ("Add to Library", "tmdb-lib"),
1137 ],
6651138 )
6661139 monkeypatch.setattr(
6671140 catalog_menus,
……@@ -670,8 +1143,20 @@ def test_add_meta_items_uses_tmdb_context_menu_only_for_reliable_tmdb_ids(monkey
6701143 )
6711144
6721145 metas = [
673 MetaPreview(id="tmdb:100", type="movie", name="Reliable Movie", poster="", description="desc"),
674 MetaPreview(id="custom:200", type="movie", name="Custom Movie", poster="", description="desc"),
1146 MetaPreview(
1147 id="tmdb:100",
1148 type="movie",
1149 name="Reliable Movie",
1150 poster="",
1151 description="desc",
1152 ),
1153 MetaPreview(
1154 id="custom:200",
1155 type="movie",
1156 name="Custom Movie",
1157 poster="",
1158 description="desc",
1159 ),
6751160 ]
6761161
6771162 catalog_menus.add_meta_items(
……@@ -722,19 +1207,31 @@ def test_add_meta_items_resolves_tmdb_id_from_imdb_for_context_menu(monkeypatch)
7221207
7231208 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": _ListItem(label))
7241209 monkeypatch.setattr(catalog_menus, "setContent", lambda *args, **kwargs: None)
725 monkeypatch.setattr(catalog_menus, "translation", lambda value: {90205: "Add to Library", 90116: "Open Settings"}.get(value, str(value)))
1210 monkeypatch.setattr(
1211 catalog_menus,
1212 "translation",
1213 lambda value: {90205: "Add to Library", 90116: "Open Settings"}.get(value, str(value)),
1214 )
7261215 monkeypatch.setattr(catalog_menus, "get_addon_by_base_url", lambda *args, **kwargs: None)
7271216 monkeypatch.setattr(catalog_menus, "addon_has_meta", lambda *args, **kwargs: False)
7281217 monkeypatch.setattr(catalog_menus, "addon_has_stream", lambda *args, **kwargs: False)
7291218 monkeypatch.setattr(catalog_menus, "build_url", lambda *args, **kwargs: "plugin://test")
730 monkeypatch.setattr(catalog_menus, "kodi_play_media", lambda **kwargs: f"PlayMedia({kwargs['name']})")
731 monkeypatch.setattr(catalog_menus, "container_update", lambda **kwargs: "Container.Update(settings)")
1219 monkeypatch.setattr(
1220 catalog_menus,
1221 "kodi_play_media",
1222 lambda **kwargs: f"PlayMedia({kwargs['name']})",
1223 )
1224 monkeypatch.setattr(
1225 catalog_menus, "container_update", lambda **kwargs: "Container.Update(settings)"
1226 )
7321227 monkeypatch.setattr(
7331228 catalog_menus.cache,
7341229 "get",
735 lambda key: type("FindResult", (), {"movie_results": [{"id": 550}], "tv_results": []})()
736 if key == "find_by_imdb_id|tt0133093"
737 else None,
1230 lambda key: (
1231 type("FindResult", (), {"movie_results": [{"id": 550}], "tv_results": []})()
1232 if key == "find_by_imdb_id|tt0133093"
1233 else None
1234 ),
7381235 )
7391236
7401237 def _movie_context_menu(**kwargs):
……@@ -749,7 +1246,14 @@ def test_add_meta_items_resolves_tmdb_id_from_imdb_for_context_menu(monkeypatch)
7491246 )
7501247
7511248 metas = [
752 MetaPreview(id="tt0133093", type="movie", name="The Matrix", poster="", description="desc", imdb_id="tt0133093"),
1249 MetaPreview(
1250 id="tt0133093",
1251 type="movie",
1252 name="The Matrix",
1253 poster="",
1254 description="desc",
1255 imdb_id="tt0133093",
1256 ),
7531257 ]
7541258
7551259 catalog_menus.add_meta_items(
……@@ -779,7 +1283,12 @@ def test_list_stremio_movie_builds_enriched_play_media_payload(monkeypatch):
7791283 type(
7801284 "Stream",
7811285 (),
782 {"title": "Movie Stream", "description": "Stream plot", "url": "https://video", "infoHash": ""},
1286 {
1287 "title": "Movie Stream",
1288 "description": "Stream plot",
1289 "url": "https://video",
1290 "infoHash": "",
1291 },
7831292 )()
7841293 ]
7851294 },
……@@ -801,7 +1310,9 @@ def test_list_stremio_movie_builds_enriched_play_media_payload(monkeypatch):
8011310 "addon_url": "https://example.com/addon",
8021311 "catalog_type": "movie",
8031312 "meta_id": "custom:movie",
804 "ids": json.dumps({"tmdb_id": "100", "imdb_id": "tt100", "original_id": "custom:movie"}),
1313 "ids": json.dumps(
1314 {"tmdb_id": "100", "imdb_id": "tt100", "original_id": "custom:movie"}
1315 ),
8051316 "poster": "poster.jpg",
8061317 "fanart": "fanart.jpg",
8071318 "genres": json.dumps(["Drama"]),
plugin.video.jacktook/tests/unit/test_title_parser.py +57 -2
……@@ -1,5 +1,5 @@
1import pytest
2from lib.utils.parsers.title_parser import parse_title_info
1from lib.utils.parsers.title_parser import extract_codec_hdr, parse_title_info
2
33
44def test_parse_title_info_complex():
55 title = "Movie.Name.2024.2160p.REPACK.HEVC.DV.HDR10.Atmos.TrueHD.7.1-GROUP"
……@@ -9,8 +9,63 @@ def test_parse_title_info_complex():
99 assert "DV" in info["hdr"]
1010 assert "GROUP" in info["release_group"]
1111
12
1213def test_parse_title_info_simple():
1314 title = "Show.S01E01.1080p.H264-EVO"
1415 info = parse_title_info(title)
1516 assert "H.264" in info["codec"]
1617 assert info["release_group"] == "EVO"
18
19
20def test_parse_title_info_av1():
21 title = "Movie.2024.2160p.AV1.DDP5.1-GROUP"
22 info = parse_title_info(title)
23 assert "AV1" in info["codec"]
24
25
26def test_extract_codec_hdr_hevc():
27 codec, hdr = extract_codec_hdr("Movie.2024.2160p.HEVC.DV-GROUP")
28 assert codec == "HEVC"
29 assert hdr == "DV"
30
31
32def test_extract_codec_hdr_av1():
33 codec, hdr = extract_codec_hdr("Movie.2024.2160p.AV1-GROUP")
34 assert codec == "AV1"
35 assert hdr == ""
36
37
38def test_extract_codec_hdr_h264():
39 codec, hdr = extract_codec_hdr("Show.S01E01.1080p.H264-GROUP")
40 assert codec == "H.264"
41 assert hdr == ""
42
43
44def test_extract_codec_hdr_hdr10_plus():
45 codec, hdr = extract_codec_hdr("Movie.2024.HDR10+.HEVC-GROUP")
46 assert codec == "HEVC"
47 assert hdr == "HDR10+"
48
49
50def test_extract_codec_hdr_hdr10():
51 codec, hdr = extract_codec_hdr("Movie.2024.HDR10.HEVC-GROUP")
52 assert codec == "HEVC"
53 assert hdr == "HDR10"
54
55
56def test_extract_codec_hdr_generic_hdr():
57 codec, hdr = extract_codec_hdr("Movie.2024.HDR.H264-GROUP")
58 assert codec == "H.264"
59 assert hdr == "HDR"
60
61
62def test_extract_codec_hdr_empty():
63 codec, hdr = extract_codec_hdr("Movie.2024.WEB-DL-GROUP")
64 assert codec == ""
65 assert hdr == ""
66
67
68def test_extract_codec_hdr_none():
69 codec, hdr = extract_codec_hdr("")
70 assert codec == ""
71 assert hdr == ""
plugin.video.jacktook/tests/unit/test_tmdb_client_cache.py +31 -0
……@@ -0,0 +1,31 @@
1from unittest.mock import MagicMock, patch
2
3from lib.clients.tmdb.tmdb import TmdbClient
4
5
6def test_get_cached_tmdb_item_metadata_includes_language_in_cache_key():
7 item = {"id": 123, "title": "Test", "media_type": "movie"}
8 with patch("lib.clients.tmdb.tmdb.cache") as mock_cache:
9 mock_cache.get.return_value = None
10 with patch("lib.clients.tmdb.tmdb.TMDb") as mock_tmdb_cls:
11 mock_tmdb_instance = MagicMock()
12 mock_tmdb_instance.language = "ro-RO"
13 mock_tmdb_cls.return_value = mock_tmdb_instance
14 with patch("lib.clients.tmdb.tmdb.tmdb_get", return_value={"logos": []}):
15 TmdbClient._get_cached_tmdb_item_metadata(item, "movies")
16 get_call = mock_cache.get.call_args
17 assert get_call[0][0] == "tmdb_ui_meta|movies|123|ro-RO"
18
19
20def test_get_cached_tmdb_item_metadata_cache_set_uses_same_language_key():
21 item = {"id": 456, "name": "Show", "media_type": "tv"}
22 with patch("lib.clients.tmdb.tmdb.cache") as mock_cache:
23 mock_cache.get.return_value = None
24 with patch("lib.clients.tmdb.tmdb.TMDb") as mock_tmdb_cls:
25 mock_tmdb_instance = MagicMock()
26 mock_tmdb_instance.language = "de-DE"
27 mock_tmdb_cls.return_value = mock_tmdb_instance
28 with patch("lib.clients.tmdb.tmdb.tmdb_get", return_value={"logos": []}):
29 TmdbClient._get_cached_tmdb_item_metadata(item, "tv")
30 set_call = mock_cache.set.call_args
31 assert set_call[0][0] == "tmdb_ui_meta|tv|456|de-DE"
plugin.video.jacktook/tests/unit/test_tmdb_english_fallback.py +177 -0
……@@ -0,0 +1,177 @@
1from unittest.mock import MagicMock, patch
2
3
4class TestTmdbEnglishFallback:
5 @patch("lib.clients.tmdb.tmdb.TMDb")
6 @patch("lib.clients.tmdb.tmdb.cache")
7 @patch("lib.clients.tmdb.tmdb.tmdb_get")
8 def test_no_fallback_when_localized_data_complete(self, mock_tmdb_get, mock_cache, MockTMDb):
9 mock_tmdb = MagicMock()
10 mock_tmdb.language = "ro-RO"
11 MockTMDb.return_value = mock_tmdb
12
13 mock_cache.get.return_value = None
14
15 from lib.clients.tmdb.tmdb import TmdbClient
16
17 item = {
18 "id": 123,
19 "title": "Titlu Românesc",
20 "overview": "Descriere în română",
21 "media_type": "movie",
22 }
23
24 metadata = TmdbClient._get_cached_tmdb_item_metadata(item, "movies")
25
26 assert metadata["title"] == "Titlu Românesc"
27 assert metadata["overview"] == "Descriere în română"
28 # Should NOT call details endpoint for fallback
29 detail_calls = [
30 c for c in mock_tmdb_get.call_args_list if c[0][0] in ("movie_details", "tv_details")
31 ]
32 assert len(detail_calls) == 0
33
34 @patch("lib.clients.tmdb.tmdb.TMDb")
35 @patch("lib.clients.tmdb.tmdb.cache")
36 @patch("lib.clients.tmdb.tmdb.tmdb_get")
37 def test_fallback_to_english_when_title_missing(self, mock_tmdb_get, mock_cache, MockTMDb):
38 mock_tmdb = MagicMock()
39 mock_tmdb.language = "ro-RO"
40 MockTMDb.return_value = mock_tmdb
41
42 mock_cache.get.return_value = None
43
44 def tmdb_get_side_effect(path, params):
45 if path == "movie_images":
46 return None
47 if path == "movie_details":
48 return {
49 "id": 123,
50 "title": "English Title",
51 "overview": "English overview",
52 }
53 return None
54
55 mock_tmdb_get.side_effect = tmdb_get_side_effect
56
57 from lib.clients.tmdb.tmdb import TmdbClient
58
59 item = {
60 "id": 123,
61 "title": "",
62 "name": "",
63 "overview": "",
64 "media_type": "movie",
65 }
66
67 metadata = TmdbClient._get_cached_tmdb_item_metadata(item, "movies")
68
69 assert metadata["title"] == "English Title"
70 assert metadata["overview"] == "English overview"
71 # Should restore original language
72 assert mock_tmdb.language == "ro-RO"
73
74 @patch("lib.clients.tmdb.tmdb.TMDb")
75 @patch("lib.clients.tmdb.tmdb.cache")
76 @patch("lib.clients.tmdb.tmdb.tmdb_get")
77 def test_fallback_to_english_when_overview_missing(self, mock_tmdb_get, mock_cache, MockTMDb):
78 mock_tmdb = MagicMock()
79 mock_tmdb.language = "ro-RO"
80 MockTMDb.return_value = mock_tmdb
81
82 mock_cache.get.return_value = None
83
84 def tmdb_get_side_effect(path, params):
85 if path == "movie_images":
86 return None
87 if path == "movie_details":
88 return {
89 "id": 123,
90 "title": "Titlu Românesc",
91 "overview": "English overview",
92 }
93 return None
94
95 mock_tmdb_get.side_effect = tmdb_get_side_effect
96
97 from lib.clients.tmdb.tmdb import TmdbClient
98
99 item = {
100 "id": 123,
101 "title": "Titlu Românesc",
102 "overview": "",
103 "media_type": "movie",
104 }
105
106 metadata = TmdbClient._get_cached_tmdb_item_metadata(item, "movies")
107
108 assert metadata["title"] == "Titlu Românesc"
109 assert metadata["overview"] == "English overview"
110
111 @patch("lib.clients.tmdb.tmdb.TMDb")
112 @patch("lib.clients.tmdb.tmdb.cache")
113 @patch("lib.clients.tmdb.tmdb.tmdb_get")
114 def test_fallback_for_tv_shows(self, mock_tmdb_get, mock_cache, MockTMDb):
115 mock_tmdb = MagicMock()
116 mock_tmdb.language = "ro-RO"
117 MockTMDb.return_value = mock_tmdb
118
119 mock_cache.get.return_value = None
120
121 def tmdb_get_side_effect(path, params):
122 if path == "tv_images":
123 return None
124 if path == "tv_details":
125 return {
126 "id": 456,
127 "name": "English Show Name",
128 "overview": "English show overview",
129 }
130 return None
131
132 mock_tmdb_get.side_effect = tmdb_get_side_effect
133
134 from lib.clients.tmdb.tmdb import TmdbClient
135
136 item = {
137 "id": 456,
138 "name": "",
139 "overview": "",
140 "media_type": "tv",
141 }
142
143 metadata = TmdbClient._get_cached_tmdb_item_metadata(item, "tv")
144
145 assert metadata["name"] == "English Show Name"
146 assert metadata["overview"] == "English show overview"
147 assert mock_tmdb.language == "ro-RO"
148
149 @patch("lib.clients.tmdb.tmdb.TMDb")
150 @patch("lib.clients.tmdb.tmdb.cache")
151 @patch("lib.clients.tmdb.tmdb.tmdb_get")
152 def test_uses_cached_metadata_when_available(self, mock_tmdb_get, mock_cache, MockTMDb):
153 mock_tmdb = MagicMock()
154 mock_tmdb.language = "ro-RO"
155 MockTMDb.return_value = mock_tmdb
156
157 mock_cache.get.return_value = {
158 "title": "Cached Title",
159 "overview": "Cached overview",
160 "images": {"posters": []},
161 }
162
163 from lib.clients.tmdb.tmdb import TmdbClient
164
165 item = {
166 "id": 123,
167 "title": "",
168 "overview": "",
169 "media_type": "movie",
170 }
171
172 metadata = TmdbClient._get_cached_tmdb_item_metadata(item, "movies")
173
174 assert metadata["title"] == "Cached Title"
175 assert metadata["overview"] == "Cached overview"
176 # Should NOT make any API calls when cache hit
177 assert mock_tmdb_get.call_count == 0
plugin.video.jacktook/tests/unit/test_tmdb_get_cache.py +33 -0
……@@ -0,0 +1,33 @@
1from unittest.mock import MagicMock, patch
2
3from lib.clients.tmdb.utils.utils import tmdb_get
4
5
6def test_tmdb_get_includes_language_in_cache_key():
7 with patch("lib.clients.tmdb.utils.utils.cache") as mock_cache:
8 mock_cache.get.return_value = None
9 with patch("lib.clients.tmdb.utils.utils.TMDb") as mock_tmdb_cls:
10 mock_tmdb_instance = MagicMock()
11 mock_tmdb_instance.language = "ro-RO"
12 mock_tmdb_cls.return_value = mock_tmdb_instance
13 with patch("lib.clients.tmdb.utils.utils.Search") as mock_search_cls:
14 mock_search_cls.return_value.tv_shows.return_value = {"results": []}
15 tmdb_get("search_tv", {"query": "test"})
16 get_call = mock_cache.get.call_args
17 assert get_call[1]["key"].endswith("|ro-RO")
18 assert get_call[1]["key"].startswith("search_tv|")
19
20
21def test_tmdb_get_cache_set_uses_same_language_key():
22 with patch("lib.clients.tmdb.utils.utils.cache") as mock_cache:
23 mock_cache.get.return_value = None
24 with patch("lib.clients.tmdb.utils.utils.TMDb") as mock_tmdb_cls:
25 mock_tmdb_instance = MagicMock()
26 mock_tmdb_instance.language = "de-DE"
27 mock_tmdb_cls.return_value = mock_tmdb_instance
28 with patch("lib.clients.tmdb.utils.utils.Search") as mock_search_cls:
29 mock_search_cls.return_value.movies.return_value = {"results": []}
30 tmdb_get("search_movie", {"query": "film"})
31 set_call = mock_cache.set.call_args
32 assert set_call[1]["key"].endswith("|de-DE")
33 assert set_call[1]["key"].startswith("search_movie|")
plugin.video.jacktook/tests/unit/test_tmdb_init.py +62 -0
……@@ -0,0 +1,62 @@
1from unittest.mock import MagicMock, patch
2
3
4class TestEnsureTmdbInit:
5 @patch("lib.utils.tmdb_init.get_setting")
6 @patch("lib.utils.tmdb_init.TMDb")
7 def test_initializes_api_key_and_language(self, MockTMDb, mock_get_setting):
8 mock_tmdb = MagicMock()
9 MockTMDb.return_value = mock_tmdb
10 mock_get_setting.side_effect = lambda key, default=None: {
11 "tmdb_api_key": "test-key",
12 "language": 20,
13 }.get(key, default)
14
15 from lib.utils.tmdb_init import ensure_tmdb_init
16
17 ensure_tmdb_init()
18
19 assert mock_tmdb.api_key == "test-key"
20 assert mock_tmdb.language == "es-ES"
21
22 @patch("lib.utils.tmdb_init.get_setting")
23 @patch("lib.utils.tmdb_init.TMDb")
24 def test_updates_language_on_subsequent_calls(self, MockTMDb, mock_get_setting):
25 mock_tmdb = MagicMock()
26 MockTMDb.return_value = mock_tmdb
27
28 # First call: English
29 mock_get_setting.side_effect = lambda key, default=None: {
30 "tmdb_api_key": "test-key",
31 "language": 18,
32 }.get(key, default)
33
34 from lib.utils.tmdb_init import ensure_tmdb_init
35
36 ensure_tmdb_init()
37 assert mock_tmdb.language == "en-US"
38
39 # Second call: Romanian (index 50 in LANGUAGES)
40 mock_get_setting.side_effect = lambda key, default=None: {
41 "tmdb_api_key": "test-key",
42 "language": 50,
43 }.get(key, default)
44
45 ensure_tmdb_init()
46 assert mock_tmdb.language == "ro-RO"
47
48 @patch("lib.utils.tmdb_init.get_setting")
49 @patch("lib.utils.tmdb_init.TMDb")
50 def test_invalid_language_index_fallbacks_to_en_us(self, MockTMDb, mock_get_setting):
51 mock_tmdb = MagicMock()
52 MockTMDb.return_value = mock_tmdb
53 mock_get_setting.side_effect = lambda key, default=None: {
54 "tmdb_api_key": "test-key",
55 "language": 999,
56 }.get(key, default)
57
58 from lib.utils.tmdb_init import ensure_tmdb_init
59
60 ensure_tmdb_init()
61
62 assert mock_tmdb.language == "en-US"
plugin.video.jacktook/tests/unit/test_tmdb_multi_context_actions.py +13 -13
……@@ -1,7 +1,7 @@
11import json
22from types import SimpleNamespace
3from urllib.parse import parse_qs, urlparse
43from unittest.mock import MagicMock, patch
4from urllib.parse import parse_qs, urlparse
55
66from lib.clients.tmdb.people_client import PeopleClient
77from lib.clients.tmdb.tmdb import TmdbClient
……@@ -59,15 +59,15 @@ def test_movie_multi_context_menu_passes_media_type_to_context_actions():
5959
6060
6161def test_search_tmdb_recommendations_uses_movie_endpoint_for_multi_movie():
62 results = SimpleNamespace(total_results=1, total_pages=2, results=[SimpleNamespace(id=11, title="R")])
62 results = SimpleNamespace(
63 total_results=1, total_pages=2, results=[SimpleNamespace(id=11, title="R")]
64 )
6365
6466 with patch("lib.clients.tmdb.tmdb.tmdb_get", return_value=results) as tmdb_get, patch(
6567 "lib.clients.tmdb.tmdb.set_content_type"
66 ) as set_content_type, patch(
67 "lib.clients.tmdb.tmdb.set_pluging_category"
68 ), patch("lib.clients.tmdb.tmdb.make_list_item", return_value=MagicMock()), patch(
69 "lib.clients.tmdb.tmdb.set_media_infoTag"
70 ), patch(
68 ) as set_content_type, patch("lib.clients.tmdb.tmdb.set_pluging_category"), patch(
69 "lib.clients.tmdb.tmdb.make_list_item", return_value=MagicMock()
70 ), patch("lib.clients.tmdb.tmdb.set_media_infoTag"), patch(
7171 "lib.clients.tmdb.tmdb.BaseTmdbClient.add_media_directory_item",
7272 return_value=("url", MagicMock(), False),
7373 ), patch("lib.clients.tmdb.tmdb.add_directory_items_batch"), patch(
……@@ -92,15 +92,15 @@ def test_search_tmdb_recommendations_uses_movie_endpoint_for_multi_movie():
9292
9393
9494def test_search_tmdb_similar_uses_movie_endpoint_for_multi_movie():
95 results = SimpleNamespace(total_results=1, total_pages=2, results=[SimpleNamespace(id=22, title="S")])
95 results = SimpleNamespace(
96 total_results=1, total_pages=2, results=[SimpleNamespace(id=22, title="S")]
97 )
9698
9799 with patch("lib.clients.tmdb.tmdb.tmdb_get", return_value=results) as tmdb_get, patch(
98100 "lib.clients.tmdb.tmdb.set_content_type"
99 ) as set_content_type, patch(
100 "lib.clients.tmdb.tmdb.set_pluging_category"
101 ), patch("lib.clients.tmdb.tmdb.make_list_item", return_value=MagicMock()), patch(
102 "lib.clients.tmdb.tmdb.set_media_infoTag"
103 ), patch(
101 ) as set_content_type, patch("lib.clients.tmdb.tmdb.set_pluging_category"), patch(
102 "lib.clients.tmdb.tmdb.make_list_item", return_value=MagicMock()
103 ), patch("lib.clients.tmdb.tmdb.set_media_infoTag"), patch(
104104 "lib.clients.tmdb.tmdb.BaseTmdbClient.add_media_directory_item",
105105 return_value=("url", MagicMock(), False),
106106 ), patch("lib.clients.tmdb.tmdb.add_directory_items_batch"), patch(
plugin.video.jacktook/tests/unit/test_tmdb_search_modes.py +68 -19
……@@ -11,7 +11,9 @@ def test_tmdb_search_modes_runs_title_year_search_for_movies():
1111
1212 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
1313 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
14 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value="Inception Alt"), patch("lib.search.run_search_entry") as run_search_entry:
14 ) as dialog_cls, patch(
15 "lib.clients.tmdb.tmdb.show_keyboard", return_value="Inception Alt"
16 ), patch("lib.search.run_search_entry") as run_search_entry:
1517 dialog_cls.return_value.select.return_value = 0
1618 dialog_cls.return_value.numeric.return_value = "2010"
1719
……@@ -45,7 +47,10 @@ def test_tmdb_search_modes_runs_original_title_search_for_movies():
4547
4648 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
4749 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
48 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value="Sen to Chihiro no kamikakushi"), patch("lib.search.run_search_entry") as run_search_entry:
50 ) as dialog_cls, patch(
51 "lib.clients.tmdb.tmdb.show_keyboard",
52 return_value="Sen to Chihiro no kamikakushi",
53 ), patch("lib.search.run_search_entry") as run_search_entry:
4954 dialog_cls.return_value.select.return_value = 1
5055
5156 TmdbClient.tmdb_search_modes(
……@@ -71,7 +76,9 @@ def test_tmdb_search_modes_returns_early_when_dialog_cancelled():
7176
7277 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
7378 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
74 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value="Inception"), patch("lib.search.run_search_entry") as run_search_entry:
79 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value="Inception"), patch(
80 "lib.search.run_search_entry"
81 ) as run_search_entry:
7582 dialog_cls.return_value.select.return_value = -1
7683
7784 TmdbClient.tmdb_search_modes(
……@@ -93,7 +100,9 @@ def test_tmdb_search_modes_returns_early_when_year_input_cancelled():
93100
94101 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
95102 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
96 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value="Inception"), patch("lib.search.run_search_entry") as run_search_entry:
103 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value="Inception"), patch(
104 "lib.search.run_search_entry"
105 ) as run_search_entry:
97106 dialog_cls.return_value.select.return_value = 0
98107 dialog_cls.return_value.numeric.return_value = ""
99108
……@@ -116,9 +125,9 @@ def test_tmdb_search_modes_rejects_invalid_manual_year():
116125
117126 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
118127 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
119 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value="Inception"), patch("lib.clients.tmdb.tmdb.notification") as notification, patch(
120 "lib.search.run_search_entry"
121 ) as run_search_entry:
128 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value="Inception"), patch(
129 "lib.clients.tmdb.tmdb.notification"
130 ) as notification, patch("lib.search.run_search_entry") as run_search_entry:
122131 dialog_cls.return_value.select.return_value = 2
123132 dialog_cls.return_value.numeric.return_value = "20ab"
124133
……@@ -166,9 +175,9 @@ def test_tmdb_episode_search_modes_runs_search_with_edited_title_season_episode(
166175
167176 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
168177 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
169 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value="Breaking Bad Alt"), patch(
170 "lib.search.run_search_entry"
171 ) as run_search_entry:
178 ) as dialog_cls, patch(
179 "lib.clients.tmdb.tmdb.show_keyboard", return_value="Breaking Bad Alt"
180 ), patch("lib.search.run_search_entry") as run_search_entry:
172181 dialog_cls.return_value.select.return_value = 0
173182 dialog_cls.return_value.numeric.side_effect = ["2", "5"]
174183
……@@ -203,9 +212,9 @@ def test_tmdb_episode_search_modes_runs_search_with_original_title():
203212
204213 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
205214 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
206 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value="Breaking Bad Original"), patch(
207 "lib.search.run_search_entry"
208 ) as run_search_entry:
215 ) as dialog_cls, patch(
216 "lib.clients.tmdb.tmdb.show_keyboard", return_value="Breaking Bad Original"
217 ), patch("lib.search.run_search_entry") as run_search_entry:
209218 dialog_cls.return_value.select.return_value = 1
210219 dialog_cls.return_value.numeric.side_effect = ["3", "8"]
211220
……@@ -226,6 +235,44 @@ def test_tmdb_episode_search_modes_runs_search_with_original_title():
226235 assert tv_data["episode"] == 8
227236
228237
238def test_tmdb_episode_search_modes_runs_full_show_title_year_without_tv_data():
239 tmdb_obj = MagicMock()
240 tmdb_obj.original_name = "Breaking Bad"
241
242 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
243 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
244 ) as dialog_cls, patch(
245 "lib.clients.tmdb.tmdb.show_keyboard", return_value="Breaking Bad Alt"
246 ), patch("lib.search.run_search_entry") as run_search_entry:
247 dialog_cls.return_value.select.return_value = 2
248 dialog_cls.return_value.numeric.return_value = "2008"
249
250 TmdbClient.tmdb_episode_search_modes(
251 {
252 "mode": "tv",
253 "media_type": "tv",
254 "query": "Breaking Bad",
255 "ids": json.dumps({"tmdb_id": "1396", "imdb_id": "tt0903747", "tvdb_id": "81189"}),
256 "tv_data": json.dumps({"name": "Episode 5", "season": 1, "episode": 5}),
257 }
258 )
259
260 payload = run_search_entry.call_args[0][0]
261 assert payload["query"] == "Breaking Bad Alt"
262 assert payload["mode"] == "tv"
263 assert payload["search_variant"] == "title_year"
264 assert payload["year"] == "2008"
265 assert payload["rescrape"] is True
266 assert payload["force_select"] is True
267 assert "tv_data" not in payload
268 assert json.loads(payload["ids"]) == {
269 "tmdb_id": "1396",
270 "imdb_id": "tt0903747",
271 "tvdb_id": "81189",
272 }
273 assert dialog_cls.return_value.numeric.call_count == 1
274
275
229276def test_tmdb_episode_search_modes_returns_early_when_selector_cancelled():
230277 tmdb_obj = MagicMock()
231278 tmdb_obj.original_name = "Breaking Bad"
……@@ -280,9 +327,9 @@ def test_tmdb_episode_search_modes_returns_early_when_season_cancelled():
280327
281328 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
282329 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
283 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value="Breaking Bad"), patch(
284 "lib.search.run_search_entry"
285 ) as run_search_entry:
330 ) as dialog_cls, patch(
331 "lib.clients.tmdb.tmdb.show_keyboard", return_value="Breaking Bad"
332 ), patch("lib.search.run_search_entry") as run_search_entry:
286333 dialog_cls.return_value.select.return_value = 0
287334 dialog_cls.return_value.numeric.return_value = ""
288335
……@@ -305,9 +352,11 @@ def test_tmdb_episode_search_modes_rejects_invalid_season():
305352
306353 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
307354 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
308 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value="Breaking Bad"), patch(
309 "lib.clients.tmdb.tmdb.notification"
310 ) as notification, patch("lib.search.run_search_entry") as run_search_entry:
355 ) as dialog_cls, patch(
356 "lib.clients.tmdb.tmdb.show_keyboard", return_value="Breaking Bad"
357 ), patch("lib.clients.tmdb.tmdb.notification") as notification, patch(
358 "lib.search.run_search_entry"
359 ) as run_search_entry:
311360 dialog_cls.return_value.select.return_value = 0
312361 dialog_cls.return_value.numeric.return_value = "abc"
313362
plugin.video.jacktook/tests/unit/test_tmdb_trailers.py +6 -2
……@@ -1,4 +1,3 @@
1import os
21from unittest.mock import patch
32
43from lib.api.tmdbv3api.as_obj import AsObj
……@@ -52,7 +51,12 @@ def test_choose_tmdb_trailer_prefers_trailer_over_teaser_then_official_flag():
5251def test_choose_tmdb_trailer_ignores_non_youtube_and_unrelated_types():
5352 videos = [
5453 {"site": "Vimeo", "type": "Trailer", "official": True, "key": "vimeo123456"},
55 {"site": "YouTube", "type": "Behind the Scenes", "official": True, "key": "bts12345678"},
54 {
55 "site": "YouTube",
56 "type": "Behind the Scenes",
57 "official": True,
58 "key": "bts12345678",
59 },
5660 ]
5761
5862 assert choose_tmdb_trailer(videos) is None
plugin.video.jacktook/tests/unit/test_tmdb_trakt_integration.py +20 -11
……@@ -7,18 +7,24 @@ def test_movie_item_adds_trakt_context_actions_when_authenticated():
77 list_item = MagicMock()
88 ids = {"tmdb_id": 10, "imdb_id": "tt0010", "tvdb_id": ""}
99
10 with patch("lib.clients.tmdb.base.add_tmdb_movie_context_menu", return_value=[("tmdb", "cmd")]), patch(
11 "lib.clients.tmdb.base.is_trakt_auth", return_value=True
10 with patch(
11 "lib.clients.tmdb.base.add_tmdb_movie_context_menu",
12 return_value=[("tmdb", "cmd")],
13 ), patch("lib.clients.tmdb.base.is_trakt_auth", return_value=True), patch(
14 "lib.clients.tmdb.base.add_trakt_watchlist_context_menu",
15 return_value=[("watchlist", "cmd")],
1216 ), patch(
13 "lib.clients.tmdb.base.add_trakt_watchlist_context_menu", return_value=[("watchlist", "cmd")]
17 "lib.clients.tmdb.base.add_trakt_watched_context_menu",
18 return_value=[("watched", "cmd")],
1419 ), patch(
15 "lib.clients.tmdb.base.add_trakt_watched_context_menu", return_value=[("watched", "cmd")]
20 "lib.clients.tmdb.base.add_trakt_collection_context_menu",
21 return_value=[("collection", "cmd")],
1622 ), patch(
17 "lib.clients.tmdb.base.add_trakt_collection_context_menu", return_value=[("collection", "cmd")]
23 "lib.clients.tmdb.base.add_trakt_favorites_context_menu",
24 return_value=[("favorites", "cmd")],
1825 ), patch(
19 "lib.clients.tmdb.base.add_trakt_favorites_context_menu", return_value=[("favorites", "cmd")]
20 ), patch(
21 "lib.clients.tmdb.base.add_trakt_custom_list_context_menu", return_value=[("customlist", "cmd")]
26 "lib.clients.tmdb.base.add_trakt_custom_list_context_menu",
27 return_value=[("customlist", "cmd")],
2228 ), patch("lib.clients.tmdb.base.add_kodi_dir_item"):
2329 BaseTmdbClient.add_media_directory_item(list_item, "movies", "Demo Movie", ids)
2430
……@@ -39,9 +45,12 @@ def test_tv_item_skips_trakt_context_actions_when_not_authenticated():
3945 list_item = MagicMock()
4046 ids = {"tmdb_id": 20, "imdb_id": "tt0020", "tvdb_id": 200}
4147
42 with patch("lib.clients.tmdb.base.add_tmdb_show_context_menu", return_value=[("tmdb-tv", "cmd")]), patch(
43 "lib.clients.tmdb.base.is_trakt_auth", return_value=False
44 ), patch("lib.clients.tmdb.base.add_kodi_dir_item"):
48 with patch(
49 "lib.clients.tmdb.base.add_tmdb_show_context_menu",
50 return_value=[("tmdb-tv", "cmd")],
51 ), patch("lib.clients.tmdb.base.is_trakt_auth", return_value=False), patch(
52 "lib.clients.tmdb.base.add_kodi_dir_item"
53 ):
4554 BaseTmdbClient.add_media_directory_item(list_item, "tv", "Demo Show", ids)
4655
4756 list_item.addContextMenuItems.assert_called_once_with([("tmdb-tv", "cmd")])
plugin.video.jacktook/tests/unit/test_torrserver_api.py +195 -0
……@@ -0,0 +1,195 @@
1"""Tests for TorrServer API client response normalization.
2
3Regression tests for issue #193: TorrServer may return a JSON list
4instead of a dict for some endpoints, causing TypeError when
5accessing ["hash"] on the list.
6"""
7
8from unittest.mock import MagicMock
9
10import pytest
11
12from lib.api.jacktorr.jacktorr import TorrServer, TorrServerError
13
14
15def _make_response(json_data, status_code=200):
16 """Create a mock requests.Response with the given json data and status code."""
17 response = MagicMock()
18 response.json.return_value = json_data
19 response.status_code = status_code
20 response.text = str(json_data)
21 return response
22
23
24@pytest.fixture
25def torrserver():
26 """Create a TorrServer instance with a mock session."""
27 session = MagicMock()
28 return TorrServer(
29 host="localhost",
30 port=8090,
31 username="admin",
32 password="pass",
33 ssl_enabled=False,
34 session=session,
35 )
36
37
38class TestNormalizeJsonResponse:
39 """Tests for TorrServer._normalize_json_response."""
40
41 def test_dict_passthrough(self):
42 """Dict responses pass through unchanged."""
43 data = {"hash": "abc123", "stat": 3}
44 result = TorrServer._normalize_json_response(data)
45 assert result == data
46
47 def test_list_with_one_element_unwrapped(self):
48 """A single-element list is unwrapped to its first element."""
49 data = [{"hash": "abc123", "stat": 3}]
50 result = TorrServer._normalize_json_response(data)
51 assert result == {"hash": "abc123", "stat": 3}
52
53 def test_list_with_multiple_elements_takes_first(self):
54 """A multi-element list takes only the first element."""
55 data = [{"hash": "abc123"}, {"hash": "def456"}]
56 result = TorrServer._normalize_json_response(data)
57 assert result == {"hash": "abc123"}
58
59 def test_empty_list_raises_error(self):
60 """An empty list raises TorrServerError."""
61 with pytest.raises(TorrServerError, match="empty list"):
62 TorrServer._normalize_json_response([])
63
64 def test_non_dict_non_list_passthrough(self):
65 """Non-dict, non-list values pass through (edge case)."""
66 # Strings or numbers should come through as-is; the caller
67 # will get a KeyError when accessing ["hash"], which is expected.
68 result = TorrServer._normalize_json_response(42)
69 assert result == 42
70
71
72class TestExtractHash:
73 """Tests for TorrServer._extract_hash."""
74
75 def test_dict_response_returns_hash(self, torrserver):
76 """Standard dict response extracts hash correctly."""
77 response = _make_response({"hash": "abc123", "stat": 3})
78 result = torrserver._extract_hash(response)
79 assert result == "abc123"
80
81 def test_list_response_extracts_hash(self, torrserver):
82 """List-wrapped response is unwrapped and hash is extracted."""
83 response = _make_response([{"hash": "abc123", "stat": 3}])
84 result = torrserver._extract_hash(response)
85 assert result == "abc123"
86
87 def test_list_with_multiple_items_takes_first_hash(self, torrserver):
88 """Multi-item list takes first element's hash."""
89 response = _make_response([{"hash": "first_hash"}, {"hash": "second_hash"}])
90 result = torrserver._extract_hash(response)
91 assert result == "first_hash"
92
93 def test_empty_list_raises_error(self, torrserver):
94 """Empty list response raises TorrServerError."""
95 response = _make_response([])
96 with pytest.raises(TorrServerError, match="empty list"):
97 torrserver._extract_hash(response)
98
99 def test_missing_hash_key_raises_key_error(self, torrserver):
100 """Response without 'hash' key raises KeyError."""
101 response = _make_response({"stat": 3, "name": "test"})
102 with pytest.raises(KeyError):
103 torrserver._extract_hash(response)
104
105
106class TestAddMagnet:
107 """Tests for TorrServer.add_magnet with normalized responses."""
108
109 def test_add_magnet_dict_response(self, torrserver):
110 """add_magnet handles dict response from TorrServer."""
111 torrserver._session.request.return_value = _make_response({"hash": "magnet_hash_1"})
112 result = torrserver.add_magnet("magnet:?xt=urn:btih:abc123")
113 assert result == "magnet_hash_1"
114
115 def test_add_magnet_list_response(self, torrserver):
116 """add_magnet handles list response from TorrServer (issue #193)."""
117 torrserver._session.request.return_value = _make_response([{"hash": "magnet_hash_1"}])
118 result = torrserver.add_magnet("magnet:?xt=urn:btih:abc123")
119 assert result == "magnet_hash_1"
120
121
122class TestAddTorrentObj:
123 """Tests for TorrServer.add_torrent_obj with normalized responses."""
124
125 def test_add_torrent_obj_dict_response(self, torrserver):
126 """add_torrent_obj handles dict response from TorrServer."""
127 torrserver._session.request.return_value = _make_response({"hash": "torrent_hash_1"})
128 mock_file = MagicMock()
129 result = torrserver.add_torrent_obj(mock_file)
130 assert result == "torrent_hash_1"
131
132 def test_add_torrent_obj_list_response(self, torrserver):
133 """add_torrent_obj handles list response from TorrServer (issue #193)."""
134 torrserver._session.request.return_value = _make_response([{"hash": "torrent_hash_1"}])
135 mock_file = MagicMock()
136 result = torrserver.add_torrent_obj(mock_file)
137 assert result == "torrent_hash_1"
138
139
140class TestAddTorrent:
141 """Tests for TorrServer.add_torrent with normalized responses."""
142
143 def test_add_torrent_dict_response(self, torrserver, tmp_path):
144 """add_torrent handles dict response from TorrServer."""
145 torrserver._session.request.return_value = _make_response({"hash": "file_hash_1"})
146 torrent_file = tmp_path / "test.torrent"
147 torrent_file.write_bytes(b"d8:announce17:http://test.com4:info6:teste")
148 result = torrserver.add_torrent(str(torrent_file))
149 assert result == "file_hash_1"
150
151 def test_add_torrent_list_response(self, torrserver, tmp_path):
152 """add_torrent handles list response from TorrServer (issue #193)."""
153 torrserver._session.request.return_value = _make_response([{"hash": "file_hash_1"}])
154 torrent_file = tmp_path / "test.torrent"
155 torrent_file.write_bytes(b"d8:announce17:http://test.com4:info6:teste")
156 result = torrserver.add_torrent(str(torrent_file))
157 assert result == "file_hash_1"
158
159
160class TestGetTorrentInfo:
161 """Tests for TorrServer info methods with normalized responses."""
162
163 def test_get_torrent_info_dict(self, torrserver):
164 """get_torrent_info handles dict response."""
165 expected = {"hash": "abc123", "stat": 3, "file_stats": []}
166 torrserver._session.request.return_value = _make_response(expected)
167 result = torrserver.get_torrent_info("abc123")
168 assert result == expected
169
170 def test_get_torrent_info_list(self, torrserver):
171 """get_torrent_info handles list response (defensive)."""
172 expected_inner = {"hash": "abc123", "stat": 3, "file_stats": []}
173 torrserver._session.request.return_value = _make_response([expected_inner])
174 result = torrserver.get_torrent_info("abc123")
175 assert result == expected_inner
176
177 def test_torrents_dict(self, torrserver):
178 """torrents() handles dict response."""
179 expected = {"torrents": []}
180 torrserver._session.request.return_value = _make_response(expected)
181 result = torrserver.torrents()
182 assert result == expected
183
184 def test_torrents_list(self, torrserver):
185 """torrents() returns raw list from the list endpoint."""
186 expected = [{"hash": "abc123"}]
187 torrserver._session.request.return_value = _make_response(expected)
188 result = torrserver.torrents()
189 assert result == expected
190
191 def test_torrents_empty_list(self, torrserver):
192 """torrents() handles empty list gracefully (no TorrServerError)."""
193 torrserver._session.request.return_value = _make_response([])
194 result = torrserver.torrents()
195 assert result == []
plugin.video.jacktook/tests/unit/test_torrserver_context_menus.py +198 -0
……@@ -0,0 +1,198 @@
1import importlib
2import json
3import sys
4from unittest.mock import MagicMock, patch
5
6
7def _load_navigation_module():
8 if "lib.navigation" in sys.modules:
9 return importlib.reload(sys.modules["lib.navigation"])
10 return importlib.import_module("lib.navigation")
11
12
13def _load_torrserver_utils():
14 if "lib.utils.torrent.torrserver_utils" in sys.modules:
15 return importlib.reload(sys.modules["lib.utils.torrent.torrserver_utils"])
16 return importlib.import_module("lib.utils.torrent.torrserver_utils")
17
18
19class TestTorrentsContextMenu:
20 def test_adds_download_subtitles_context_menu(self):
21 nav = _load_navigation_module()
22
23 mock_torrent = {
24 "hash": "abc123",
25 "title": "Test Movie",
26 "stat": 1,
27 }
28
29 mock_meta = {
30 "title": "Test Movie",
31 "mode": "tv",
32 "ids": {"imdb_id": "tt123"},
33 "tv_data": {"season": 1, "episode": 2},
34 }
35
36 with patch.object(nav, "get_torrserver_api") as mock_api, patch.object(
37 nav, "JACKTORR_ADDON", True
38 ), patch.object(nav, "end_of_directory"), patch.object(
39 nav, "apply_section_view"
40 ), patch.object(nav, "set_pluging_category"), patch.object(
41 nav, "action_url_run"
42 ) as mock_action_url, patch.object(nav, "addDirectoryItem"), patch(
43 "lib.utils.torrent.torrserver_utils.get_torrent_meta",
44 return_value=mock_meta,
45 ):
46 mock_api.return_value.torrents.return_value = [mock_torrent]
47 mock_action_url.side_effect = lambda name, **kwargs: (
48 f"RunPlugin({nav.build_url(name, **kwargs)})"
49 )
50
51 nav.torrents({})
52
53 # Find the call for download_torrent_subtitles
54 subtitle_calls = [
55 call
56 for call in mock_action_url.call_args_list
57 if call.args[0] == "download_torrent_subtitles"
58 ]
59 assert len(subtitle_calls) == 1
60 call_kwargs = subtitle_calls[0].kwargs
61 assert call_kwargs["hash"] == "abc123"
62 meta = json.loads(call_kwargs["meta"])
63 assert meta["title"] == "Test Movie"
64 assert meta["ids"]["imdb_id"] == "tt123"
65
66 def test_uses_torrent_title_when_data_missing(self):
67 nav = _load_navigation_module()
68
69 mock_torrent = {
70 "hash": "abc123",
71 "title": "Fallback Title",
72 "stat": 1,
73 }
74
75 with patch.object(nav, "get_torrserver_api") as mock_api, patch.object(
76 nav, "JACKTORR_ADDON", True
77 ), patch.object(nav, "end_of_directory"), patch.object(
78 nav, "apply_section_view"
79 ), patch.object(nav, "set_pluging_category"), patch.object(
80 nav, "action_url_run"
81 ) as mock_action_url, patch.object(nav, "addDirectoryItem"), patch(
82 "lib.utils.torrent.torrserver_utils.get_torrent_meta", return_value={}
83 ):
84 mock_api.return_value.torrents.return_value = [mock_torrent]
85 mock_action_url.side_effect = lambda name, **kwargs: (
86 f"RunPlugin({nav.build_url(name, **kwargs)})"
87 )
88
89 nav.torrents({})
90
91 subtitle_calls = [
92 call
93 for call in mock_action_url.call_args_list
94 if call.args[0] == "download_torrent_subtitles"
95 ]
96 assert len(subtitle_calls) == 1
97 meta = json.loads(subtitle_calls[0].kwargs["meta"])
98 assert meta["title"] == "Fallback Title"
99 assert meta["ids"]["imdb_id"] is None
100
101
102class TestTorrentFilesContextMenu:
103 def test_adds_download_subtitles_for_video_files(self):
104 utils = _load_torrserver_utils()
105
106 mock_video_tag = MagicMock()
107 mock_list_item = MagicMock()
108 mock_list_item.getVideoInfoTag.return_value = mock_video_tag
109
110 mock_meta = {
111 "title": "Test Movie",
112 "mode": "movies",
113 "ids": {"imdb_id": "tt123"},
114 "tv_data": {},
115 }
116
117 with patch.object(utils, "get_torrserver_api") as mock_api, patch.object(
118 utils, "is_video", return_value=True
119 ), patch.object(utils, "is_picture", return_value=False), patch.object(
120 utils, "is_text", return_value=False
121 ), patch.object(utils, "is_music", return_value=False), patch.object(
122 utils, "set_pluging_category"
123 ), patch.object(utils, "end_of_directory"), patch.object(
124 utils, "action_url_run"
125 ) as mock_action_url, patch.object(utils, "addDirectoryItem") as mock_add_dir, patch.object(
126 utils, "build_list_item", return_value=mock_list_item
127 ), patch.object(utils, "get_torrent_meta", return_value=mock_meta):
128 mock_api.return_value.get_torrent_info.return_value = {
129 "title": "Test Movie",
130 "hash": "abc123",
131 "file_stats": [{"path": "movie.mkv", "id": "1"}],
132 }
133 mock_api.return_value.get_stream_url.return_value = "http://serve/url"
134 mock_action_url.side_effect = lambda name, **kwargs: (
135 f"RunPlugin({utils.build_url(name, **kwargs)})"
136 )
137
138 utils.torrent_files({"info_hash": "abc123"})
139
140 subtitle_calls = [
141 call
142 for call in mock_action_url.call_args_list
143 if call.args[0] == "download_and_play_subtitles"
144 ]
145 assert len(subtitle_calls) == 1
146 call_kwargs = subtitle_calls[0].kwargs
147 assert call_kwargs["hash"] == "abc123"
148 assert call_kwargs["file_id"] == "1"
149 assert call_kwargs["path"] == "movie.mkv"
150 meta = json.loads(call_kwargs["meta"])
151 assert meta["title"] == "Test Movie"
152 assert meta["ids"]["imdb_id"] == "tt123"
153 mock_list_item.getVideoInfoTag.assert_called_once()
154 mock_video_tag.setTitle.assert_called_once_with("Test Movie")
155 jacktorr_url = "plugin://plugin.video.jacktorr/buffer_and_play?info_hash=abc123&file_id=1&path=movie.mkv"
156 mock_list_item.setPath.assert_any_call(jacktorr_url)
157 mock_add_dir.assert_called_once_with(utils.ADDON_HANDLE, jacktorr_url, mock_list_item)
158
159 def test_uses_torrent_title_when_data_missing(self):
160 utils = _load_torrserver_utils()
161
162 mock_video_tag = MagicMock()
163 mock_list_item = MagicMock()
164 mock_list_item.getVideoInfoTag.return_value = mock_video_tag
165
166 with patch.object(utils, "get_torrserver_api") as mock_api, patch.object(
167 utils, "is_video", return_value=True
168 ), patch.object(utils, "is_picture", return_value=False), patch.object(
169 utils, "is_text", return_value=False
170 ), patch.object(utils, "is_music", return_value=False), patch.object(
171 utils, "set_pluging_category"
172 ), patch.object(utils, "end_of_directory"), patch.object(
173 utils, "action_url_run"
174 ) as mock_action_url, patch.object(utils, "addDirectoryItem"), patch.object(
175 utils, "build_list_item", return_value=mock_list_item
176 ), patch.object(utils, "get_torrent_meta", return_value={}):
177 mock_api.return_value.get_torrent_info.return_value = {
178 "title": "Fallback Title",
179 "hash": "abc123",
180 "file_stats": [{"path": "movie.mkv", "id": "1"}],
181 }
182 mock_api.return_value.get_stream_url.return_value = "http://serve/url"
183 mock_action_url.side_effect = lambda name, **kwargs: (
184 f"RunPlugin({utils.build_url(name, **kwargs)})"
185 )
186
187 utils.torrent_files({"info_hash": "abc123"})
188
189 subtitle_calls = [
190 call
191 for call in mock_action_url.call_args_list
192 if call.args[0] == "download_and_play_subtitles"
193 ]
194 assert len(subtitle_calls) == 1
195 meta = json.loads(subtitle_calls[0].kwargs["meta"])
196 assert meta["title"] == "Fallback Title"
197 assert meta["ids"]["imdb_id"] is None
198 mock_video_tag.setTitle.assert_called_once_with("Fallback Title")
plugin.video.jacktook/tests/unit/test_torrserver_subtitles.py +328 -0
……@@ -0,0 +1,328 @@
1import importlib
2import json
3import sys
4from unittest.mock import MagicMock, patch
5
6
7def _load_torrserver_utils():
8 if "lib.utils.torrent.torrserver_utils" in sys.modules:
9 return importlib.reload(sys.modules["lib.utils.torrent.torrserver_utils"])
10 return importlib.import_module("lib.utils.torrent.torrserver_utils")
11
12
13class TestDownloadTorrentSubtitles:
14 def test_calls_subtitle_manager_with_imdb_id(self):
15 module = _load_torrserver_utils()
16 params = {
17 "hash": "abc123",
18 "meta": json.dumps({"title": "Test Movie", "ids": {"imdb_id": "tt123"}}),
19 }
20
21 with patch.object(module, "SubtitleManager") as MockManager, patch.object(
22 module, "notification"
23 ) as mock_notification, patch.object(
24 module, "ADDON_PROFILE_PATH", "/addon/profile"
25 ), patch.object(module.os.path, "exists", return_value=True), patch.object(
26 module, "get_torrserver_api"
27 ) as mock_api, patch.object(module.xbmcgui, "ListItem") as MockListItem, patch.object(
28 module.xbmc, "Player"
29 ) as MockPlayer:
30 mock_manager = MagicMock()
31 mock_manager.fetch_subtitles.return_value = ["/path/to/sub.srt"]
32 mock_manager.last_fetch_status = None
33 MockManager.return_value = mock_manager
34 mock_api.return_value.get_torrent_info.return_value = {
35 "file_stats": [{"path": "movie.mkv", "id": "1"}]
36 }
37 mock_listitem = MagicMock()
38 MockListItem.return_value = mock_listitem
39 mock_player = MagicMock()
40 MockPlayer.return_value = mock_player
41
42 module.download_torrent_subtitles(params)
43
44 MockManager.assert_called_once_with(
45 data={
46 "title": "Test Movie",
47 "mode": "movies",
48 "ids": {
49 "imdb_id": "tt123",
50 "tmdb_id": "",
51 "tvdb_id": "",
52 "original_id": "",
53 },
54 "tv_data": {},
55 },
56 notification=module.notification,
57 )
58 mock_manager.fetch_subtitles.assert_called_once_with(
59 auto_select=False, folder_path="/addon/profile/subtitles/abc123"
60 )
61 mock_notification.assert_called_once_with("Subtitles downloaded successfully")
62 playback_url = "plugin://plugin.video.jacktorr/buffer_and_play?info_hash=abc123&file_id=1&path=movie.mkv"
63 MockListItem.assert_called_once_with(label="Test Movie", path=playback_url)
64 mock_listitem.setSubtitles.assert_called_once_with(["/path/to/sub.srt"])
65 mock_player.play.assert_called_once_with(playback_url, mock_listitem)
66
67 def test_notifies_when_no_subtitles_found(self):
68 module = _load_torrserver_utils()
69 params = {
70 "hash": "abc123",
71 "meta": json.dumps({"title": "Test Movie", "ids": {"imdb_id": "tt123"}}),
72 }
73
74 with patch.object(module, "SubtitleManager") as MockManager, patch.object(
75 module, "notification"
76 ) as mock_notification:
77 mock_manager = MagicMock()
78 mock_manager.fetch_subtitles.return_value = None
79 mock_manager.last_fetch_status = "not_found"
80 MockManager.return_value = mock_manager
81
82 module.download_torrent_subtitles(params)
83
84 mock_notification.assert_called_once_with(module.translation(90252))
85
86 def test_title_fallback_used_when_no_imdb_id(self):
87 module = _load_torrserver_utils()
88 params = {
89 "hash": "abc123",
90 "meta": json.dumps({"title": "Test Movie"}),
91 }
92
93 with patch.object(module, "SubtitleManager") as MockManager, patch.object(
94 module, "notification"
95 ) as mock_notification, patch.object(
96 module, "ADDON_PROFILE_PATH", "/addon/profile"
97 ), patch.object(module.os.path, "exists", return_value=True):
98 mock_manager = MagicMock()
99 mock_manager.fetch_subtitles.return_value = None
100 mock_manager.last_fetch_status = "no_imdb"
101 MockManager.return_value = mock_manager
102
103 module.download_torrent_subtitles(params)
104
105 MockManager.assert_called_once_with(
106 data={
107 "title": "Test Movie",
108 "mode": "movies",
109 "ids": {
110 "tmdb_id": "",
111 "tvdb_id": "",
112 "imdb_id": "",
113 "original_id": "",
114 },
115 "tv_data": {},
116 },
117 notification=module.notification,
118 )
119 mock_manager.fetch_subtitles.assert_called_once_with(
120 auto_select=False, folder_path="/addon/profile/subtitles/abc123"
121 )
122 mock_notification.assert_called_once_with(module.translation(90299))
123
124 def test_aborts_when_no_imdb_id_and_no_title(self):
125 module = _load_torrserver_utils()
126 params = {
127 "hash": "abc123",
128 "meta": json.dumps({}),
129 }
130
131 with patch.object(module, "SubtitleManager") as MockManager, patch.object(
132 module, "notification"
133 ) as mock_notification:
134 module.download_torrent_subtitles(params)
135
136 MockManager.assert_not_called()
137 mock_notification.assert_called_once_with("Insufficient metadata")
138
139
140class TestDownloadAndPlaySubtitles:
141 def test_plays_video_with_subtitles(self):
142 module = _load_torrserver_utils()
143 params = {
144 "hash": "abc123",
145 "file_id": "1",
146 "path": "movie.mkv",
147 "meta": json.dumps({"title": "Test Movie", "ids": {"imdb_id": "tt123"}}),
148 }
149
150 with patch.object(module, "SubtitleManager") as MockManager, patch.object(
151 module, "get_setting", return_value=False
152 ), patch.object(module.xbmcgui, "ListItem") as MockListItem, patch.object(
153 module.xbmc, "Player"
154 ) as MockPlayer, patch.object(module, "notification") as mock_notification:
155 mock_manager = MagicMock()
156 mock_manager.fetch_subtitles.return_value = ["/path/to/sub.srt"]
157 mock_manager.last_fetch_status = None
158 MockManager.return_value = mock_manager
159
160 mock_player = MagicMock()
161 MockPlayer.return_value = mock_player
162 mock_listitem = MagicMock()
163 MockListItem.return_value = mock_listitem
164
165 module.download_and_play_subtitles(params)
166
167 playback_url = "plugin://plugin.video.jacktorr/buffer_and_play?info_hash=abc123&file_id=1&path=movie.mkv"
168 MockListItem.assert_called_once_with(label="Test Movie", path=playback_url)
169 mock_listitem.setSubtitles.assert_called_once_with(["/path/to/sub.srt"])
170 mock_player.play.assert_called_once_with(playback_url, mock_listitem)
171 mock_notification.assert_not_called()
172
173 def test_uses_subtitle_paths_returned_by_manager_without_second_translation_prompt(
174 self,
175 ):
176 module = _load_torrserver_utils()
177 params = {
178 "hash": "abc123",
179 "file_id": "1",
180 "path": "movie.mkv",
181 "meta": json.dumps({"title": "Test Movie", "ids": {"imdb_id": "tt123"}}),
182 }
183
184 with patch.object(module, "SubtitleManager") as MockManager, patch.object(
185 module, "DeepLTranslator"
186 ) as MockTranslator, patch.object(module, "get_setting", return_value=True), patch.object(
187 module.xbmcgui.Dialog, "yesno", return_value=True
188 ), patch.object(module.xbmcgui, "ListItem") as MockListItem, patch.object(
189 module.xbmc, "Player"
190 ) as MockPlayer, patch.object(module, "notification"):
191 mock_manager = MagicMock()
192 mock_manager.fetch_subtitles.return_value = ["/path/to/translated.srt"]
193 MockManager.return_value = mock_manager
194
195 mock_player = MagicMock()
196 MockPlayer.return_value = mock_player
197 mock_listitem = MagicMock()
198 MockListItem.return_value = mock_listitem
199
200 module.download_and_play_subtitles(params)
201
202 mock_manager.fetch_subtitles.assert_called_once_with(auto_select=False)
203 MockTranslator.assert_not_called()
204 mock_listitem.setSubtitles.assert_called_once_with(["/path/to/translated.srt"])
205
206 def test_deepL_no_uses_original_subtitles(self):
207 module = _load_torrserver_utils()
208 params = {
209 "hash": "abc123",
210 "file_id": "1",
211 "path": "movie.mkv",
212 "meta": json.dumps({"title": "Test Movie", "ids": {"imdb_id": "tt123"}}),
213 }
214
215 with patch.object(module, "SubtitleManager") as MockManager, patch.object(
216 module, "DeepLTranslator"
217 ) as MockTranslator, patch.object(module, "get_setting", return_value=True), patch.object(
218 module.xbmcgui.Dialog, "yesno", return_value=False
219 ), patch.object(module.xbmcgui, "ListItem") as MockListItem, patch.object(
220 module.xbmc, "Player"
221 ) as MockPlayer, patch.object(module, "notification"):
222 mock_manager = MagicMock()
223 mock_manager.fetch_subtitles.return_value = ["/path/to/sub.srt"]
224 MockManager.return_value = mock_manager
225
226 mock_player = MagicMock()
227 MockPlayer.return_value = mock_player
228 mock_listitem = MagicMock()
229 MockListItem.return_value = mock_listitem
230
231 module.download_and_play_subtitles(params)
232
233 MockTranslator.assert_not_called()
234 mock_listitem.setSubtitles.assert_called_once_with(["/path/to/sub.srt"])
235
236 def test_no_results_notifies_and_does_not_play(self):
237 module = _load_torrserver_utils()
238 params = {
239 "hash": "abc123",
240 "file_id": "1",
241 "path": "movie.mkv",
242 "meta": json.dumps({"title": "Test Movie", "ids": {"imdb_id": "tt123"}}),
243 }
244
245 with patch.object(module, "SubtitleManager") as MockManager, patch.object(
246 module, "get_torrserver_api"
247 ), patch.object(module.xbmcgui, "ListItem") as MockListItem, patch.object(
248 module.xbmc, "Player"
249 ) as MockPlayer, patch.object(module, "notification") as mock_notification:
250 mock_manager = MagicMock()
251 mock_manager.fetch_subtitles.return_value = None
252 mock_manager.last_fetch_status = "not_found"
253 MockManager.return_value = mock_manager
254
255 module.download_and_play_subtitles(params)
256
257 MockListItem.assert_not_called()
258 MockPlayer.assert_not_called()
259 mock_notification.assert_called_once_with(module.translation(90252))
260
261 def test_does_not_run_deepl_translation_twice_after_manager_fetch(self):
262 module = _load_torrserver_utils()
263 params = {
264 "hash": "abc123",
265 "file_id": "1",
266 "path": "movie.mkv",
267 "meta": json.dumps({"title": "Test Movie", "ids": {"imdb_id": "tt123"}}),
268 }
269
270 with patch.object(module, "SubtitleManager") as MockManager, patch.object(
271 module, "DeepLTranslator"
272 ) as MockTranslator, patch.object(module, "get_setting", return_value=True), patch.object(
273 module.xbmcgui.Dialog, "yesno", return_value=True
274 ) as yesno, patch.object(module.xbmcgui, "ListItem") as MockListItem, patch.object(
275 module.xbmc, "Player"
276 ) as MockPlayer, patch.object(module, "notification") as mock_notification, patch.object(
277 module, "kodilog"
278 ):
279 mock_manager = MagicMock()
280 mock_manager.fetch_subtitles.return_value = ["/path/to/sub.srt"]
281 MockManager.return_value = mock_manager
282
283 mock_player = MagicMock()
284 MockPlayer.return_value = mock_player
285 mock_listitem = MagicMock()
286 MockListItem.return_value = mock_listitem
287
288 module.download_and_play_subtitles(params)
289
290 mock_listitem.setSubtitles.assert_called_once_with(["/path/to/sub.srt"])
291 MockTranslator.assert_not_called()
292 yesno.assert_not_called()
293 mock_notification.assert_not_called()
294
295
296class TestTorrentMetadataCache:
297 def test_save_and_get_torrent_meta_normalizes_hash_case(self):
298 module = _load_torrserver_utils()
299 meta = {"title": "Test Movie", "ids": {"imdb_id": "tt123"}}
300
301 stored = {}
302
303 def fake_set(key, value, expires=None):
304 stored[key] = value
305
306 def fake_get(key):
307 return stored.get(key)
308
309 with patch.object(module.cache, "set", side_effect=fake_set), patch.object(
310 module.cache, "get", side_effect=fake_get
311 ), patch.object(module, "kodilog"):
312 module.save_torrent_meta("ABC123", meta)
313 result = module.get_torrent_meta("abc123")
314
315 assert result == meta
316 assert stored == {"torrent_meta:abc123": meta}
317
318 def test_get_torrent_meta_strips_hash_whitespace(self):
319 module = _load_torrserver_utils()
320 meta = {"title": "Test Movie", "ids": {"imdb_id": "tt123"}}
321
322 with patch.object(module.cache, "get", return_value=meta) as mock_get, patch.object(
323 module, "kodilog"
324 ):
325 result = module.get_torrent_meta(" ABC123 ")
326
327 assert result == meta
328 mock_get.assert_called_once_with("torrent_meta:abc123")
plugin.video.jacktook/tests/unit/test_trailer_context_menu.py +24 -20
……@@ -1,13 +1,12 @@
1from urllib.parse import parse_qs, urlparse
21from unittest.mock import MagicMock, patch
2from urllib.parse import parse_qs, urlparse
33
4from lib.clients.trakt.trakt import BaseTraktClient
54from lib.clients.tmdb.base import BaseTmdbClient
65from lib.clients.tmdb.utils.utils import (
76 add_tmdb_movie_context_menu,
87 add_tmdb_show_context_menu,
98)
10
9from lib.clients.trakt.trakt import BaseTraktClient
1110
1211TRAILER_LABEL = "Play Trailer"
1312
……@@ -46,7 +45,10 @@ def _parse_runplugin_command(command):
4645
4746
4847def test_movie_context_menu_contains_play_trailer_when_tmdb_id_exists():
49 with patch("lib.clients.tmdb.utils.utils.translation", side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}"):
48 with patch(
49 "lib.clients.tmdb.utils.utils.translation",
50 side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}",
51 ):
5052 menu = add_tmdb_movie_context_menu(
5153 mode="movies",
5254 media_type="movie",
……@@ -68,7 +70,10 @@ def test_movie_context_menu_contains_play_trailer_when_tmdb_id_exists():
6870
6971
7072def test_movie_context_menu_hides_play_trailer_without_tmdb_or_youtube_metadata():
71 with patch("lib.clients.tmdb.utils.utils.translation", side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}"):
73 with patch(
74 "lib.clients.tmdb.utils.utils.translation",
75 side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}",
76 ):
7277 menu = add_tmdb_movie_context_menu(
7378 mode="movies",
7479 media_type="movie",
……@@ -106,7 +111,10 @@ def test_movie_context_menu_contains_search_modes_entry():
106111
107112
108113def test_tv_context_menu_contains_play_trailer_when_tmdb_id_exists():
109 with patch("lib.clients.tmdb.utils.utils.translation", side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}"):
114 with patch(
115 "lib.clients.tmdb.utils.utils.translation",
116 side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}",
117 ):
110118 menu = add_tmdb_show_context_menu(
111119 mode="tv",
112120 title="Demo Show",
……@@ -132,9 +140,9 @@ def test_base_tmdb_client_passes_show_title_to_context_menu_builder():
132140 with patch(
133141 "lib.clients.tmdb.base.add_tmdb_show_context_menu",
134142 return_value=[("tmdb-tv", "cmd")],
135 ) as add_context_menu, patch(
136 "lib.clients.tmdb.base.is_trakt_auth", return_value=False
137 ), patch("lib.clients.tmdb.base.add_kodi_dir_item"):
143 ) as add_context_menu, patch("lib.clients.tmdb.base.is_trakt_auth", return_value=False), patch(
144 "lib.clients.tmdb.base.add_kodi_dir_item"
145 ):
138146 BaseTmdbClient.add_media_directory_item(
139147 list_item=list_item,
140148 mode="tv",
……@@ -142,17 +150,15 @@ def test_base_tmdb_client_passes_show_title_to_context_menu_builder():
142150 ids={"tmdb_id": 1399},
143151 )
144152
145 add_context_menu.assert_called_once_with(
146 "tv", ids={"tmdb_id": 1399}, title="Demo Show"
147 )
153 add_context_menu.assert_called_once_with("tv", ids={"tmdb_id": 1399}, title="Demo Show")
148154
149155
150156def test_trakt_movie_context_menu_contains_play_trailer_when_tmdb_id_exists():
151157 list_item = MagicMock()
152158
153 with patch(
154 "lib.clients.trakt.trakt.is_trakt_auth", return_value=False
155 ), patch("lib.clients.trakt.trakt.add_kodi_dir_item"), patch(
159 with patch("lib.clients.trakt.trakt.is_trakt_auth", return_value=False), patch(
160 "lib.clients.trakt.trakt.add_kodi_dir_item"
161 ), patch(
156162 "lib.clients.tmdb.utils.utils.translation",
157163 side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}",
158164 ):
……@@ -181,11 +187,9 @@ def test_trakt_movie_context_menu_contains_play_trailer_when_tmdb_id_exists():
181187def test_trakt_show_context_menu_contains_play_trailer_when_tmdb_id_exists():
182188 list_item = MagicMock()
183189
184 with patch(
185 "lib.clients.trakt.trakt.is_trakt_auth", return_value=True
186 ), patch(
187 "lib.clients.trakt.trakt.BaseTraktClient._trakt_context_menu",
188 return_value=[("watched", "cmd")],
190 with patch("lib.clients.trakt.trakt.is_trakt_auth", return_value=True), patch(
191 "lib.clients.trakt.trakt.BaseTraktClient._trakt_context_menu",
192 return_value=[("watched", "cmd")],
189193 ), patch("lib.clients.trakt.trakt.add_kodi_dir_item"), patch(
190194 "lib.clients.tmdb.utils.utils.translation",
191195 side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}",
plugin.video.jacktook/tests/unit/test_trailer_navigation.py +43 -31
……@@ -10,16 +10,25 @@ def test_play_trailer_resolves_and_hands_off_to_kodi():
1010 "title": "Demo Movie",
1111 }
1212 list_item = MagicMock()
13 player = MagicMock()
1314
1415 with patch(
1516 "lib.navigation.resolve_item_trailer",
1617 return_value={
17 "trailer": {"yt_id": "dQw4w9WgXcQ", "youtube_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
18 "playback": {"video_url": "https://video.example/trailer.m3u8", "source_type": "hls"},
18 "trailer": {
19 "yt_id": "dQw4w9WgXcQ",
20 "youtube_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
21 },
22 "playback": {
23 "video_url": "https://video.example/trailer.m3u8",
24 "source_type": "hls",
25 },
1926 },
20 ) as resolve_item_trailer, patch("lib.navigation.ListItem", return_value=list_item) as list_item_cls, patch(
21 "lib.navigation.setResolvedUrl"
22 ) as set_resolved_url, patch("lib.navigation.notification") as notification:
27 ) as resolve_item_trailer, patch(
28 "lib.navigation.ListItem", return_value=list_item
29 ) as list_item_cls, patch(
30 "lib.navigation.notification"
31 ) as notification, patch("xbmc.Player", return_value=player) as player_cls:
2332 from lib.navigation import play_trailer
2433
2534 play_trailer(params)
……@@ -36,12 +45,13 @@ def test_play_trailer_resolves_and_hands_off_to_kodi():
3645 )
3746 list_item.setPath.assert_called_once_with("https://video.example/trailer.m3u8")
3847 list_item.setProperty.assert_called_once_with("IsPlayable", "true")
39 set_resolved_url.assert_called_once_with(1, True, list_item)
48 player.play.assert_called_once_with("https://video.example/trailer.m3u8", list_item)
4049 notification.assert_not_called()
4150
4251
4352def test_play_trailer_handles_adaptive_source_without_falling_back_to_youtube_addon():
4453 list_item = MagicMock()
54 player = MagicMock()
4555
4656 with patch(
4757 "lib.navigation.resolve_item_trailer",
……@@ -54,8 +64,8 @@ def test_play_trailer_handles_adaptive_source_without_falling_back_to_youtube_ad
5464 },
5565 },
5666 ), patch("lib.navigation.ListItem", return_value=list_item) as list_item_cls, patch(
57 "lib.navigation.setResolvedUrl"
58 ) as set_resolved_url, patch("lib.navigation.execute_builtin") as execute_builtin:
67 "lib.navigation.execute_builtin"
68 ) as execute_builtin, patch("xbmc.Player", return_value=player) as player_cls:
5969 from lib.navigation import play_trailer
6070
6171 play_trailer({"tmdb_id": "550", "media_type": "movie", "title": "Demo Movie"})
……@@ -69,7 +79,7 @@ def test_play_trailer_handles_adaptive_source_without_falling_back_to_youtube_ad
6979 (("IsPlayable", "true"),),
7080 (("inputstream.adaptive.manifest_type", "mpd"),),
7181 ]
72 set_resolved_url.assert_called_once_with(1, True, list_item)
82 player.play.assert_called_once_with("https://video.example/video-1080.mp4", list_item)
7383 execute_builtin.assert_not_called()
7484
7585
……@@ -80,9 +90,7 @@ def test_play_trailer_notifies_when_no_resolved_video_url():
8090 ) as resolve_item_trailer, patch(
8191 "lib.navigation.translation",
8292 side_effect=lambda value: "Trailer unavailable" if value == 90673 else f"t-{value}",
83 ), patch("lib.navigation.notification") as notification, patch(
84 "lib.navigation.setResolvedUrl"
85 ) as set_resolved_url:
93 ), patch("lib.navigation.notification") as notification:
8694 from lib.navigation import play_trailer
8795
8896 play_trailer({"tmdb_id": "1399", "media_type": "tv", "title": "Demo Show"})
……@@ -94,14 +102,19 @@ def test_play_trailer_notifies_when_no_resolved_video_url():
94102 media_type="tv",
95103 )
96104 notification.assert_called_once_with("Trailer unavailable")
97 set_resolved_url.assert_not_called()
98105
99106
100107def test_play_trailer_uses_mode_when_media_type_missing():
101108 with patch(
102109 "lib.navigation.resolve_item_trailer",
103 return_value={"trailer": {"yt_id": None}, "playback": {"audio_url": "https://video.example/audio.m4a"}},
104 ) as resolve_item_trailer, patch("lib.navigation.translation", side_effect=lambda value: "Trailer unavailable" if value == 90673 else f"t-{value}"), patch("lib.navigation.notification") as notification:
110 return_value={
111 "trailer": {"yt_id": None},
112 "playback": {"audio_url": "https://video.example/audio.m4a"},
113 },
114 ) as resolve_item_trailer, patch(
115 "lib.navigation.translation",
116 side_effect=lambda value: "Trailer unavailable" if value == 90673 else f"t-{value}",
117 ), patch("lib.navigation.notification") as notification:
105118 from lib.navigation import play_trailer
106119
107120 play_trailer({"tmdb_id": "1399", "mode": "tv", "title": "Demo Show"})
……@@ -122,9 +135,7 @@ def test_play_trailer_logs_when_resolution_fails():
122135 ), patch("lib.navigation.kodilog") as log_mock, patch(
123136 "lib.navigation.translation",
124137 side_effect=lambda value: "Trailer unavailable" if value == 90673 else f"t-{value}",
125 ), patch(
126 "lib.navigation.notification"
127 ):
138 ), patch("lib.navigation.notification"):
128139 from lib.navigation import play_trailer
129140
130141 play_trailer({"tmdb_id": "1399", "media_type": "tv", "title": "Demo Show"})
……@@ -141,9 +152,7 @@ def test_play_trailer_falls_back_to_youtube_addon_when_direct_playback_unavailab
141152 return_value=True,
142153 ) as is_youtube_addon_enabled, patch(
143154 "lib.navigation.execute_builtin"
144 ) as execute_builtin, patch("lib.navigation.notification") as notification, patch(
145 "lib.navigation.setResolvedUrl"
146 ) as set_resolved_url:
155 ) as execute_builtin, patch("lib.navigation.notification") as notification:
147156 from lib.navigation import play_trailer
148157
149158 play_trailer(
……@@ -166,7 +175,6 @@ def test_play_trailer_falls_back_to_youtube_addon_when_direct_playback_unavailab
166175 "PlayMedia(plugin://plugin.video.youtube/play/?video_id=dQw4w9WgXcQ)"
167176 )
168177 notification.assert_not_called()
169 set_resolved_url.assert_not_called()
170178
171179
172180def test_play_trailer_notifies_when_direct_playback_unavailable_and_no_youtube_id():
……@@ -176,9 +184,12 @@ def test_play_trailer_notifies_when_direct_playback_unavailable_and_no_youtube_i
176184 ) as resolve_item_trailer, patch(
177185 "lib.navigation.is_youtube_addon_enabled",
178186 return_value=True,
179 ) as is_youtube_addon_enabled, patch("lib.navigation.translation", side_effect=lambda value: "Trailer unavailable" if value == 90673 else f"t-{value}"), patch(
180 "lib.navigation.execute_builtin"
181 ) as execute_builtin, patch("lib.navigation.notification") as notification:
187 ) as is_youtube_addon_enabled, patch(
188 "lib.navigation.translation",
189 side_effect=lambda value: "Trailer unavailable" if value == 90673 else f"t-{value}",
190 ), patch("lib.navigation.execute_builtin") as execute_builtin, patch(
191 "lib.navigation.notification"
192 ) as notification:
182193 from lib.navigation import play_trailer
183194
184195 play_trailer(
……@@ -249,9 +260,12 @@ def test_play_trailer_notifies_when_youtube_addon_is_missing_for_fallback():
249260 ) as resolve_item_trailer, patch(
250261 "lib.navigation.is_youtube_addon_enabled",
251262 return_value=False,
252 ) as is_youtube_addon_enabled, patch("lib.navigation.translation", side_effect=lambda value: "Trailer unavailable" if value == 90673 else f"t-{value}"), patch(
253 "lib.navigation.execute_builtin"
254 ) as execute_builtin, patch("lib.navigation.notification") as notification:
263 ) as is_youtube_addon_enabled, patch(
264 "lib.navigation.translation",
265 side_effect=lambda value: "Trailer unavailable" if value == 90673 else f"t-{value}",
266 ), patch("lib.navigation.execute_builtin") as execute_builtin, patch(
267 "lib.navigation.notification"
268 ) as notification:
255269 from lib.navigation import play_trailer
256270
257271 play_trailer(
……@@ -290,9 +304,7 @@ def test_play_trailer_logs_unavailable_youtube_addon_fallback_details():
290304 ), patch(
291305 "lib.navigation.translation",
292306 side_effect=lambda value: "Trailer unavailable" if value == 90673 else f"t-{value}",
293 ), patch(
294 "lib.navigation.notification"
295 ), patch("lib.navigation.kodilog") as log_mock:
307 ), patch("lib.navigation.notification"), patch("lib.navigation.kodilog") as log_mock:
296308 from lib.navigation import play_trailer
297309
298310 play_trailer(
plugin.video.jacktook/tests/unit/test_trakt_phase4.py +1 -3
……@@ -103,9 +103,7 @@ def test_build_up_next_entries_finds_next_aired_episode():
103103 "seasons": [
104104 {
105105 "number": 1,
106 "episodes": [
107 {"number": 1, "last_watched_at": "2026-03-10T18:00:00.000Z"}
108 ],
106 "episodes": [{"number": 1, "last_watched_at": "2026-03-10T18:00:00.000Z"}],
109107 }
110108 ],
111109 }
plugin.video.jacktook/tests/unit/test_trakt_phase7.py +3 -3
……@@ -7,9 +7,9 @@ from lib.clients.trakt.trakt import TraktPresentation
77def test_create_list_calls_trakt_and_clears_my_lists_cache():
88 trakt_lists = TraktLists()
99
10 with patch.object(trakt_lists, "call_trakt", return_value={"ids": {"trakt": 1}}) as call_trakt, patch(
11 "lib.api.trakt.trakt.clear_trakt_list_data"
12 ) as clear_list_data, patch(
10 with patch.object(
11 trakt_lists, "call_trakt", return_value={"ids": {"trakt": 1}}
12 ) as call_trakt, patch("lib.api.trakt.trakt.clear_trakt_list_data") as clear_list_data, patch(
1313 "lib.api.trakt.trakt.clear_trakt_list_contents_data"
1414 ) as clear_list_contents:
1515 trakt_lists.create_list("My List", "Description")
plugin.video.jacktook/tests/unit/test_trakt_phase8.py +3 -9
……@@ -112,9 +112,7 @@ def test_remove_from_collection_uses_all_known_ids_for_shows():
112112 with patch.object(trakt_lists, "call_trakt", return_value={}) as call_trakt, patch(
113113 "lib.api.trakt.trakt.lists_cache.delete_prefix"
114114 ), patch("lib.api.trakt.trakt.clear_trakt_collection_watchlist_data"):
115 trakt_lists.remove_from_collection(
116 "shows", {"tmdb": "44", "tvdb": "55", "imdb": "tt66"}
117 )
115 trakt_lists.remove_from_collection("shows", {"tmdb": "44", "tvdb": "55", "imdb": "tt66"})
118116
119117 call_trakt.assert_called_once_with(
120118 "sync/collection/remove",
……@@ -236,9 +234,7 @@ def test_build_show_collection_season_payload_uses_season_numbers_only():
236234def test_normalize_trakt_media_type_prefers_show_when_tvdb_present():
237235 from lib.clients.trakt.trakt import TraktClient
238236
239 result = TraktClient._normalize_trakt_media_type(
240 "movies", {"tmdb": 44, "tvdb": 55}
241 )
237 result = TraktClient._normalize_trakt_media_type("movies", {"tmdb": 44, "tvdb": 55})
242238
243239 assert result == "shows"
244240
……@@ -269,9 +265,7 @@ def test_build_collection_remove_payload_uses_existing_collection_entry():
269265 }
270266 ],
271267 ):
272 payload = trakt_lists.build_collection_remove_payload(
273 "shows", {"tmdb": 99, "tvdb": 77}
274 )
268 payload = trakt_lists.build_collection_remove_payload("shows", {"tmdb": 99, "tvdb": 77})
275269
276270 assert payload == {
277271 "shows": [
plugin.video.jacktook/tests/unit/test_trakt_sync_service.py +8 -2
……@@ -25,7 +25,10 @@ def test_sync_activities_applies_changed_buckets():
2525 api.sync.get_last_activities.return_value = {"movies": {"watched_at": "new"}}
2626 service = TraktSyncService(api=api, monitor=MagicMock())
2727
28 with patch("lib.services.trakt_sync.reset_activity", return_value={"movies": {"watched_at": "old"}}), patch.object(
28 with patch(
29 "lib.services.trakt_sync.reset_activity",
30 return_value={"movies": {"watched_at": "old"}},
31 ), patch.object(
2932 service, "_get_changed_buckets", return_value={"watched_movies", "lists"}
3033 ) as changed_buckets, patch.object(service, "_apply_activity_changes") as apply_changes:
3134 result = service.sync_activities()
……@@ -40,7 +43,10 @@ def test_sync_activities_logs_changed_buckets():
4043 api.sync.get_last_activities.return_value = {"movies": {"watched_at": "new"}}
4144 service = TraktSyncService(api=api, monitor=MagicMock())
4245
43 with patch("lib.services.trakt_sync.reset_activity", return_value={"movies": {"watched_at": "old"}}), patch.object(
46 with patch(
47 "lib.services.trakt_sync.reset_activity",
48 return_value={"movies": {"watched_at": "old"}},
49 ), patch.object(
4450 service, "_get_changed_buckets", return_value={"watched_movies", "lists"}
4551 ), patch.object(service, "_apply_activity_changes"), patch(
4652 "lib.services.trakt_sync.kodilog"
plugin.video.jacktook/tests/unit/test_transfer_actions.py +33 -13
……@@ -2,8 +2,8 @@ from unittest.mock import MagicMock, patch
22
33from lib.clients.debrid.torbox import TorboxHelper
44from lib.nav import debrid as debrid_navigation
5from lib.utils.general.utils import DebridType
65from lib.utils.debrid import debrid_utils
6from lib.utils.general.utils import DebridType
77from lib.utils.torrent import torrserver_utils
88
99
……@@ -19,12 +19,33 @@ def test_add_source_to_torrserver_uses_magnet_when_available():
1919 )
2020
2121 assert result == "abc123"
22 api.add_magnet.assert_called_once_with(
23 "magnet:?xt=urn:btih:abc123", title="Test", poster=""
24 )
22 api.add_magnet.assert_called_once_with("magnet:?xt=urn:btih:abc123", title="Test", poster="")
2523 notification.assert_called_once()
2624
2725
26def test_add_source_to_torrserver_saves_metadata_under_returned_and_source_hashes():
27 api = MagicMock()
28 api.add_magnet.return_value = "RETURNEDHASH"
29 data = '{"title": "Test Movie", "ids": {"imdb_id": "tt123"}}'
30
31 with patch.object(torrserver_utils, "JACKTORR_ADDON", True), patch.object(
32 torrserver_utils, "get_torrserver_api", return_value=api
33 ), patch.object(torrserver_utils, "notification"), patch.object(
34 torrserver_utils, "get_info_hash_from_magnet", return_value="MAGNETHASH"
35 ), patch.object(torrserver_utils, "save_torrent_meta") as mock_save:
36 result = torrserver_utils.add_source_to_torrserver(
37 magnet="magnet:?xt=urn:btih:MAGNETHASH",
38 info_hash="SOURCEHASH",
39 title="Test",
40 data=data,
41 )
42
43 assert result == "RETURNEDHASH"
44 saved_hashes = [call.args[0] for call in mock_save.call_args_list]
45 assert saved_hashes == ["returnedhash", "sourcehash", "magnethash"]
46 assert all(call.args[1]["ids"]["imdb_id"] == "tt123" for call in mock_save.call_args_list)
47
48
2849def test_add_source_to_torrserver_uploads_torrent_file_from_url():
2950 api = MagicMock()
3051 api.add_torrent_obj.return_value = "def456"
……@@ -68,18 +89,14 @@ def test_add_source_to_torrserver_falls_back_to_magnet_when_url_fails():
6889
6990 with patch.object(torrserver_utils, "JACKTORR_ADDON", True), patch.object(
7091 torrserver_utils, "get_torrserver_api", return_value=api
71 ), patch.object(
72 torrserver_utils.requests, "get", side_effect=Exception("network")
73 ):
92 ), patch.object(torrserver_utils.requests, "get", side_effect=Exception("network")):
7493 result = torrserver_utils.add_source_to_torrserver(
7594 url="https://example.com/details/1",
7695 magnet="magnet:?xt=urn:btih:abc123",
7796 )
7897
7998 assert result == "abc123"
80 api.add_magnet.assert_called_once_with(
81 "magnet:?xt=urn:btih:abc123", title="", poster=""
82 )
99 api.add_magnet.assert_called_once_with("magnet:?xt=urn:btih:abc123", title="", poster="")
83100
84101
85102def test_add_source_to_debrid_uses_preferred_enabled_service():
……@@ -112,6 +129,11 @@ def test_choose_debrid_for_transfer_prompts_when_multiple_supported_services_ena
112129 dialog.select.assert_called_once()
113130
114131
132def test_offcloud_is_supported_for_pack_direct_and_cloud_transfer():
133 assert DebridType.OC in debrid_utils.PACK_DIRECT_DEBRID_TYPES
134 assert DebridType.OC in debrid_utils.SUPPORTED_CLOUD_TRANSFER_DEBRIDS
135
136
115137def test_torbox_helper_get_cloud_downloads_returns_playable_items():
116138 helper = TorboxHelper()
117139 helper.client = MagicMock()
……@@ -155,9 +177,7 @@ def test_torbox_helper_get_link_uses_cloud_ids_when_present():
155177
156178 assert data is not None
157179 assert data["url"] == "https://download"
158 helper.client.create_download_link.assert_called_once_with(
159 "torrent-1", "file-2", "127.0.0.1"
160 )
180 helper.client.create_download_link.assert_called_once_with("torrent-1", "file-2", "127.0.0.1")
161181
162182
163183def test_get_tb_downloads_builds_playable_cloud_entries():
plugin.video.jacktook/tests/unit/test_updater.py +9 -3
……@@ -101,7 +101,9 @@ def test_update_addon_keeps_current_install_when_zip_is_invalid(monkeypatch, tmp
101101
102102 updater.update_addon("1.7.2")
103103
104 assert updater._read_addon_version_from_xml(os.path.join(destination_dir, "addon.xml")) == "1.8.0"
104 assert (
105 updater._read_addon_version_from_xml(os.path.join(destination_dir, "addon.xml")) == "1.8.0"
106 )
105107 assert dialog_calls
106108
107109
……@@ -130,7 +132,9 @@ def test_update_addon_restores_backup_when_install_move_fails(monkeypatch, tmp_p
130132
131133 updater.update_addon("1.7.2")
132134
133 assert updater._read_addon_version_from_xml(os.path.join(destination_dir, "addon.xml")) == "1.8.0"
135 assert (
136 updater._read_addon_version_from_xml(os.path.join(destination_dir, "addon.xml")) == "1.8.0"
137 )
134138 assert not os.path.exists(os.path.join(str(packages_dir), f"{updater.ADDON_ID}-backup"))
135139
136140
……@@ -147,6 +151,8 @@ def test_update_addon_installs_valid_downgrade(monkeypatch, tmp_path):
147151
148152 updater.update_addon("1.7.2")
149153
150 assert updater._read_addon_version_from_xml(os.path.join(destination_dir, "addon.xml")) == "1.7.2"
154 assert (
155 updater._read_addon_version_from_xml(os.path.join(destination_dir, "addon.xml")) == "1.7.2"
156 )
151157 assert not os.path.exists(os.path.join(str(packages_dir), f"{updater.ADDON_ID}-backup"))
152158 assert not os.path.exists(os.path.join(str(packages_dir), f"{updater.ADDON_ID}-staging"))
plugin.video.jacktook/tests/unit/test_webserver.py +0 -3
……@@ -1,6 +1,3 @@
1from unittest.mock import MagicMock
2
3
41from lib.services.webserver import _addon_capabilities
52
63
plugin.video.jacktook/tests/unit/test_youtube_resolver.py +17 -17
……@@ -1,6 +1,6 @@
1import pytest
21from unittest.mock import MagicMock, patch
32
3import pytest
44
55from lib.clients.youtube_resolver import (
66 extract_video_id,
……@@ -11,7 +11,6 @@ from lib.clients.youtube_resolver import (
1111 select_best_stream,
1212)
1313
14
1514WATCH_HTML = """
1615<html>
1716 <script>
……@@ -452,10 +451,11 @@ def test_resolve_trailer_playback_tries_android_vr_android_then_ios_clients():
452451 resolved = resolve_trailer_playback("dQw4w9WgXcQ", session=session)
453452
454453 assert resolved["video_url"] == "https://video.example/final.m3u8"
455 assert [
456 call[1]["json"]["context"]["client"]["clientName"]
457 for call in session.post_calls
458 ] == ["ANDROID_VR", "ANDROID", "IOS"]
454 assert [call[1]["json"]["context"]["client"]["clientName"] for call in session.post_calls] == [
455 "ANDROID_VR",
456 "ANDROID",
457 "IOS",
458 ]
459459 assert [call[1]["headers"]["User-Agent"] for call in session.post_calls] == [
460460 (
461461 "com.google.android.apps.youtube.vr.oculus/1.56.21 "
……@@ -469,9 +469,11 @@ def test_resolve_trailer_playback_tries_android_vr_android_then_ios_clients():
469469 "3",
470470 "5",
471471 ]
472 assert [
473 call[1]["headers"]["X-YouTube-Client-Version"] for call in session.post_calls
474 ] == ["1.56.21", "20.10.35", "20.10.1"]
472 assert [call[1]["headers"]["X-YouTube-Client-Version"] for call in session.post_calls] == [
473 "1.56.21",
474 "20.10.35",
475 "20.10.1",
476 ]
475477 assert [call[1]["headers"]["X-Goog-Visitor-Id"] for call in session.post_calls] == [
476478 "visitor-token",
477479 "visitor-token",
……@@ -508,17 +510,14 @@ def test_call_player_api_sends_origin_and_html5_preference():
508510 _, post_kwargs = session.post_calls[0]
509511 assert post_kwargs["headers"]["Origin"] == "https://www.youtube.com"
510512 assert (
511 post_kwargs["headers"]["User-Agent"]
512 == "com.google.android.apps.youtube.vr.oculus/1.56.21 "
513 post_kwargs["headers"]["User-Agent"] == "com.google.android.apps.youtube.vr.oculus/1.56.21 "
513514 "(Linux; U; Android 12; en_US; Quest 3; Build/SQ3A.220605.009.A1) gzip"
514515 )
515516 assert post_kwargs["headers"]["X-YouTube-Client-Name"] == "28"
516517 assert post_kwargs["headers"]["X-YouTube-Client-Version"] == "1.56.21"
517518 assert post_kwargs["headers"]["X-Goog-Visitor-Id"] == "visitor-token"
518519 assert (
519 post_kwargs["json"]["playbackContext"]["contentPlaybackContext"][
520 "html5Preference"
521 ]
520 post_kwargs["json"]["playbackContext"]["contentPlaybackContext"]["html5Preference"]
522521 == "HTML5_PREF_WANTS"
523522 )
524523
……@@ -551,8 +550,7 @@ def test_resolve_trailer_playback_logs_player_api_error_details():
551550 assert resolve_trailer_playback("dQw4w9WgXcQ", session=session) is None
552551
553552 assert any(
554 "bad client" in str(call) and "preview=" in str(call)
555 for call in log_mock.call_args_list
553 "bad client" in str(call) and "preview=" in str(call) for call in log_mock.call_args_list
556554 )
557555
558556
……@@ -648,7 +646,9 @@ def test_resolve_item_trailer_playback_falls_back_to_tmdb_trailer(monkeypatch):
648646 ]
649647
650648
651def test_resolve_item_trailer_playback_returns_none_when_no_direct_or_tmdb_trailer(monkeypatch):
649def test_resolve_item_trailer_playback_returns_none_when_no_direct_or_tmdb_trailer(
650 monkeypatch,
651):
652652 monkeypatch.setattr(
653653 "lib.clients.youtube_resolver.resolve_tmdb_trailer",
654654 lambda tmdb_id, media_type: None,
plugin.video.jacktook/tests/verify_debrid.py +5 -5
……@@ -1,5 +1,5 @@
1import sys
21import os
2import sys
33from unittest.mock import MagicMock
44
55if "pytest" in sys.modules:
……@@ -58,11 +58,11 @@ sys.modules["lib.api.tvdbapi.tvdbapi"] = MagicMock()
5858sys.modules["lib.api.tvdbapi"] = MagicMock()
5959
6060# 4. Now import the clients
61from lib.api.debrid.realdebrid import RealDebrid
6261from lib.api.debrid.alldebrid import AllDebrid
63from lib.api.debrid.torbox import Torbox
64from lib.api.debrid.premiumize import Premiumize
6562from lib.api.debrid.debrider import Debrider
63from lib.api.debrid.premiumize import Premiumize
64from lib.api.debrid.realdebrid import RealDebrid
65from lib.api.debrid.torbox import Torbox
6666
6767# === CONFIGURATION: ADD YOUR TOKENS HERE ===
6868TOKENS = {
……@@ -93,7 +93,7 @@ def test_service(name, client_class, token):
9393 f"FAILED: {name} returned None. Check if token is valid or if account has premium."
9494 )
9595 except Exception as e:
96 print(f"ERROR testing {name}: {str(e)}")
96 print(f"ERROR testing {name}: {e!s}")
9797
9898
9999if __name__ == "__main__":