| … | … | @@ -111,23 +111,46 @@ Notes: |
| 111 | 111 | |
| 112 | 112 | ## 7) Lint / Static Checks |
| 113 | 113 | |
| 114 | No dedicated linter config (`pyproject.toml`, `setup.cfg`, `tox.ini`, `pytest.ini`) is present. | |
| 114 | Configuration lives in `pyproject.toml` under `[tool.ruff]` and `[tool.mypy]`. | |
| 115 | 115 | |
| 116 | Recommended lightweight checks: | |
| 116 | ### Ruff — linter + formatter | |
| 117 | ||
| 118 | Ruff 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 | ||
| 131 | Notable rule groups enabled: `E`, `W`, `F`, `I`, `N`, `UP`, `B`, `SIM`, `D` (Google convention, relaxed), `YTT`, `RUF`. | |
| 132 | ||
| 133 | ### mypy — static type checker | |
| 117 | 134 | |
| 118 | 135 | ```bash |
| 119 | python3 -m py_compile jacktook.py service.py | |
| 120 | python3 scripts/check_py37_compat.py . | |
| 136 | # Type-check all project source (excludes tests, scripts, docs, lib/vendor) | |
| 137 | .venv/bin/mypy . | |
| 121 | 138 | ``` |
| 122 | 139 | |
| 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 | ``` | |
| 124 | 149 | |
| 125 | 150 | ## 8) Known Test Environment Caveats |
| 126 | 151 | |
| 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. | |
| 131 | 154 | |
| 132 | 155 | ## 9) Code Style Guidelines |
| 133 | 156 | |
| … | … | @@ -193,3 +216,13 @@ Follow existing codebase conventions first; avoid style-only churn. |
| 193 | 216 | - When changing routing/action names, update all call sites and query param builders. |
| 194 | 217 | - Run relevant tests first (single-test command), then broader suite when feasible. |
| 195 | 218 | - 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. |
| … | … | @@ -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 | ||
| 8 | Spanish: | |
| 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 | ||
| 19 | Spanish: | |
| 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 | ||
| 30 | Spanish: | |
| 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 | ||
| 46 | Spanish: | |
| 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 | ||
| 55 | Note: | |
| 56 | If you enjoy this addon, please consider supporting the project with a donation. Your contributions help keep improving and maintaining it. Thank you! | |
| 57 | ||
| 58 | Si 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 | ||
| 60 | Dacă îț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 | ||
| 64 | Se 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 | ||
| 76 | Spanish: | |
| 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 | ||
| 100 | Spanish: | |
| 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 | ||
| 1 | 113 | ## 1.10.0 |
| 2 | 114 | - Search: Add Search Title Language mode with localized first, English first, and English only ordering for Easynews/Jackett/Prowlarr context searches. |
| 3 | 115 | - Search: Improve English-first fallback behavior to prioritize TMDB original titles before localized titles when English translations are missing. |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | <div align="center"> |
| 2 | 2 | |
| 3 | # 🚀 Jacktook | |
| 3 | # Jacktook | |
| 4 | 4 | |
| 5 | 5 | *An advanced meta-scraper and streaming Kodi addon for enthusiasts.* |
| 6 | 6 | |
| … | … | @@ -14,19 +14,20 @@ |
| 14 | 14 | |
| 15 | 15 | </div> |
| 16 | 16 | |
| 17 | ## 🌟 Overview | |
| 17 | ## Overview | |
| 18 | 18 | |
| 19 | 19 | **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. |
| 20 | 20 | |
| 21 | 21 | --- |
| 22 | 22 | |
| 23 | ## ✨ Features | |
| 23 | ## Features | |
| 24 | 24 | |
| 25 | 25 | ### 🔍 Search & Scraping |
| 26 | 26 | - **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. | |
| 28 | 28 | - **Torrent Scraping**: Integration with Jackett and Prowlarr for torrent sources. |
| 29 | 29 | - **Usenet Scraping**: EasyNews integration for Usenet sources. |
| 30 | - **External Scrapers**: Support for external scrapers like Magneto and CocoScrapers. | |
| 30 | 31 | - **Telegram Scraping**: Jackgram integration for streaming from Telegram channels. |
| 31 | 32 | - **Rich Metadata**: Integration with TMDB, MDBList, Trakt, Fanart.tv, and Stremio catalogs. |
| 32 | 33 | - **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 |
| 67 | 68 | |
| 68 | 69 | ## ⚙️ Configuration & Usage |
| 69 | 70 | |
| 70 | ### 🚀 Stremio Integration | |
| 71 | ### Stremio Integration | |
| 71 | 72 | Jacktook can import configurations and Debrid services directly from your Stremio account. |
| 72 | 73 | - Enable Stremio in settings. |
| 73 | 74 | - 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 |
| 102 | 103 | |
| 103 | 104 | --- |
| 104 | 105 | |
| 105 | ## 📸 Screenshots | |
| 106 | ## Screenshots | |
| 106 | 107 | |
| 107 | 108 | | Home Screen | TV Shows | |
| 108 | 109 | | :---: | :---: | |
| … | … | @@ -112,7 +113,7 @@ If you are not using Debrid services, you can use these engines for P2P streamin |
| 112 | 113 | |
| 113 | 114 | --- |
| 114 | 115 | |
| 115 | ## ☕ Support | |
| 116 | ## Support | |
| 116 | 117 | |
| 117 | 118 | 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. |
| 118 | 119 | |
| … | … | @@ -120,7 +121,7 @@ If you enjoy using **Jacktook** and want to support its development, you can buy |
| 120 | 121 | |
| 121 | 122 | --- |
| 122 | 123 | |
| 123 | ## ⚖️ Disclaimer | |
| 124 | ## Disclaimer | |
| 124 | 125 | |
| 125 | 126 | This 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. |
| 126 | 127 |
| … | … | @@ -1,5 +1,5 @@ |
| 1 | 1 | <?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"> | |
| 3 | 3 | <requires> |
| 4 | 4 | <import addon="xbmc.python" version="3.00.0"/> |
| 5 | 5 | <import addon="script.module.requests" version="2.25.1"/> |
| … | … | @@ -1,7 +0,0 @@ |
| 1 | #!/usr/bin/env python3 | |
| 2 | ||
| 3 | from lib.router import addon_router | |
| 4 | from lib.utils.kodi.utils import load_saved_view_properties | |
| 5 | ||
| 6 | load_saved_view_properties() | |
| 7 | addon_router() |
| … | … | @@ -1,13 +1,14 @@ |
| 1 | 1 | import json |
| 2 | ||
| 2 | 3 | from lib.domain.torrent import TorrentStream |
| 3 | 4 | from lib.gui.resolver_window import ResolverWindow |
| 4 | 5 | from lib.utils.kodi.utils import ADDON_PATH |
| 5 | from lib.downloader import handle_download_file | |
| 6 | ||
| 6 | 7 | |
| 7 | 8 | def resolve_for_pack_selection(params): |
| 8 | 9 | data = json.loads(params["data"]) |
| 9 | 10 | info_hash = data.get("infoHash") |
| 10 | ||
| 11 | ||
| 11 | 12 | source = TorrentStream( |
| 12 | 13 | guid="", |
| 13 | 14 | url=data.get("url"), |
| … | … | @@ -21,6 +22,7 @@ def resolve_for_pack_selection(params): |
| 21 | 22 | subindexer=data.get("subindexer"), |
| 22 | 23 | addonKey=data.get("addonKey", ""), |
| 23 | 24 | addonName=data.get("addonName", ""), |
| 25 | addonSourceName=data.get("addonSourceName", ""), | |
| 24 | 26 | addonInstanceLabel=data.get("addonInstanceLabel", ""), |
| 25 | 27 | quality=data.get("quality"), |
| 26 | 28 | isPack=data.get("isPack", False), |
| … | … | @@ -64,6 +66,7 @@ def resolve_for_subtitles(params): |
| 64 | 66 | subindexer=data.get("subindexer"), |
| 65 | 67 | addonKey=data.get("addonKey", ""), |
| 66 | 68 | addonName=data.get("addonName", ""), |
| 69 | addonSourceName=data.get("addonSourceName", ""), | |
| 67 | 70 | addonInstanceLabel=data.get("addonInstanceLabel", ""), |
| 68 | 71 | quality=data.get("quality"), |
| 69 | 72 | isPack=data.get("isPack"), |
| … | … | @@ -89,5 +92,3 @@ def resolve_for_subtitles(params): |
| 89 | 92 | ) |
| 90 | 93 | resolver_window.doModal() |
| 91 | 94 | del resolver_window |
| 92 | ||
| 93 |
| … | … | @@ -1,8 +1,7 @@ |
| 1 | import requests | |
| 2 | 1 | from lib.api.debrid.base import DebridClient, ProviderException |
| 3 | 2 | from lib.jacktook.utils import kodilog |
| 4 | from lib.utils.kodi.utils import dialog_ok, set_setting, translation | |
| 5 | 3 | from lib.services.debrid.auth import run_alldebrid_auth |
| 4 | from lib.utils.kodi.utils import dialog_ok, set_setting, translation | |
| 6 | 5 | |
| 7 | 6 | |
| 8 | 7 | class AllDebrid(DebridClient): |
| … | … | @@ -70,9 +69,7 @@ class AllDebrid(DebridClient): |
| 70 | 69 | if error_code == "AUTH_BAD_APIKEY": |
| 71 | 70 | raise ProviderException("Invalid AllDebrid API key") |
| 72 | 71 | 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}") | |
| 76 | 73 | elif error_code == "AUTH_BLOCKED": |
| 77 | 74 | raise ProviderException("API got blocked on AllDebrid") |
| 78 | 75 | elif error_code == "MAGNET_MUST_BE_PREMIUM": |
| … | … | @@ -82,14 +79,10 @@ class AllDebrid(DebridClient): |
| 82 | 79 | elif error_code == "MAGNET_INVALID_ID": |
| 83 | 80 | raise ProviderException("Invalid magnet link for AllDebrid") |
| 84 | 81 | 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}") | |
| 88 | 83 | |
| 89 | 84 | 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}) | |
| 93 | 86 | self._validate_error_response(response) |
| 94 | 87 | return response |
| 95 | 88 | |
| … | … | @@ -129,9 +122,7 @@ class AllDebrid(DebridClient): |
| 129 | 122 | return response.get("data", {}).get("magnets", {}) |
| 130 | 123 | |
| 131 | 124 | 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}) | |
| 135 | 126 | return response.get("data", {}).get("magnets", []) |
| 136 | 127 | |
| 137 | 128 | def get_available_torrent(self, info_hash): |
| … | … | @@ -1,28 +1,30 @@ |
| 1 | import requests | |
| 2 | 1 | import traceback |
| 3 | 2 | from abc import ABC, abstractmethod |
| 4 | 3 | from json.decoder import JSONDecodeError |
| 4 | from typing import Any, Dict, Optional | |
| 5 | ||
| 6 | import requests | |
| 5 | 7 | |
| 6 | 8 | from lib.utils.kodi.utils import kodilog, notification |
| 7 | from typing import Optional, Dict, Any | |
| 8 | 9 | |
| 9 | 10 | |
| 10 | 11 | class DebridClient(ABC): |
| 11 | 12 | """ |
| 12 | 13 | Abstract base class for Debrid service clients. |
| 14 | ||
| 13 | 15 | Handles HTTP requests, error handling, and session management. |
| 14 | 16 | """ |
| 15 | 17 | |
| 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 | ||
| 20 | 21 | Args: |
| 21 | 22 | token (str): API token for authentication. |
| 22 | 23 | timeout (int): Request timeout in seconds. |
| 23 | 24 | session (requests.Session, optional): Custom session for requests. |
| 24 | 25 | """ |
| 25 | 26 | self.token = token |
| 27 | ||
| 26 | 28 | self.timeout = timeout |
| 27 | 29 | self.session = session or requests.Session() |
| 28 | 30 | self.headers = {} |
| … | … | @@ -68,20 +70,18 @@ class DebridClient(ABC): |
| 68 | 70 | ) |
| 69 | 71 | except requests.exceptions.Timeout as e: |
| 70 | 72 | kodilog(f"Timeout: {e}") |
| 71 | raise ProviderException("Request timed out.") | |
| 73 | raise ProviderException("Request timed out.") from e | |
| 72 | 74 | except requests.exceptions.ConnectionError as e: |
| 73 | 75 | kodilog(f"ConnectionError: {e}") |
| 74 | raise ProviderException(f"ConnectionError: {e}") | |
| 76 | raise ProviderException(f"ConnectionError: {e}") from e | |
| 75 | 77 | except requests.exceptions.RequestException as e: |
| 76 | 78 | kodilog(f"RequestException: {e}") |
| 77 | raise ProviderException(f"Request failed: {str(e)}") | |
| 79 | raise ProviderException(f"Request failed: {e!s}") from e | |
| 78 | 80 | except Exception as e: |
| 79 | 81 | kodilog(f"Unexpected error: {e}") |
| 80 | raise ProviderException(f"Unexpected error: {str(e)}") | |
| 82 | raise ProviderException(f"Unexpected error: {e!s}") from e | |
| 81 | 83 | |
| 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: | |
| 85 | 85 | """ |
| 86 | 86 | Handle HTTP errors and raise ProviderException as needed. |
| 87 | 87 | """ |
| … | … | @@ -110,13 +110,11 @@ class DebridClient(ABC): |
| 110 | 110 | |
| 111 | 111 | # Specific cases |
| 112 | 112 | if status_code == 401: |
| 113 | raise ProviderException("Invalid token", status_code, error_content) | |
| 113 | raise ProviderException("Invalid token", status_code, error_content) from error | |
| 114 | 114 | elif status_code == 403: |
| 115 | raise ProviderException("Forbidden", status_code, error_content) | |
| 115 | raise ProviderException("Forbidden", status_code, error_content) from error | |
| 116 | 116 | 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 | |
| 120 | 118 | else: |
| 121 | 119 | kodilog( |
| 122 | 120 | f"Error: {''.join(traceback.format_exception(type(error), error, error.__traceback__))}" |
| … | … | @@ -125,7 +123,7 @@ class DebridClient(ABC): |
| 125 | 123 | f"API Error: {status_code} for {url}\nDetails: {error_content}", |
| 126 | 124 | status_code, |
| 127 | 125 | error_content, |
| 128 | ) | |
| 126 | ) from error | |
| 129 | 127 | |
| 130 | 128 | @abstractmethod |
| 131 | 129 | def initialize_headers(self) -> None: |
| … | … | @@ -142,9 +140,7 @@ class DebridClient(ABC): |
| 142 | 140 | raise NotImplementedError |
| 143 | 141 | |
| 144 | 142 | @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]: | |
| 148 | 144 | """ |
| 149 | 145 | Parse the HTTP response as JSON. |
| 150 | 146 | """ |
| … | … | @@ -155,12 +151,10 @@ class DebridClient(ABC): |
| 155 | 151 | except JSONDecodeError as error: |
| 156 | 152 | raise ProviderException( |
| 157 | 153 | f"Failed to parse response error: {error}. \nresponse: {response.text}" |
| 158 | ) | |
| 154 | ) from error | |
| 159 | 155 | |
| 160 | 156 | @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: | |
| 164 | 158 | """ |
| 165 | 159 | Service specific errors on api requests. Must be implemented by subclasses. |
| 166 | 160 | """ |
| … | … | @@ -168,9 +162,7 @@ class DebridClient(ABC): |
| 168 | 162 | |
| 169 | 163 | |
| 170 | 164 | class 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): | |
| 174 | 166 | self.message = message |
| 175 | 167 | self.status_code = status_code |
| 176 | 168 | self.error_content = error_content |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | from lib.api.debrid.base import DebridClient |
| 2 | 2 | from lib.jacktook.utils import kodilog |
| 3 | from lib.utils.kodi.utils import dialog_ok, set_setting, translation | |
| 4 | 3 | from lib.services.debrid.auth import run_debrider_auth |
| 4 | from lib.utils.kodi.utils import dialog_ok, set_setting, translation | |
| 5 | 5 | |
| 6 | 6 | |
| 7 | 7 | class Debrider(DebridClient): |
| … | … | @@ -58,7 +58,7 @@ class Debrider(DebridClient): |
| 58 | 58 | ) |
| 59 | 59 | |
| 60 | 60 | 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.""" | |
| 62 | 62 | return self._make_request( |
| 63 | 63 | "GET", |
| 64 | 64 | f"{self.AUTH_URL}/device/auth", |
| … | … | @@ -109,16 +109,12 @@ class Debrider(DebridClient): |
| 109 | 109 | import datetime |
| 110 | 110 | |
| 111 | 111 | 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") | |
| 115 | 113 | 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") | |
| 119 | 115 | |
| 120 | 116 | days = (expires - datetime.datetime.utcnow()).days |
| 121 | 117 | return days |
| 122 | except Exception as e: | |
| 118 | except Exception: | |
| 123 | 119 | # kodilog(f"Error calculating Debrider days remaining: {e}") |
| 124 | 120 | return None |
| … | … | @@ -0,0 +1,123 @@ |
| 1 | from lib.api.debrid.base import DebridClient, ProviderException | |
| 2 | ||
| 3 | ||
| 4 | class 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}) |
| … | … | @@ -21,6 +21,7 @@ class Premiumize(DebridClient): |
| 21 | 21 | |
| 22 | 22 | def remove_auth(self): |
| 23 | 23 | from lib.utils.kodi.utils import set_setting |
| 24 | ||
| 24 | 25 | self.token = "" |
| 25 | 26 | set_setting("premiumize_token", "") |
| 26 | 27 | set_setting("premiumize_authorized", "false") |
| … | … | @@ -114,11 +115,7 @@ class Premiumize(DebridClient): |
| 114 | 115 | def get_torrent_info(self, torrent_id): |
| 115 | 116 | transfer_list = self.get_transfer_list() |
| 116 | 117 | 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), | |
| 122 | 119 | None, |
| 123 | 120 | ) |
| 124 | 121 | return torrent_info |
| … | … | @@ -180,6 +177,6 @@ class Premiumize(DebridClient): |
| 180 | 177 | expires = datetime.datetime.fromtimestamp(premium_until) |
| 181 | 178 | days = (expires - datetime.datetime.now()).days |
| 182 | 179 | return days |
| 183 | except Exception as e: | |
| 180 | except Exception: | |
| 184 | 181 | # kodilog(f"Error calculating Premiumize days remaining: {e}") |
| 185 | 182 | return None |
| … | … | @@ -1,13 +1,14 @@ |
| 1 | from base64 import b64decode, b64encode | |
| 2 | ||
| 1 | 3 | from lib.api.debrid.base import DebridClient, ProviderException |
| 2 | from base64 import b64encode, b64decode | |
| 4 | from lib.services.debrid.auth import run_realdebrid_auth | |
| 5 | from lib.services.debrid.download import run_realdebrid_download | |
| 3 | 6 | from lib.utils.kodi.utils import ( |
| 4 | 7 | dialog_ok, |
| 5 | 8 | kodilog, |
| 6 | 9 | set_setting, |
| 7 | 10 | translation, |
| 8 | 11 | ) |
| 9 | from lib.services.debrid.auth import run_realdebrid_auth | |
| 10 | from lib.services.debrid.download import run_realdebrid_download | |
| 11 | 12 | |
| 12 | 13 | |
| 13 | 14 | class RealDebrid(DebridClient): |
| … | … | @@ -30,9 +31,7 @@ class RealDebrid(DebridClient): |
| 30 | 31 | token_data["client_secret"], |
| 31 | 32 | token_data["code"], |
| 32 | 33 | ) |
| 33 | self.headers["Authorization"] = ( | |
| 34 | f"Bearer {access_token_data['access_token']}" | |
| 35 | ) | |
| 34 | self.headers["Authorization"] = f"Bearer {access_token_data['access_token']}" | |
| 36 | 35 | |
| 37 | 36 | def _handle_service_specific_errors(self, error_data: dict, status_code: int): |
| 38 | 37 | error_code = error_data.get("error_code") |
| … | … | @@ -125,7 +124,7 @@ class RealDebrid(DebridClient): |
| 125 | 124 | "code": code, |
| 126 | 125 | } |
| 127 | 126 | except Exception as e: |
| 128 | raise ProviderException(f"Invalid token {e}") | |
| 127 | raise ProviderException(f"Invalid token {e}") from e | |
| 129 | 128 | |
| 130 | 129 | def get_device_code(self): |
| 131 | 130 | return self._make_request( |
| … | … | @@ -247,9 +246,7 @@ class RealDebrid(DebridClient): |
| 247 | 246 | return self._make_request("GET", f"{self.BASE_URL}/torrents") |
| 248 | 247 | |
| 249 | 248 | 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}) | |
| 253 | 250 | |
| 254 | 251 | def get_user(self): |
| 255 | 252 | return self._make_request("GET", f"{self.BASE_URL}/user") |
| … | … | @@ -267,9 +264,7 @@ class RealDebrid(DebridClient): |
| 267 | 264 | import datetime |
| 268 | 265 | |
| 269 | 266 | 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") | |
| 273 | 268 | except ValueError: |
| 274 | 269 | expires = datetime.datetime.strptime(expiration, "%Y-%m-%dT%H:%M:%SZ") |
| 275 | 270 | |
| … | … | @@ -316,9 +311,8 @@ class RealDebrid(DebridClient): |
| 316 | 311 | if "download" in response: |
| 317 | 312 | return response |
| 318 | 313 | |
| 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") | |
| 322 | 316 | raise ProviderException(f"Failed to create download link. response: {response}") |
| 323 | 317 | |
| 324 | 318 | def delete_torrent(self, torrent_id): |
| … | … | @@ -1,4 +1,10 @@ |
| 1 | import datetime | |
| 2 | ||
| 3 | import requests | |
| 4 | from requests.adapters import HTTPAdapter | |
| 5 | ||
| 1 | 6 | from lib.api.debrid.base import DebridClient, ProviderException |
| 7 | from lib.services.debrid.auth import run_torbox_auth | |
| 2 | 8 | from lib.utils.kodi.utils import ( |
| 3 | 9 | dialog_ok, |
| 4 | 10 | kodilog, |
| … | … | @@ -6,10 +12,6 @@ from lib.utils.kodi.utils import ( |
| 6 | 12 | set_setting, |
| 7 | 13 | translation, |
| 8 | 14 | ) |
| 9 | import datetime | |
| 10 | import requests | |
| 11 | from requests.adapters import HTTPAdapter | |
| 12 | from lib.services.debrid.auth import run_torbox_auth | |
| 13 | 15 | |
| 14 | 16 | |
| 15 | 17 | class Torbox(DebridClient): |
| … | … | @@ -55,15 +57,46 @@ class Torbox(DebridClient): |
| 55 | 57 | ): |
| 56 | 58 | params = params or {} |
| 57 | 59 | 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 | |
| 67 | 100 | |
| 68 | 101 | def add_magnet_link(self, magnet_link): |
| 69 | 102 | return self._make_request( |
| … | … | @@ -77,7 +110,11 @@ class Torbox(DebridClient): |
| 77 | 110 | response = self.session.request( |
| 78 | 111 | method="POST", |
| 79 | 112 | 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 | }, | |
| 81 | 118 | files={ |
| 82 | 119 | "file": ( |
| 83 | 120 | torrent_name or "torrent.torrent", |
| … | … | @@ -102,7 +139,7 @@ class Torbox(DebridClient): |
| 102 | 139 | if "Request timed out" in str(exc): |
| 103 | 140 | raise ProviderException( |
| 104 | 141 | "Torbox timed out while checking your cloud torrents. Please try again." |
| 105 | ) | |
| 142 | ) from exc | |
| 106 | 143 | raise |
| 107 | 144 | |
| 108 | 145 | def get_torrent_info(self, magnet_id): |
| … | … | @@ -1,136 +1,133 @@ |
| 1 | import requests | |
| 2 | from lib.utils.kodi.utils import get_setting | |
| 3 | ||
| 4 | API_KEY = "a7ad21743fd710fccb738232f2fbdcfc" | |
| 5 | CLIENT_KEY = "fe073550acf157bdb8a4217f215c0882" | |
| 6 | BASE_URL = "https://webservice.fanart.tv/v3/{media_type}/{media_id}" | |
| 7 | TIMEOUT = 3.0 | |
| 8 | ||
| 9 | DEFAULT_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) | |
| 21 | session = requests.Session() | |
| 22 | session.mount( | |
| 23 | "https://webservice.fanart.tv", requests.adapters.HTTPAdapter(pool_maxsize=100) | |
| 24 | ) | |
| 25 | ||
| 26 | ||
| 27 | def 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 --- | |
| 82 | def 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 --- | |
| 127 | def 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 | |
| 1 | import requests | |
| 2 | ||
| 3 | from lib.utils.kodi.utils import get_setting | |
| 4 | ||
| 5 | API_KEY = "a7ad21743fd710fccb738232f2fbdcfc" | |
| 6 | CLIENT_KEY = "fe073550acf157bdb8a4217f215c0882" | |
| 7 | BASE_URL = "https://webservice.fanart.tv/v3/{media_type}/{media_id}" | |
| 8 | TIMEOUT = 3.0 | |
| 9 | ||
| 10 | DEFAULT_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) | |
| 22 | session = requests.Session() | |
| 23 | session.mount("https://webservice.fanart.tv", requests.adapters.HTTPAdapter(pool_maxsize=100)) | |
| 24 | ||
| 25 | ||
| 26 | def 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 --- | |
| 79 | def 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 --- | |
| 122 | def 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 |
| … | … | @@ -1,196 +1,317 @@ |
| 1 | import requests | |
| 2 | import re | |
| 3 | 1 | import html |
| 4 | from lib.utils.kodi.utils import kodilog | |
| 5 | from lib.db.cached import cache | |
| 2 | import re | |
| 6 | 3 | from datetime import timedelta |
| 7 | 4 | |
| 5 | import requests | |
| 8 | 6 | |
| 9 | def clean_html(text): | |
| 7 | from lib.db.cached import cache | |
| 8 | from lib.utils.kodi.utils import kodilog | |
| 9 | ||
| 10 | GQL_URL = "https://graphql.imdb.com/" | |
| 11 | GQL_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 | ||
| 23 | def _clean(text): | |
| 10 | 24 | if not text: |
| 11 | 25 | 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) | |
| 15 | 29 | text = html.unescape(text) |
| 16 | # Cleanup whitespace | |
| 17 | text = " ".join(text.split()) | |
| 18 | 30 | return text.strip() |
| 19 | 31 | |
| 20 | 32 | |
| 21 | def get_imdb_trivia(imdb_id): | |
| 33 | def get_imdb_extras(imdb_id): | |
| 22 | 34 | """ |
| 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. | |
| 25 | 38 | """ |
| 26 | 39 | if not imdb_id: |
| 27 | return [] | |
| 28 | 40 | |
| 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": []} | |
| 33 | 42 | |
| 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 | |
| 39 | 47 | |
| 40 | 48 | 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 | |
| 62 | 172 | |
| 63 | 173 | 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": []} | |
| 66 | 176 | |
| 67 | 177 | |
| 68 | def get_imdb_goofs(imdb_id): | |
| 178 | def get_imdb_more_like_this(imdb_id): | |
| 69 | 179 | """ |
| 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...). | |
| 72 | 183 | """ |
| 73 | 184 | if not imdb_id: |
| 74 | return [] | |
| 75 | 185 | |
| 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 [] | |
| 80 | 187 | |
| 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 | |
| 86 | 192 | |
| 87 | 193 | 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 | |
| 108 | 206 | 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}") | |
| 110 | 208 | return [] |
| 111 | 209 | |
| 112 | 210 | |
| 113 | def 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 | |
| 212 | def get_imdb_trivia(imdb_id): | |
| 213 | """Legacy wrapper. Use get_imdb_extras() for new code.""" | |
| 214 | return get_imdb_extras(imdb_id).get("trivia", []) | |
| 125 | 215 | |
| 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 | } | |
| 131 | 216 | |
| 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 | } | |
| 217 | def get_imdb_goofs(imdb_id): | |
| 218 | """Legacy wrapper. Use get_imdb_extras() for new code.""" | |
| 219 | return get_imdb_extras(imdb_id).get("blunders", []) | |
| 145 | 220 | |
| 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 | |
| 193 | 221 | |
| 194 | except Exception as e: | |
| 195 | kodilog(f"Error scraping IMDb parental guide for {imdb_id}: {str(e)}") | |
| 196 | return [] | |
| 222 | def 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 | ||
| 240 | IMDB_EXTRAS_QUERY = """\ | |
| 241 | query { | |
| 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 | }""" |
| … | … | @@ -1,90 +1,124 @@ |
| 1 | 1 | from json import dumps |
| 2 | from urllib.parse import quote | |
| 3 | ||
| 2 | 4 | import requests |
| 3 | 5 | from requests.auth import HTTPBasicAuth |
| 4 | from urllib.parse import quote | |
| 6 | ||
| 5 | 7 | from lib.utils.kodi.utils import notification |
| 6 | 8 | |
| 7 | 9 | |
| 8 | class TorrServer(object): | |
| 10 | class TorrServer: | |
| 9 | 11 | 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) | |
| 13 | 13 | self._username = username |
| 14 | 14 | self._password = password |
| 15 | 15 | self._auth = HTTPBasicAuth(self._username, self._password) |
| 16 | 16 | self._session = session or requests |
| 17 | 17 | |
| 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 | ||
| 18 | 40 | @property |
| 19 | 41 | def torr_version(self): |
| 20 | """tests server status""" | |
| 42 | """Tests server status""" | |
| 21 | 43 | return self._get("/echo").content |
| 22 | 44 | |
| 23 | 45 | 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 | ) | |
| 37 | 61 | |
| 38 | 62 | def add_torrent(self, path, title="", poster="", data=""): |
| 39 | 63 | 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( | |
| 41 | 80 | "/torrent/upload", |
| 42 | files={"file": file}, | |
| 81 | files={"file": obj}, | |
| 43 | 82 | data={ |
| 44 | 83 | "save": "true", |
| 45 | 84 | "title": title, |
| 46 | 85 | "poster": poster, |
| 47 | 86 | "data": data, |
| 48 | 87 | }, |
| 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 | ) | |
| 62 | 90 | |
| 63 | 91 | 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 | """ | |
| 65 | 97 | return self._post("/torrents", data=dumps({"action": "list"})).json() |
| 66 | 98 | |
| 67 | 99 | 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 | ) | |
| 72 | 104 | |
| 73 | 105 | 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 | ) | |
| 76 | 110 | |
| 77 | 111 | 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 | ) | |
| 82 | 116 | |
| 83 | 117 | def drop_torrent(self, hash): |
| 84 | 118 | return self._post("/torrents", data=dumps({"action": "drop", "hash": hash})) |
| 85 | 119 | |
| 86 | 120 | def remove_torrent(self, info_hash, save_to_db=True): |
| 87 | """delete torrent from TorrServer""" | |
| 121 | """Delete torrent from TorrServer""" | |
| 88 | 122 | return self._post( |
| 89 | 123 | "/torrents", |
| 90 | 124 | data=dumps({"action": "rem", "hash": info_hash, "save_to_db": save_to_db}), |
| … | … | @@ -104,7 +138,7 @@ class TorrServer(object): |
| 104 | 138 | ) |
| 105 | 139 | |
| 106 | 140 | def preload_torrent(self, link, file_id=1, title=""): |
| 107 | """preload torrent""" | |
| 141 | """Preload torrent""" | |
| 108 | 142 | return self._get( |
| 109 | 143 | "/stream", |
| 110 | 144 | params={ |
| … | … | @@ -117,7 +151,7 @@ class TorrServer(object): |
| 117 | 151 | ) |
| 118 | 152 | |
| 119 | 153 | def get_stream_url(self, link, path, file_id): |
| 120 | """returns the stream url""" | |
| 154 | """Returns the stream url""" | |
| 121 | 155 | return f"{self._base_url}/stream/{quote(path)}?link={link}&index={file_id}&play" |
| 122 | 156 | |
| 123 | 157 | def get_settings(self): |
| … | … | @@ -138,11 +172,9 @@ class TorrServer(object): |
| 138 | 172 | |
| 139 | 173 | def _request(self, method, url, **kwargs): |
| 140 | 174 | 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) | |
| 144 | 176 | except Exception as e: |
| 145 | raise TorrServerError(str(e)) | |
| 177 | raise TorrServerError(str(e)) from e | |
| 146 | 178 | |
| 147 | 179 | |
| 148 | 180 | class TorrServerError(Exception): |
| … | … | @@ -1,5 +1,7 @@ |
| 1 | from typing import Any, Dict, List, Optional | |
| 2 | ||
| 1 | 3 | import requests |
| 2 | from typing import List, Dict, Any, Optional | |
| 4 | ||
| 3 | 5 | from lib.utils.kodi.utils import get_setting |
| 4 | 6 | |
| 5 | 7 | |
| … | … | @@ -18,9 +20,11 @@ class MDblistAPI: |
| 18 | 20 | def search_lists(self, query: str) -> List[Dict[str, Any]]: |
| 19 | 21 | """ |
| 20 | 22 | Searches lists on MDblist by query string. |
| 23 | ||
| 21 | 24 | Returns a list of dictionaries representing each matching list. |
| 22 | 25 | """ |
| 23 | 26 | if not query: |
| 27 | ||
| 24 | 28 | raise ValueError("Query string must not be empty.") |
| 25 | 29 | url = f"{self.BASE_URL}lists/search" |
| 26 | 30 | params = {"query": query, "apikey": self.api_key} |
| … | … | @@ -29,42 +33,48 @@ class MDblistAPI: |
| 29 | 33 | response.raise_for_status() |
| 30 | 34 | return response.json() |
| 31 | 35 | 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 | |
| 33 | 37 | |
| 34 | 38 | def get_user_lists(self) -> List[Dict[str, Any]]: |
| 35 | 39 | """ |
| 36 | 40 | Fetches user lists from MDblist API. |
| 41 | ||
| 37 | 42 | Returns a list of dictionaries representing each user list. |
| 38 | 43 | """ |
| 39 | 44 | url = f"{self.BASE_URL}lists/user" |
| 45 | ||
| 40 | 46 | params = {"apikey": self.api_key} |
| 41 | 47 | try: |
| 42 | 48 | response = self.session.get(url, params=params) |
| 43 | 49 | response.raise_for_status() |
| 44 | 50 | return response.json() |
| 45 | 51 | 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 | |
| 47 | 53 | |
| 48 | 54 | def get_top_lists(self) -> List[Dict[str, Any]]: |
| 49 | 55 | """ |
| 50 | 56 | Fetches top lists from MDblist API. |
| 57 | ||
| 51 | 58 | Returns a list of dictionaries representing each top list. |
| 52 | 59 | """ |
| 53 | 60 | url = f"{self.BASE_URL}lists/top" |
| 61 | ||
| 54 | 62 | params = {"apikey": self.api_key} |
| 55 | 63 | try: |
| 56 | 64 | response = self.session.get(url, params=params) |
| 57 | 65 | response.raise_for_status() |
| 58 | 66 | return response.json() |
| 59 | 67 | 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 | |
| 61 | 69 | |
| 62 | 70 | def get_list_by_id(self, list_id: str) -> Optional[Dict[str, Any]]: |
| 63 | 71 | """ |
| 64 | 72 | Fetches a single list by its ID. |
| 73 | ||
| 65 | 74 | Returns a dictionary representing the list, or None if not found. |
| 66 | 75 | """ |
| 67 | 76 | if not list_id: |
| 77 | ||
| 68 | 78 | raise ValueError("List ID must not be empty.") |
| 69 | 79 | url = f"{self.BASE_URL}lists/{list_id}" |
| 70 | 80 | params = {"apikey": self.api_key} |
| … | … | @@ -73,9 +83,9 @@ class MDblistAPI: |
| 73 | 83 | response.raise_for_status() |
| 74 | 84 | return response.json() |
| 75 | 85 | 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 | |
| 77 | 87 | 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 | |
| 79 | 89 | |
| 80 | 90 | def get_bulk_ratings( |
| 81 | 91 | self, |
| … | … | @@ -86,9 +96,11 @@ class MDblistAPI: |
| 86 | 96 | ) -> Dict[str, Any]: |
| 87 | 97 | """ |
| 88 | 98 | Fetches bulk ratings for a list of IDs from MDblist API. |
| 99 | ||
| 89 | 100 | Returns a dictionary with ratings for each ID. |
| 90 | 101 | """ |
| 91 | 102 | if not ids or not isinstance(ids, list): |
| 103 | ||
| 92 | 104 | raise ValueError("IDs must be a non-empty list.") |
| 93 | 105 | provider = provider or self.provider |
| 94 | 106 | url = f"{self.BASE_URL}rating/{media_type}/{return_rating}" |
| … | … | @@ -96,13 +108,11 @@ class MDblistAPI: |
| 96 | 108 | payload = {"ids": ids, "provider": provider} |
| 97 | 109 | headers = {"Content-Type": "application/json"} |
| 98 | 110 | 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) | |
| 102 | 112 | response.raise_for_status() |
| 103 | 113 | return response.json() |
| 104 | 114 | 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 | |
| 106 | 116 | |
| 107 | 117 | def get_list_items( |
| 108 | 118 | self, |
| … | … | @@ -121,9 +131,11 @@ class MDblistAPI: |
| 121 | 131 | ) -> Dict[str, Any]: |
| 122 | 132 | """ |
| 123 | 133 | Fetches items from a specified list with pagination and filters. |
| 134 | ||
| 124 | 135 | Returns a dict with 'movies', 'shows', and pagination info. |
| 125 | 136 | """ |
| 126 | 137 | if not list_id: |
| 138 | ||
| 127 | 139 | raise ValueError("List ID must not be empty.") |
| 128 | 140 | url = f"{self.BASE_URL}lists/{list_id}/items" |
| 129 | 141 | params = { |
| … | … | @@ -149,4 +161,4 @@ class MDblistAPI: |
| 149 | 161 | response.raise_for_status() |
| 150 | 162 | return response.json() |
| 151 | 163 | 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 |
| … | … | @@ -1,9 +1,9 @@ |
| 1 | 1 | import json |
| 2 | import requests | |
| 3 | ||
| 4 | 2 | from http import HTTPStatus |
| 3 | ||
| 4 | import requests | |
| 5 | 5 | from requests.exceptions import ConnectionError, Timeout |
| 6 | from lib.api.plex.settings import settings | |
| 6 | ||
| 7 | 7 | from lib.api.plex.models.plex_models import ( |
| 8 | 8 | PlexEpisodeMeta, |
| 9 | 9 | PlexLibrarySection, |
| … | … | @@ -11,6 +11,7 @@ from lib.api.plex.models.plex_models import ( |
| 11 | 11 | PlexMediaType, |
| 12 | 12 | PlexServer, |
| 13 | 13 | ) |
| 14 | from lib.api.plex.settings import settings | |
| 14 | 15 | from lib.api.plex.utils import PlexUnauthorizedError |
| 15 | 16 | from lib.utils.kodi.utils import kodilog |
| 16 | 17 | |
| … | … | @@ -24,9 +25,7 @@ def check_server_connection(url, access_token): |
| 24 | 25 | }, |
| 25 | 26 | timeout=settings.plex_requests_timeout, |
| 26 | 27 | ) |
| 27 | if response.status_code != HTTPStatus.OK: | |
| 28 | return False | |
| 29 | return True | |
| 28 | return response.status_code == HTTPStatus.OK | |
| 30 | 29 | except (Timeout, ConnectionError): |
| 31 | 30 | return False |
| 32 | 31 | |
| … | … | @@ -128,7 +127,7 @@ def imdb_to_plex_id(imdb_id, token, mode, media_type): |
| 128 | 127 | url="https://metadata.provider.plex.tv/library/metadata/matches", |
| 129 | 128 | params={ |
| 130 | 129 | "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, | |
| 132 | 131 | "title": f"imdb-{imdb_id}", |
| 133 | 132 | "guid": f"com.plexapp.agents.imdb://{imdb_id}?lang=en", |
| 134 | 133 | }, |
| … | … | @@ -192,4 +191,4 @@ def get_json(url, params=None): |
| 192 | 191 | kodilog("Received error from plex server") |
| 193 | 192 | return json.loads(response.content) |
| 194 | 193 | except TimeoutError: |
| 195 | kodilog("Plex server timeout error") | |
| 194 | kodilog("Plex server timeout error") |
| … | … | @@ -2,6 +2,7 @@ import os |
| 2 | 2 | from datetime import datetime |
| 3 | 3 | from enum import Enum |
| 4 | 4 | from urllib import parse |
| 5 | ||
| 5 | 6 | from lib.utils.kodi_utils import get_setting |
| 6 | 7 | from lib.utils.utils import Indexer |
| 7 | 8 | |
| … | … | @@ -48,27 +49,27 @@ class PlexMediaMeta: |
| 48 | 49 | self.type = kwargs.get("type") |
| 49 | 50 | self.title = kwargs.get("title") |
| 50 | 51 | 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") | |
| 59 | 60 | 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") | |
| 72 | 73 | self.media = kwargs.get("Media") |
| 73 | 74 | self.genre = kwargs.get("Genre") |
| 74 | 75 | self.country = kwargs.get("Country") |
| … | … | @@ -98,10 +99,10 @@ class PlexMediaMeta: |
| 98 | 99 | |
| 99 | 100 | description_template = ( |
| 100 | 101 | 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))})" | |
| 103 | 104 | ) |
| 104 | ||
| 105 | ||
| 105 | 106 | encoded_params = parse.urlencode( |
| 106 | 107 | { |
| 107 | 108 | "X-Plex-Token": get_setting("plex_server_token"), |
| … | … | @@ -111,7 +112,7 @@ class PlexMediaMeta: |
| 111 | 112 | { |
| 112 | 113 | "title": f"Direct Play - {name} - {description_template}", |
| 113 | 114 | "indexer": Indexer.PLEX, |
| 114 | "quality_description": f'Direct Play {media["videoResolution"]}', | |
| 115 | "quality_description": f"Direct Play {media['videoResolution']}", | |
| 115 | 116 | "url": f"{get_setting('plex_streaming_url')}/{media['Part'][0]['key'][1:]}?{encoded_params}", |
| 116 | 117 | } |
| 117 | 118 | ) |
| … | … | @@ -133,7 +134,7 @@ class PlexMediaMeta: |
| 133 | 134 | { |
| 134 | 135 | "title": f"Transcode - {name} - {description_template}", |
| 135 | 136 | "indexer": Indexer.PLEX, |
| 136 | "quality_description": f'Transcode {media["videoResolution"]} (original)', | |
| 137 | "quality_description": f"Transcode {media['videoResolution']} (original)", | |
| 137 | 138 | "url": f"{get_setting('plex_streaming_url')}/video/:/transcode/universal/start.m3u8?{encoded_params}", |
| 138 | 139 | } |
| 139 | 140 | ) |
| … | … | @@ -1,14 +1,16 @@ |
| 1 | from http import HTTPStatus | |
| 1 | import contextlib | |
| 2 | 2 | import json |
| 3 | from http import HTTPStatus | |
| 3 | 4 | from time import sleep, time |
| 5 | ||
| 4 | 6 | import requests |
| 7 | import xbmcgui | |
| 5 | 8 | |
| 6 | from lib.api.plex.settings import settings | |
| 7 | 9 | from lib.api.plex.models.plex_models import AuthPin, PlexUser |
| 10 | from lib.api.plex.settings import settings | |
| 8 | 11 | from lib.api.plex.utils import HTTPException, PlexUnauthorizedError |
| 9 | 12 | from lib.utils.kodi.utils import copy2clip, dialog_ok, kodilog, set_setting |
| 10 | 13 | |
| 11 | import xbmcgui | |
| 12 | 14 | |
| 13 | 15 | class PlexApi: |
| 14 | 16 | def __init__(self) -> None: |
| … | … | @@ -21,9 +23,9 @@ class PlexApi: |
| 21 | 23 | def login(self): |
| 22 | 24 | auth_pin = self.create_auth_pin() |
| 23 | 25 | 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] ", | |
| 27 | 29 | ) |
| 28 | 30 | progressDialog = xbmcgui.DialogProgress() |
| 29 | 31 | progressDialog.create("Plex Auth") |
| … | … | @@ -93,7 +95,7 @@ class PlexApi: |
| 93 | 95 | raise HTTPException( |
| 94 | 96 | status_code=504, |
| 95 | 97 | detail="Plex server timeout error", |
| 96 | ) | |
| 98 | ) from None | |
| 97 | 99 | |
| 98 | 100 | def get_plex_user(self): |
| 99 | 101 | response = self.client.get( |
| … | … | @@ -114,14 +116,11 @@ class PlexApi: |
| 114 | 116 | def logout(self): |
| 115 | 117 | self.auth_token = "" |
| 116 | 118 | set_setting("plex_token", "") |
| 117 | try: | |
| 119 | with contextlib.suppress(BaseException): | |
| 118 | 120 | self.client.close() |
| 119 | except: | |
| 120 | pass | |
| 121 | 121 | set_setting("plex_user", "") |
| 122 | 122 | set_setting("plex_server_name", "") |
| 123 | 123 | set_setting("plex_discovery_url", "") |
| 124 | 124 | set_setting("plex_streaming_url", "") |
| 125 | 125 | set_setting("plex_token", "") |
| 126 | 126 | set_setting("plex_server_token", "") |
| 127 |
| … | … | @@ -1,8 +1,9 @@ |
| 1 | 1 | from uuid import uuid4 |
| 2 | 2 | |
| 3 | class Settings(): | |
| 3 | ||
| 4 | class Settings: | |
| 4 | 5 | identifier: str = str(uuid4()) |
| 5 | product_name: str = 'Jacktook' | |
| 6 | product_name: str = "Jacktook" | |
| 6 | 7 | plex_requests_timeout: int = 30 |
| 7 | 8 | plex_matching_token: str |
| 8 | 9 |
| … | … | @@ -1,4 +1,3 @@ |
| 1 | ||
| 2 | 1 | class PlexUnauthorizedError(BaseException): |
| 3 | 2 | pass |
| 4 | 3 | |
| … | … | @@ -11,6 +10,3 @@ class HTTPException(BaseException): |
| 11 | 10 | ): |
| 12 | 11 | self.status_code = status_code |
| 13 | 12 | self.detail = detail |
| 14 | ||
| 15 | ||
| 16 |
| … | … | @@ -1,5 +1,5 @@ |
| 1 | 1 | import json |
| 2 | from typing import List, Optional, Any | |
| 2 | from typing import Any, List, Optional | |
| 3 | 3 | from urllib.parse import urlsplit, urlunsplit |
| 4 | 4 | |
| 5 | 5 | |
| … | … | @@ -16,9 +16,7 @@ def normalize_transport_url(url: str) -> str: |
| 16 | 16 | if path.endswith("/manifest.json"): |
| 17 | 17 | path = path[: -len("/manifest.json")] |
| 18 | 18 | |
| 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, "")) | |
| 22 | 20 | |
| 23 | 21 | |
| 24 | 22 | def build_addon_base_url(transport_url: str) -> str: |
| … | … | @@ -35,12 +33,7 @@ def build_addon_instance_key(addon_like: Any) -> str: |
| 35 | 33 | transport_url = addon_like.transport_url or "" |
| 36 | 34 | elif isinstance(addon_like, dict): |
| 37 | 35 | 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 "" | |
| 44 | 37 | transport_url = addon_like.get("transportUrl") or addon_like.get("transport_url") or "" |
| 45 | 38 | |
| 46 | 39 | normalized_url = normalize_transport_url(transport_url) |
| … | … | @@ -57,10 +50,7 @@ def build_addon_instance_label(addon_like: Any) -> str: |
| 57 | 50 | else: |
| 58 | 51 | manifest = addon_like.get("manifest") or {} |
| 59 | 52 | 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" | |
| 64 | 54 | ) |
| 65 | 55 | transport_url = addon_like.get("transportUrl") or addon_like.get("transport_url") or "" |
| 66 | 56 | 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: |
| 80 | 70 | |
| 81 | 71 | |
| 82 | 72 | class 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): | |
| 84 | 74 | self.type = type |
| 85 | 75 | self.id = id |
| 86 | 76 | self.name = name |
| … | … | @@ -237,9 +227,7 @@ class AddonManager: |
| 237 | 227 | ) |
| 238 | 228 | if isinstance(resource, str) |
| 239 | 229 | else Resource( |
| 240 | name=( | |
| 241 | resource["name"] if isinstance(resource, dict) else resource | |
| 242 | ), | |
| 230 | name=(resource["name"] if isinstance(resource, dict) else resource), | |
| 243 | 231 | types=resource.get("types", item["manifest"].get("types", [])), |
| 244 | 232 | id_prefixes=resource.get( |
| 245 | 233 | "idPrefixes", item["manifest"].get("idPrefixes", []) |
| … | … | @@ -297,10 +285,9 @@ class AddonManager: |
| 297 | 285 | if addon.manifest.id == "org.stremio.local": |
| 298 | 286 | continue |
| 299 | 287 | 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 | |
| 304 | 291 | |
| 305 | 292 | if resource.name == resource_name: |
| 306 | 293 | result.append(addon) |
| … | … | @@ -316,10 +303,7 @@ class AddonManager: |
| 316 | 303 | continue |
| 317 | 304 | for resource in addon.manifest.resources: |
| 318 | 305 | 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: | |
| 323 | 307 | result.append(addon) |
| 324 | 308 | break |
| 325 | 309 |
| … | … | @@ -1,9 +1,11 @@ |
| 1 | 1 | from json import JSONDecodeError |
| 2 | from requests.exceptions import RequestException, Timeout, TooManyRedirects | |
| 2 | ||
| 3 | 3 | from requests import Session |
| 4 | from requests.exceptions import RequestException, Timeout, TooManyRedirects | |
| 5 | ||
| 4 | 6 | from lib.utils.general.utils import USER_AGENT_HEADER |
| 5 | from lib.utils.kodi.utils import kodilog | |
| 6 | 7 | from lib.utils.kodi.settings import get_int_setting |
| 8 | from lib.utils.kodi.utils import kodilog | |
| 7 | 9 | |
| 8 | 10 | |
| 9 | 11 | class Stremio: |
| … | … | @@ -17,9 +19,7 @@ class Stremio: |
| 17 | 19 | if method == "GET": |
| 18 | 20 | resp = self.session.get(url, timeout=get_int_setting("stremio_timeout")) |
| 19 | 21 | 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")) | |
| 23 | 23 | else: |
| 24 | 24 | raise ValueError(f"Unsupported HTTP method: {method}") |
| 25 | 25 | |
| … | … | @@ -32,9 +32,7 @@ class Stremio: |
| 32 | 32 | try: |
| 33 | 33 | return resp.json() |
| 34 | 34 | 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}") | |
| 38 | 36 | raise |
| 39 | 37 | except Timeout: |
| 40 | 38 | kodilog(f"Request timed out for URL: {url}") |
| … | … | @@ -54,7 +52,6 @@ class Stremio: |
| 54 | 52 | |
| 55 | 53 | def login(self, email, password): |
| 56 | 54 | """Login to Stremio account.""" |
| 57 | ||
| 58 | 55 | data = { |
| 59 | 56 | "authKey": self.authKey, |
| 60 | 57 | "email": email, |
| … | … | @@ -71,9 +68,7 @@ class Stremio: |
| 71 | 68 | res = self._post("https://api.strem.io/api/dataExport", data) |
| 72 | 69 | exportId = res.get("result", {}).get("exportId", None) |
| 73 | 70 | |
| 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") | |
| 77 | 72 | return dataExport |
| 78 | 73 | |
| 79 | 74 | def get_my_addons(self): |
| … | … | @@ -1,5 +1,6 @@ |
| 1 | from typing import List, Optional, Any, Dict, Union | |
| 2 | 1 | from dataclasses import dataclass, field |
| 2 | from typing import Any, Dict, List, Optional, Union | |
| 3 | ||
| 3 | 4 | |
| 4 | 5 | @dataclass |
| 5 | 6 | class MetaLink: |
| … | … | @@ -8,13 +9,14 @@ class MetaLink: |
| 8 | 9 | url: str |
| 9 | 10 | |
| 10 | 11 | @classmethod |
| 11 | def from_dict(cls, data: Dict[str, Any]) -> 'MetaLink': | |
| 12 | def from_dict(cls, data: Dict[str, Any]) -> "MetaLink": | |
| 12 | 13 | return cls( |
| 13 | 14 | name=data.get("name", ""), |
| 14 | 15 | category=data.get("category", ""), |
| 15 | url=data.get("url", "") | |
| 16 | url=data.get("url", ""), | |
| 16 | 17 | ) |
| 17 | 18 | |
| 19 | ||
| 18 | 20 | def extract_proxy_headers(raw: Any) -> Dict[str, str]: |
| 19 | 21 | """Normalise a stream's proxyHeaders into flat request headers. |
| 20 | 22 | |
| … | … | @@ -41,7 +43,7 @@ class StreamBehaviorHints: |
| 41 | 43 | filename: Optional[str] = None |
| 42 | 44 | |
| 43 | 45 | @classmethod |
| 44 | def from_dict(cls, data: Dict[str, Any]) -> 'StreamBehaviorHints': | |
| 46 | def from_dict(cls, data: Dict[str, Any]) -> "StreamBehaviorHints": | |
| 45 | 47 | return cls( |
| 46 | 48 | countryWhitelist=data.get("countryWhitelist", []), |
| 47 | 49 | notWebReady=data.get("notWebReady", False), |
| … | … | @@ -49,9 +51,10 @@ class StreamBehaviorHints: |
| 49 | 51 | proxyHeaders=data.get("proxyHeaders", {}), |
| 50 | 52 | videoHash=data.get("videoHash"), |
| 51 | 53 | videoSize=data.get("videoSize"), |
| 52 | filename=data.get("filename") | |
| 54 | filename=data.get("filename"), | |
| 53 | 55 | ) |
| 54 | 56 | |
| 57 | ||
| 55 | 58 | @dataclass |
| 56 | 59 | class Stream: |
| 57 | 60 | url: Optional[str] = None |
| … | … | @@ -63,7 +66,7 @@ class Stream: |
| 63 | 66 | title: Optional[str] = None |
| 64 | 67 | description: Optional[str] = None |
| 65 | 68 | behaviorHints: Optional[StreamBehaviorHints] = None |
| 66 | ||
| 69 | ||
| 67 | 70 | # Custom/Extended fields |
| 68 | 71 | fileMustInclude: Optional[List[str]] = None |
| 69 | 72 | nzbUrl: Optional[str] = None |
| … | … | @@ -73,11 +76,11 @@ class Stream: |
| 73 | 76 | sevenZipUrls: List[str] = field(default_factory=list) |
| 74 | 77 | tgzUrls: List[str] = field(default_factory=list) |
| 75 | 78 | tarUrls: List[str] = field(default_factory=list) |
| 76 | ||
| 79 | ||
| 77 | 80 | meta: Dict[str, Any] = field(default_factory=dict) |
| 78 | 81 | |
| 79 | 82 | @classmethod |
| 80 | def from_dict(cls, data: Dict[str, Any]) -> 'Stream': | |
| 83 | def from_dict(cls, data: Dict[str, Any]) -> "Stream": | |
| 81 | 84 | hints = data.get("behaviorHints") |
| 82 | 85 | return cls( |
| 83 | 86 | url=data.get("url"), |
| … | … | @@ -97,24 +100,24 @@ class Stream: |
| 97 | 100 | sevenZipUrls=data.get("7zipUrls", []), |
| 98 | 101 | tgzUrls=data.get("tgzUrls", []), |
| 99 | 102 | tarUrls=data.get("tarUrls", []), |
| 100 | meta=data.get("meta", {}) | |
| 103 | meta=data.get("meta", {}), | |
| 101 | 104 | ) |
| 102 | 105 | |
| 103 | 106 | def get_parsed_title(self) -> str: |
| 104 | 107 | filename = self.behaviorHints.filename if self.behaviorHints else None |
| 105 | 108 | title = filename or self.description or self.title |
| 106 | 109 | return title.splitlines()[0] if title else "" |
| 107 | ||
| 110 | ||
| 108 | 111 | def get_sub_indexer(self, addon: Any) -> str: |
| 109 | 112 | if not self.name: |
| 110 | 113 | return "" |
| 111 | ||
| 114 | ||
| 112 | 115 | name_parts = self.name.split() |
| 113 | 116 | if len(name_parts) > 1: |
| 114 | 117 | return name_parts[1] |
| 115 | ||
| 116 | return "" | |
| 117 | ||
| 118 | ||
| 119 | return "" | |
| 120 | ||
| 118 | 121 | def get_proxy_headers(self) -> Dict[str, str]: |
| 119 | 122 | """Request headers this stream needs at playback time.""" |
| 120 | 123 | return extract_proxy_headers( |
| … | … | @@ -124,10 +127,11 @@ class Stream: |
| 124 | 127 | def get_parsed_size(self) -> int: |
| 125 | 128 | size = self.behaviorHints.videoSize if self.behaviorHints else None |
| 126 | 129 | return size or self.meta.get("size") or 0 |
| 127 | ||
| 128 | def get_provider(self) -> str: | |
| 130 | ||
| 131 | def get_provider(self) -> str: | |
| 129 | 132 | return self.meta.get("indexer") or "" |
| 130 | 133 | |
| 134 | ||
| 131 | 135 | @dataclass |
| 132 | 136 | class Video: |
| 133 | 137 | id: str |
| … | … | @@ -144,7 +148,7 @@ class Video: |
| 144 | 148 | imdbEpisode: Optional[Union[int, str]] = None |
| 145 | 149 | |
| 146 | 150 | @classmethod |
| 147 | def from_dict(cls, data: Dict[str, Any]) -> 'Video': | |
| 151 | def from_dict(cls, data: Dict[str, Any]) -> "Video": | |
| 148 | 152 | return cls( |
| 149 | 153 | id=data.get("id", ""), |
| 150 | 154 | title=data.get("title", ""), |
| … | … | @@ -157,17 +161,19 @@ class Video: |
| 157 | 161 | trailers=[Stream.from_dict(t) for t in data.get("trailers", [])], |
| 158 | 162 | overview=data.get("overview"), |
| 159 | 163 | imdbSeason=data.get("imdbSeason"), |
| 160 | imdbEpisode=data.get("imdbEpisode") | |
| 164 | imdbEpisode=data.get("imdbEpisode"), | |
| 161 | 165 | ) |
| 162 | 166 | |
| 167 | ||
| 163 | 168 | @dataclass |
| 164 | 169 | class MetaBehaviorHints: |
| 165 | 170 | defaultVideoId: Optional[str] = None |
| 166 | ||
| 171 | ||
| 167 | 172 | @classmethod |
| 168 | def from_dict(cls, data: Dict[str, Any]) -> 'MetaBehaviorHints': | |
| 173 | def from_dict(cls, data: Dict[str, Any]) -> "MetaBehaviorHints": | |
| 169 | 174 | return cls(defaultVideoId=data.get("defaultVideoId")) |
| 170 | 175 | |
| 176 | ||
| 171 | 177 | @dataclass |
| 172 | 178 | class Meta: |
| 173 | 179 | id: str |
| … | … | @@ -183,7 +189,7 @@ class Meta: |
| 183 | 189 | cast: List[str] = field(default_factory=list) |
| 184 | 190 | imdbRating: Optional[str] = None |
| 185 | 191 | 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 | |
| 187 | 193 | links: List[MetaLink] = field(default_factory=list) |
| 188 | 194 | videos: List[Video] = field(default_factory=list) |
| 189 | 195 | runtime: Optional[str] = None |
| … | … | @@ -197,7 +203,7 @@ class Meta: |
| 197 | 203 | genres: List[str] = field(default_factory=list) |
| 198 | 204 | |
| 199 | 205 | @classmethod |
| 200 | def from_dict(cls, data: Dict[str, Any]) -> 'Meta': | |
| 206 | def from_dict(cls, data: Dict[str, Any]) -> "Meta": | |
| 201 | 207 | hints = data.get("behaviorHints") |
| 202 | 208 | return cls( |
| 203 | 209 | id=data.get("id", ""), |
| … | … | @@ -224,7 +230,7 @@ class Meta: |
| 224 | 230 | behaviorHints=MetaBehaviorHints.from_dict(hints) if hints else None, |
| 225 | 231 | moviedb_id=data.get("moviedb_id"), |
| 226 | 232 | imdb_id=data.get("imdb_id"), |
| 227 | genres=data.get("genres", []) | |
| 233 | genres=data.get("genres", []), | |
| 228 | 234 | ) |
| 229 | 235 | |
| 230 | 236 | |
| … | … | @@ -249,7 +255,7 @@ class MetaPreview: |
| 249 | 255 | streams: List[Stream] = field(default_factory=list) |
| 250 | 256 | |
| 251 | 257 | @classmethod |
| 252 | def from_dict(cls, data: Dict[str, Any]) -> 'MetaPreview': | |
| 258 | def from_dict(cls, data: Dict[str, Any]) -> "MetaPreview": | |
| 253 | 259 | return cls( |
| 254 | 260 | id=data.get("id", ""), |
| 255 | 261 | type=data.get("type", ""), |
| … | … | @@ -267,5 +273,5 @@ class MetaPreview: |
| 267 | 273 | trailers=data.get("trailers", []), |
| 268 | 274 | moviedb_id=data.get("moviedb_id"), |
| 269 | 275 | 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", [])], | |
| 271 | 277 | ) |
| … | … | @@ -1,6 +1,4 @@ |
| 1 | # encoding: utf-8 | |
| 2 | 1 | import sys |
| 3 | from .exceptions import TMDbException | |
| 4 | 2 | |
| 5 | 3 | |
| 6 | 4 | class AsObj: |
| … | … | @@ -12,9 +10,7 @@ class AsObj: |
| 12 | 10 | self._obj_list = [] |
| 13 | 11 | self._list_only = False |
| 14 | 12 | 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] | |
| 18 | 14 | self._list_only = True |
| 19 | 15 | elif dict_key: |
| 20 | 16 | self._obj_list = [ |
| … | … | @@ -30,9 +26,7 @@ class AsObj: |
| 30 | 26 | for key, value in self._json.items(): |
| 31 | 27 | if isinstance(value, (dict, list)): |
| 32 | 28 | 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) | |
| 36 | 30 | self._obj_list = final |
| 37 | 31 | else: |
| 38 | 32 | final = AsObj(value) |
| … | … | @@ -1,9 +1,9 @@ |
| 1 | 1 | import os |
| 2 | ||
| 2 | 3 | from lib.api.tmdbv3api.exceptions import TMDbException |
| 3 | 4 | from lib.api.tmdbv3api.tmdb import TMDb |
| 4 | 5 | |
| 5 | 6 | |
| 6 | ||
| 7 | 7 | class Account(TMDb): |
| 8 | 8 | _urls = { |
| 9 | 9 | "details": "/account", |
| … | … | @@ -31,8 +31,7 @@ class Account(TMDb): |
| 31 | 31 | :return: |
| 32 | 32 | """ |
| 33 | 33 | 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}" | |
| 36 | 35 | ) |
| 37 | 36 | |
| 38 | 37 | def created_lists(self, page=1): |
| … | … | @@ -43,19 +42,15 @@ class Account(TMDb): |
| 43 | 42 | """ |
| 44 | 43 | return self._request_obj( |
| 45 | 44 | 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", | |
| 48 | 47 | ) |
| 49 | 48 | |
| 50 | 49 | 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}" | |
| 52 | 51 | if asc_sort is False: |
| 53 | 52 | 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") | |
| 59 | 54 | |
| 60 | 55 | def favorite_movies(self, asc_sort=True, page=1): |
| 61 | 56 | """ |
| … | … | @@ -86,13 +81,13 @@ class Account(TMDb): |
| 86 | 81 | raise TMDbException("Media Type should be tv or movie.") |
| 87 | 82 | self._request_obj( |
| 88 | 83 | self._urls["favorite"] % self.account_id, |
| 89 | params="session_id=%s" % self.session_id, | |
| 84 | params=f"session_id={self.session_id}", | |
| 90 | 85 | method="POST", |
| 91 | 86 | json={ |
| 92 | 87 | "media_type": media_type, |
| 93 | 88 | "media_id": media_id, |
| 94 | 89 | "favorite": favorite, |
| 95 | } | |
| 90 | }, | |
| 96 | 91 | ) |
| 97 | 92 | |
| 98 | 93 | def unmark_as_favorite(self, media_id, media_type): |
| … | … | @@ -159,13 +154,13 @@ class Account(TMDb): |
| 159 | 154 | raise TMDbException("Media Type should be tv or movie.") |
| 160 | 155 | self._request_obj( |
| 161 | 156 | self._urls["watchlist"] % self.account_id, |
| 162 | "session_id=%s" % self.session_id, | |
| 157 | f"session_id={self.session_id}", | |
| 163 | 158 | method="POST", |
| 164 | 159 | json={ |
| 165 | 160 | "media_type": media_type, |
| 166 | 161 | "media_id": media_id, |
| 167 | 162 | "watchlist": watchlist, |
| 168 | } | |
| 163 | }, | |
| 169 | 164 | ) |
| 170 | 165 | |
| 171 | 166 | def remove_from_watchlist(self, media_id, media_type): |
| … | … | @@ -14,100 +14,67 @@ class TmdbAnime(TMDb): |
| 14 | 14 | |
| 15 | 15 | def cartoons_popular(self, mode, page_no): |
| 16 | 16 | 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}", | |
| 23 | 19 | ) |
| 24 | 20 | |
| 25 | 21 | def animation_popular(self, mode, page_no): |
| 26 | 22 | 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}", | |
| 33 | 25 | ) |
| 34 | 26 | |
| 35 | 27 | def anime_popular(self, mode, page_no): |
| 36 | 28 | 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}", | |
| 43 | 31 | ) |
| 44 | 32 | |
| 45 | 33 | def anime_popular_recent(self, mode, page_no): |
| 46 | 34 | 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"]), | |
| 52 | 36 | 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 | ) | |
| 55 | 40 | ), |
| 56 | 41 | ) |
| 57 | 42 | |
| 58 | 43 | 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" | |
| 62 | 45 | 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"] | |
| 67 | 49 | ), |
| 68 | params="with_keywords=210024&include_null_first_air_dates=false&%s=%s&page=%s" | |
| 69 | % (year_param, params["year"], params["page"]), | |
| 70 | 50 | ) |
| 71 | 51 | |
| 72 | 52 | def anime_genres(self, params): |
| 73 | 53 | 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"] | |
| 78 | 57 | ), |
| 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"]), | |
| 81 | 58 | ) |
| 82 | 59 | |
| 83 | 60 | def anime_on_the_air(self, mode, page_no): |
| 84 | 61 | current_date, future_date = get_dates(7, reverse=False) |
| 85 | 62 | 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}", | |
| 93 | 65 | ) |
| 94 | 66 | |
| 95 | 67 | def anime_top_rated(self, mode, page_no): |
| 96 | 68 | 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}", | |
| 104 | 71 | ) |
| 105 | 72 | |
| 106 | 73 | 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}" | |
| 108 | 75 | |
| 109 | 76 | 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" | |
| 111 | 78 | |
| 112 | 79 | return self._request_obj( |
| 113 | 80 | self._urls["search_tv"] if mode == "tv" else self._urls["search_movie"], |
| … | … | @@ -117,10 +84,5 @@ class TmdbAnime(TMDb): |
| 117 | 84 | |
| 118 | 85 | def tmdb_keywords(self, mode, tmdb_id): |
| 119 | 86 | 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, | |
| 126 | 88 | ) |
| … | … | @@ -1,5 +1,6 @@ |
| 1 | 1 | from lib.api.tmdbv3api.tmdb import TMDb |
| 2 | 2 | |
| 3 | ||
| 3 | 4 | class Authentication(TMDb): |
| 4 | 5 | _urls = { |
| 5 | 6 | "create_request_token": "/authentication/token/new", |
| … | … | @@ -32,7 +33,7 @@ class Authentication(TMDb): |
| 32 | 33 | response = self._request_obj( |
| 33 | 34 | self._urls["create_session"], |
| 34 | 35 | method="POST", |
| 35 | json={"request_token": self.request_token} | |
| 36 | json={"request_token": self.request_token}, | |
| 36 | 37 | ) |
| 37 | 38 | self.session_id = response.session_id |
| 38 | 39 | |
| … | … | @@ -47,7 +48,7 @@ class Authentication(TMDb): |
| 47 | 48 | "username": self.username, |
| 48 | 49 | "password": self.password, |
| 49 | 50 | "request_token": self.request_token, |
| 50 | } | |
| 51 | }, | |
| 51 | 52 | ) |
| 52 | 53 | |
| 53 | 54 | def delete_session(self): |
| … | … | @@ -58,6 +59,6 @@ class Authentication(TMDb): |
| 58 | 59 | self._request_obj( |
| 59 | 60 | self._urls["delete_session"], |
| 60 | 61 | method="DELETE", |
| 61 | json={"session_id": self.session_id} | |
| 62 | json={"session_id": self.session_id}, | |
| 62 | 63 | ) |
| 63 | 64 | self.session_id = "" |
| … | … | @@ -1,5 +1,6 @@ |
| 1 | 1 | from lib.api.tmdbv3api.tmdb import TMDb |
| 2 | 2 | |
| 3 | ||
| 3 | 4 | class Certification(TMDb): |
| 4 | 5 | _urls = { |
| 5 | 6 | "movie_list": "/certification/movie/list", |
| … | … | @@ -5,20 +5,16 @@ class Change(TMDb): |
| 5 | 5 | _urls = { |
| 6 | 6 | "movie": "/movie/changes", |
| 7 | 7 | "tv": "/tv/changes", |
| 8 | "person": "/person/changes" | |
| 8 | "person": "/person/changes", | |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | 11 | def _change_list(self, change_type, start_date="", end_date="", page=1): |
| 12 | params = "page=%s" % page | |
| 12 | params = f"page={page}" | |
| 13 | 13 | if start_date: |
| 14 | params += "&start_date=%s" % start_date | |
| 14 | params += f"&start_date={start_date}" | |
| 15 | 15 | 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") | |
| 22 | 18 | |
| 23 | 19 | def movie_change_list(self, start_date="", end_date="", page=1): |
| 24 | 20 | """ |
| … | … | @@ -4,8 +4,8 @@ from lib.api.tmdbv3api.tmdb import TMDb |
| 4 | 4 | class Collection(TMDb): |
| 5 | 5 | _urls = { |
| 6 | 6 | "details": "/collection/%s", |
| 7 | "images": "/collection/%s/images", | |
| 8 | "translations": "/collection/%s/translations" | |
| 7 | "images": "/collection/%s/images", | |
| 8 | "translations": "/collection/%s/translations", | |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | 11 | def details(self, collection_id): |
| … | … | @@ -3,10 +3,10 @@ from lib.api.tmdbv3api.tmdb import TMDb |
| 3 | 3 | |
| 4 | 4 | class Company(TMDb): |
| 5 | 5 | _urls = { |
| 6 | "details": "/company/%s", | |
| 6 | "details": "/company/%s", | |
| 7 | 7 | "alternative_names": "/company/%s/alternative_names", |
| 8 | 8 | "images": "/company/%s/images", |
| 9 | "movies": "/company/%s/movies" | |
| 9 | "movies": "/company/%s/movies", | |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | 12 | def details(self, company_id): |
| … | … | @@ -41,7 +41,5 @@ class Company(TMDb): |
| 41 | 41 | :return: |
| 42 | 42 | """ |
| 43 | 43 | 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" | |
| 47 | 45 | ) |
| … | … | @@ -1,4 +1,5 @@ |
| 1 | 1 | import warnings |
| 2 | ||
| 2 | 3 | from lib.api.tmdbv3api.tmdb import TMDb |
| 3 | 4 | |
| 4 | 5 | |
| … | … | @@ -9,12 +10,14 @@ class Configuration(TMDb): |
| 9 | 10 | "jobs": "/configuration/jobs", |
| 10 | 11 | "languages": "/configuration/languages", |
| 11 | 12 | "primary_translations": "/configuration/primary_translations", |
| 12 | "timezones": "/configuration/timezones" | |
| 13 | "timezones": "/configuration/timezones", | |
| 13 | 14 | } |
| 14 | 15 | |
| 15 | 16 | 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 | ) | |
| 18 | 21 | return self.api_configuration() |
| 19 | 22 | |
| 20 | 23 | def api_configuration(self): |
| … | … | @@ -1,9 +1,8 @@ |
| 1 | 1 | from lib.api.tmdbv3api.tmdb import TMDb |
| 2 | 2 | |
| 3 | ||
| 3 | 4 | class Credit(TMDb): |
| 4 | _urls = { | |
| 5 | "details": "/credit/%s" | |
| 6 | } | |
| 5 | _urls = {"details": "/credit/%s"} | |
| 7 | 6 | |
| 8 | 7 | def details(self, credit_id): |
| 9 | 8 | """ |
| … | … | @@ -34,10 +34,10 @@ class Discover(TMDb): |
| 34 | 34 | current_date, future_date = get_dates(7, reverse=False) |
| 35 | 35 | params = ( |
| 36 | 36 | "&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}" | |
| 41 | 41 | ) |
| 42 | 42 | return self._request_obj( |
| 43 | 43 | self._urls["tv"], |
| … | … | @@ -15,7 +15,13 @@ class Episode(TMDb): |
| 15 | 15 | "videos": "/tv/%s/season/%s/episode/%s/videos", |
| 16 | 16 | } |
| 17 | 17 | |
| 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 | ): | |
| 19 | 25 | """ |
| 20 | 26 | Get the TV episode details by id. |
| 21 | 27 | :param tv_id: int |
| … | … | @@ -26,7 +32,7 @@ class Episode(TMDb): |
| 26 | 32 | """ |
| 27 | 33 | return self._request_obj( |
| 28 | 34 | 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}", | |
| 30 | 36 | ) |
| 31 | 37 | |
| 32 | 38 | def account_states(self, tv_id, season_num, episode_num): |
| … | … | @@ -39,7 +45,7 @@ class Episode(TMDb): |
| 39 | 45 | """ |
| 40 | 46 | return self._request_obj( |
| 41 | 47 | 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}", | |
| 43 | 49 | ) |
| 44 | 50 | |
| 45 | 51 | def changes(self, episode_id, start_date=None, end_date=None, page=1): |
| … | … | @@ -52,16 +58,12 @@ class Episode(TMDb): |
| 52 | 58 | :param page: int |
| 53 | 59 | :return: |
| 54 | 60 | """ |
| 55 | params = "page=%s" % page | |
| 61 | params = f"page={page}" | |
| 56 | 62 | if start_date: |
| 57 | params += "&start_date=%s" % start_date | |
| 63 | params += f"&start_date={start_date}" | |
| 58 | 64 | 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") | |
| 65 | 67 | |
| 66 | 68 | def credits(self, tv_id, season_num, episode_num): |
| 67 | 69 | """ |
| … | … | @@ -94,8 +96,10 @@ class Episode(TMDb): |
| 94 | 96 | """ |
| 95 | 97 | return self._request_obj( |
| 96 | 98 | 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", | |
| 99 | 103 | ) |
| 100 | 104 | |
| 101 | 105 | def translations(self, tv_id, season_num, episode_num): |
| … | … | @@ -108,7 +112,7 @@ class Episode(TMDb): |
| 108 | 112 | """ |
| 109 | 113 | return self._request_obj( |
| 110 | 114 | self._urls["translations"] % (tv_id, season_num, episode_num), |
| 111 | key="translations" | |
| 115 | key="translations", | |
| 112 | 116 | ) |
| 113 | 117 | |
| 114 | 118 | def rate_tv_episode(self, tv_id, season_num, episode_num, rating): |
| … | … | @@ -121,9 +125,9 @@ class Episode(TMDb): |
| 121 | 125 | """ |
| 122 | 126 | self._request_obj( |
| 123 | 127 | 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}", | |
| 125 | 129 | method="POST", |
| 126 | json={"value": rating} | |
| 130 | json={"value": rating}, | |
| 127 | 131 | ) |
| 128 | 132 | |
| 129 | 133 | def delete_rating(self, tv_id, season_num, episode_num): |
| … | … | @@ -135,8 +139,8 @@ class Episode(TMDb): |
| 135 | 139 | """ |
| 136 | 140 | self._request_obj( |
| 137 | 141 | 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", | |
| 140 | 144 | ) |
| 141 | 145 | |
| 142 | 146 | def videos(self, tv_id, season_num, episode_num, include_video_language=None): |
| … | … | @@ -150,8 +154,7 @@ class Episode(TMDb): |
| 150 | 154 | """ |
| 151 | 155 | params = "" |
| 152 | 156 | if include_video_language: |
| 153 | params += "&include_video_language=%s" % include_video_language | |
| 157 | params += f"&include_video_language={include_video_language}" | |
| 154 | 158 | 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 | |
| 157 | 160 | ) |
| … | … | @@ -2,9 +2,7 @@ from lib.api.tmdbv3api.tmdb import TMDb |
| 2 | 2 | |
| 3 | 3 | |
| 4 | 4 | class Find(TMDb): |
| 5 | _urls = { | |
| 6 | "find": "/find/%s" | |
| 7 | } | |
| 5 | _urls = {"find": "/find/%s"} | |
| 8 | 6 | |
| 9 | 7 | def find(self, external_id, external_source): |
| 10 | 8 | """ |
| … | … | @@ -15,7 +13,7 @@ class Find(TMDb): |
| 15 | 13 | """ |
| 16 | 14 | return self._request_obj( |
| 17 | 15 | self._urls["find"] % external_id.replace("/", "%2F"), |
| 18 | params="external_source=" + external_source | |
| 16 | params="external_source=" + external_source, | |
| 19 | 17 | ) |
| 20 | 18 | |
| 21 | 19 | def find_by_imdb_id(self, imdb_id): |
| … | … | @@ -2,10 +2,7 @@ from lib.api.tmdbv3api.tmdb import TMDb |
| 2 | 2 | |
| 3 | 3 | |
| 4 | 4 | class 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"} | |
| 9 | 6 | |
| 10 | 7 | def movie_list(self): |
| 11 | 8 | """ |
| … | … | @@ -2,9 +2,7 @@ from lib.api.tmdbv3api.tmdb import TMDb |
| 2 | 2 | |
| 3 | 3 | |
| 4 | 4 | class Group(TMDb): |
| 5 | _urls = { | |
| 6 | "details": "/tv/episode_group/%s" | |
| 7 | } | |
| 5 | _urls = {"details": "/tv/episode_group/%s"} | |
| 8 | 6 | |
| 9 | 7 | def details(self, group_id): |
| 10 | 8 | """ |
| … | … | @@ -1,10 +1,8 @@ |
| 1 | 1 | from lib.api.tmdbv3api.tmdb import TMDb |
| 2 | 2 | |
| 3 | ||
| 3 | 4 | class Keyword(TMDb): |
| 4 | _urls = { | |
| 5 | "details": "/keyword/%s", | |
| 6 | "movies": "/keyword/%s/movies" | |
| 7 | } | |
| 5 | _urls = {"details": "/keyword/%s", "movies": "/keyword/%s/movies"} | |
| 8 | 6 | |
| 9 | 7 | def details(self, keyword_id): |
| 10 | 8 | """ |
| … | … | @@ -1,5 +1,6 @@ |
| 1 | 1 | from lib.api.tmdbv3api.tmdb import TMDb |
| 2 | 2 | |
| 3 | ||
| 3 | 4 | class List(TMDb): |
| 4 | 5 | _urls = { |
| 5 | 6 | "details": "/list/%s", |
| … | … | @@ -26,7 +27,9 @@ class List(TMDb): |
| 26 | 27 | :param movie_id: int |
| 27 | 28 | :return: |
| 28 | 29 | """ |
| 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"] | |
| 30 | 33 | |
| 31 | 34 | def create_list(self, name, description): |
| 32 | 35 | """ |
| … | … | @@ -37,13 +40,9 @@ class List(TMDb): |
| 37 | 40 | """ |
| 38 | 41 | return self._request_obj( |
| 39 | 42 | self._urls["create"], |
| 40 | params="session_id=%s" % self.session_id, | |
| 43 | params=f"session_id={self.session_id}", | |
| 41 | 44 | method="POST", |
| 42 | json={ | |
| 43 | "name": name, | |
| 44 | "description": description, | |
| 45 | "language": self.language | |
| 46 | } | |
| 45 | json={"name": name, "description": description, "language": self.language}, | |
| 47 | 46 | ).list_id |
| 48 | 47 | |
| 49 | 48 | def add_movie(self, list_id, movie_id): |
| … | … | @@ -54,9 +53,9 @@ class List(TMDb): |
| 54 | 53 | """ |
| 55 | 54 | self._request_obj( |
| 56 | 55 | self._urls["add_movie"] % list_id, |
| 57 | params="session_id=%s" % self.session_id, | |
| 56 | params=f"session_id={self.session_id}", | |
| 58 | 57 | method="POST", |
| 59 | json={"media_id": movie_id} | |
| 58 | json={"media_id": movie_id}, | |
| 60 | 59 | ) |
| 61 | 60 | |
| 62 | 61 | def remove_movie(self, list_id, movie_id): |
| … | … | @@ -67,9 +66,9 @@ class List(TMDb): |
| 67 | 66 | """ |
| 68 | 67 | self._request_obj( |
| 69 | 68 | self._urls["remove_movie"] % list_id, |
| 70 | params="session_id=%s" % self.session_id, | |
| 69 | params=f"session_id={self.session_id}", | |
| 71 | 70 | method="POST", |
| 72 | json={"media_id": movie_id} | |
| 71 | json={"media_id": movie_id}, | |
| 73 | 72 | ) |
| 74 | 73 | |
| 75 | 74 | def clear_list(self, list_id): |
| … | … | @@ -79,8 +78,8 @@ class List(TMDb): |
| 79 | 78 | """ |
| 80 | 79 | self._request_obj( |
| 81 | 80 | 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", | |
| 84 | 83 | ) |
| 85 | 84 | |
| 86 | 85 | def delete_list(self, list_id): |
| … | … | @@ -90,6 +89,6 @@ class List(TMDb): |
| 90 | 89 | """ |
| 91 | 90 | self._request_obj( |
| 92 | 91 | 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", | |
| 95 | 94 | ) |
| … | … | @@ -1,5 +1,7 @@ |
| 1 | 1 | import warnings |
| 2 | ||
| 2 | 3 | from lib.api.tmdbv3api.tmdb import TMDb |
| 4 | ||
| 3 | 5 | from .find import Find |
| 4 | 6 | from .search import Search |
| 5 | 7 | |
| … | … | @@ -31,7 +33,11 @@ class Movie(TMDb): |
| 31 | 33 | "upcoming": "/movie/upcoming", |
| 32 | 34 | } |
| 33 | 35 | |
| 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 | ): | |
| 35 | 41 | """ |
| 36 | 42 | Get the primary information about a movie. |
| 37 | 43 | :param movie_id: int |
| … | … | @@ -40,7 +46,7 @@ class Movie(TMDb): |
| 40 | 46 | """ |
| 41 | 47 | return self._request_obj( |
| 42 | 48 | self._urls["details"] % movie_id, |
| 43 | params="append_to_response=%s" % append_to_response | |
| 49 | params=f"append_to_response={append_to_response}", | |
| 44 | 50 | ) |
| 45 | 51 | |
| 46 | 52 | def account_states(self, movie_id): |
| … | … | @@ -52,7 +58,7 @@ class Movie(TMDb): |
| 52 | 58 | """ |
| 53 | 59 | return self._request_obj( |
| 54 | 60 | self._urls["account_states"] % movie_id, |
| 55 | params="session_id=%s" % self.session_id | |
| 61 | params=f"session_id={self.session_id}", | |
| 56 | 62 | ) |
| 57 | 63 | |
| 58 | 64 | def alternative_titles(self, movie_id, country=None): |
| … | … | @@ -64,8 +70,8 @@ class Movie(TMDb): |
| 64 | 70 | """ |
| 65 | 71 | return self._request_obj( |
| 66 | 72 | 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", | |
| 69 | 75 | ) |
| 70 | 76 | |
| 71 | 77 | def changes(self, movie_id, start_date=None, end_date=None, page=1): |
| … | … | @@ -78,16 +84,12 @@ class Movie(TMDb): |
| 78 | 84 | :param page: int |
| 79 | 85 | :return: |
| 80 | 86 | """ |
| 81 | params = "page=%s" % page | |
| 87 | params = f"page={page}" | |
| 82 | 88 | if start_date: |
| 83 | params += "&start_date=%s" % start_date | |
| 89 | params += f"&start_date={start_date}" | |
| 84 | 90 | 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") | |
| 91 | 93 | |
| 92 | 94 | def credits(self, movie_id): |
| 93 | 95 | """ |
| … | … | @@ -104,11 +106,11 @@ class Movie(TMDb): |
| 104 | 106 | :return: |
| 105 | 107 | """ |
| 106 | 108 | return self._request_obj(self._urls["external_ids"] % movie_id) |
| 107 | ||
| 109 | ||
| 108 | 110 | def images(self, movie_id, include_image_language=None): |
| 109 | 111 | """ |
| 110 | 112 | 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. | |
| 112 | 114 | If you want to include a fallback language (especially useful for backdrops) |
| 113 | 115 | you can use the include_image_language parameter. |
| 114 | 116 | This should be a comma separated value like so: include_image_language=en,null. |
| … | … | @@ -118,7 +120,9 @@ class Movie(TMDb): |
| 118 | 120 | """ |
| 119 | 121 | return self._request_obj( |
| 120 | 122 | 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 "", | |
| 122 | 126 | ) |
| 123 | 127 | |
| 124 | 128 | def keywords(self, movie_id): |
| … | … | @@ -127,10 +131,7 @@ class Movie(TMDb): |
| 127 | 131 | :param movie_id: int |
| 128 | 132 | :return: |
| 129 | 133 | """ |
| 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") | |
| 134 | 135 | |
| 135 | 136 | def lists(self, movie_id, page=1): |
| 136 | 137 | """ |
| … | … | @@ -140,9 +141,7 @@ class Movie(TMDb): |
| 140 | 141 | :return: |
| 141 | 142 | """ |
| 142 | 143 | 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" | |
| 146 | 145 | ) |
| 147 | 146 | |
| 148 | 147 | def recommendations(self, movie_id, page=1): |
| … | … | @@ -154,8 +153,8 @@ class Movie(TMDb): |
| 154 | 153 | """ |
| 155 | 154 | return self._request_obj( |
| 156 | 155 | self._urls["recommendations"] % movie_id, |
| 157 | params="page=%s" % page, | |
| 158 | key="results" | |
| 156 | params=f"page={page}", | |
| 157 | key="results", | |
| 159 | 158 | ) |
| 160 | 159 | |
| 161 | 160 | def release_dates(self, movie_id): |
| … | … | @@ -164,10 +163,7 @@ class Movie(TMDb): |
| 164 | 163 | :param movie_id: int |
| 165 | 164 | :return: |
| 166 | 165 | """ |
| 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") | |
| 171 | 167 | |
| 172 | 168 | def reviews(self, movie_id, page=1): |
| 173 | 169 | """ |
| … | … | @@ -177,9 +173,7 @@ class Movie(TMDb): |
| 177 | 173 | :return: |
| 178 | 174 | """ |
| 179 | 175 | 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" | |
| 183 | 177 | ) |
| 184 | 178 | |
| 185 | 179 | def similar(self, movie_id, page=1): |
| … | … | @@ -190,9 +184,7 @@ class Movie(TMDb): |
| 190 | 184 | :return: |
| 191 | 185 | """ |
| 192 | 186 | 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" | |
| 196 | 188 | ) |
| 197 | 189 | |
| 198 | 190 | def translations(self, movie_id): |
| … | … | @@ -201,10 +193,7 @@ class Movie(TMDb): |
| 201 | 193 | :param movie_id: int |
| 202 | 194 | :return: |
| 203 | 195 | """ |
| 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") | |
| 208 | 197 | |
| 209 | 198 | def videos(self, movie_id, page=1): |
| 210 | 199 | """ |
| … | … | @@ -214,9 +203,7 @@ class Movie(TMDb): |
| 214 | 203 | :return: |
| 215 | 204 | """ |
| 216 | 205 | 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" | |
| 220 | 207 | ) |
| 221 | 208 | |
| 222 | 209 | def watch_providers(self, movie_id): |
| … | … | @@ -225,10 +212,7 @@ class Movie(TMDb): |
| 225 | 212 | :param movie_id: int |
| 226 | 213 | :return: |
| 227 | 214 | """ |
| 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") | |
| 232 | 216 | |
| 233 | 217 | def rate_movie(self, movie_id, rating): |
| 234 | 218 | """ |
| … | … | @@ -238,9 +222,9 @@ class Movie(TMDb): |
| 238 | 222 | """ |
| 239 | 223 | self._request_obj( |
| 240 | 224 | self._urls["rate_movie"] % movie_id, |
| 241 | params="session_id=%s" % self.session_id, | |
| 225 | params=f"session_id={self.session_id}", | |
| 242 | 226 | method="POST", |
| 243 | json={"value": rating} | |
| 227 | json={"value": rating}, | |
| 244 | 228 | ) |
| 245 | 229 | |
| 246 | 230 | def delete_rating(self, movie_id): |
| … | … | @@ -250,8 +234,8 @@ class Movie(TMDb): |
| 250 | 234 | """ |
| 251 | 235 | self._request_obj( |
| 252 | 236 | 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", | |
| 255 | 239 | ) |
| 256 | 240 | |
| 257 | 241 | def latest(self): |
| … | … | @@ -268,14 +252,10 @@ class Movie(TMDb): |
| 268 | 252 | :param page: int |
| 269 | 253 | :return: |
| 270 | 254 | """ |
| 271 | params = "page=%s" % page | |
| 255 | params = f"page={page}" | |
| 272 | 256 | if region: |
| 273 | params += "®ion=%s" % region | |
| 274 | return self._request_obj( | |
| 275 | self._urls["now_playing"], | |
| 276 | params=params, | |
| 277 | key="results" | |
| 278 | ) | |
| 257 | params += f"®ion={region}" | |
| 258 | return self._request_obj(self._urls["now_playing"], params=params, key="results") | |
| 279 | 259 | |
| 280 | 260 | def popular(self, region=None, page=1): |
| 281 | 261 | """ |
| … | … | @@ -284,14 +264,10 @@ class Movie(TMDb): |
| 284 | 264 | :param page: int |
| 285 | 265 | :return: |
| 286 | 266 | """ |
| 287 | params = "page=%s" % page | |
| 267 | params = f"page={page}" | |
| 288 | 268 | if region: |
| 289 | params += "®ion=%s" % region | |
| 290 | return self._request_obj( | |
| 291 | self._urls["popular"], | |
| 292 | params=params, | |
| 293 | key="results" | |
| 294 | ) | |
| 269 | params += f"®ion={region}" | |
| 270 | return self._request_obj(self._urls["popular"], params=params, key="results") | |
| 295 | 271 | |
| 296 | 272 | def top_rated(self, region=None, page=1): |
| 297 | 273 | """ |
| … | … | @@ -300,14 +276,10 @@ class Movie(TMDb): |
| 300 | 276 | :param page: int |
| 301 | 277 | :return: |
| 302 | 278 | """ |
| 303 | params = "page=%s" % page | |
| 279 | params = f"page={page}" | |
| 304 | 280 | if region: |
| 305 | params += "®ion=%s" % region | |
| 306 | return self._request_obj( | |
| 307 | self._urls["top_rated"], | |
| 308 | params=params, | |
| 309 | key="results" | |
| 310 | ) | |
| 281 | params += f"®ion={region}" | |
| 282 | return self._request_obj(self._urls["top_rated"], params=params, key="results") | |
| 311 | 283 | |
| 312 | 284 | def upcoming(self, region=None, page=1): |
| 313 | 285 | """ |
| … | … | @@ -316,14 +288,10 @@ class Movie(TMDb): |
| 316 | 288 | :param page: int |
| 317 | 289 | :return: |
| 318 | 290 | """ |
| 319 | params = "page=%s" % page | |
| 291 | params = f"page={page}" | |
| 320 | 292 | if region: |
| 321 | params += "®ion=%s" % region | |
| 322 | return self._request_obj( | |
| 323 | self._urls["upcoming"], | |
| 324 | params=params, | |
| 325 | key="results" | |
| 326 | ) | |
| 293 | params += f"®ion={region}" | |
| 294 | return self._request_obj(self._urls["upcoming"], params=params, key="results") | |
| 327 | 295 | |
| 328 | 296 | def search(self, term, page=1): |
| 329 | 297 | """ |
| … | … | @@ -332,8 +300,10 @@ class Movie(TMDb): |
| 332 | 300 | :param page: int |
| 333 | 301 | :return: |
| 334 | 302 | """ |
| 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 | ) | |
| 337 | 307 | return Search().movies(term, page=page) |
| 338 | 308 | |
| 339 | 309 | def external(self, external_id, external_source): |
| … | … | @@ -343,6 +313,8 @@ class Movie(TMDb): |
| 343 | 313 | :param external_source str |
| 344 | 314 | :return: |
| 345 | 315 | """ |
| 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 | ) | |
| 348 | 320 | return Find().find(external_id, external_source) |
| … | … | @@ -5,7 +5,7 @@ class Network(TMDb): |
| 5 | 5 | _urls = { |
| 6 | 6 | "details": "/network/%s", |
| 7 | 7 | "alternative_names": "/network/%s/alternative_names", |
| 8 | "images": "/network/%s/images" | |
| 8 | "images": "/network/%s/images", | |
| 9 | 9 | } |
| 10 | 10 | |
| 11 | 11 | def details(self, network_id): |
| … | … | @@ -22,10 +22,7 @@ class Network(TMDb): |
| 22 | 22 | :param network_id: int |
| 23 | 23 | :return: |
| 24 | 24 | """ |
| 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") | |
| 29 | 26 | |
| 30 | 27 | def images(self, network_id): |
| 31 | 28 | """ |
| … | … | @@ -33,7 +30,4 @@ class Network(TMDb): |
| 33 | 30 | :param network_id: int |
| 34 | 31 | :return: |
| 35 | 32 | """ |
| 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") |
| … | … | @@ -1,5 +1,7 @@ |
| 1 | 1 | import warnings |
| 2 | ||
| 2 | 3 | from lib.api.tmdbv3api.tmdb import TMDb |
| 4 | ||
| 3 | 5 | from .search import Search |
| 4 | 6 | |
| 5 | 7 | |
| … | … | @@ -28,7 +30,7 @@ class Person(TMDb): |
| 28 | 30 | """ |
| 29 | 31 | return self._request_obj( |
| 30 | 32 | self._urls["details"] % person_id, |
| 31 | params="append_to_response=%s" % append_to_response | |
| 33 | params=f"append_to_response={append_to_response}", | |
| 32 | 34 | ) |
| 33 | 35 | |
| 34 | 36 | def changes(self, person_id, start_date=None, end_date=None, page=1): |
| … | … | @@ -41,16 +43,12 @@ class Person(TMDb): |
| 41 | 43 | :param page: int |
| 42 | 44 | :return: |
| 43 | 45 | """ |
| 44 | params = "page=%s" % page | |
| 46 | params = f"page={page}" | |
| 45 | 47 | if start_date: |
| 46 | params += "&start_date=%s" % start_date | |
| 48 | params += f"&start_date={start_date}" | |
| 47 | 49 | 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") | |
| 54 | 52 | |
| 55 | 53 | def movie_credits(self, person_id): |
| 56 | 54 | """ |
| … | … | @@ -91,10 +89,7 @@ class Person(TMDb): |
| 91 | 89 | :param person_id: int |
| 92 | 90 | :return: |
| 93 | 91 | """ |
| 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") | |
| 98 | 93 | |
| 99 | 94 | def tagged_images(self, person_id, page=1): |
| 100 | 95 | """ |
| … | … | @@ -105,8 +100,8 @@ class Person(TMDb): |
| 105 | 100 | """ |
| 106 | 101 | return self._request_obj( |
| 107 | 102 | self._urls["tagged_images"] % person_id, |
| 108 | params="page=%s" % page, | |
| 109 | key="results" | |
| 103 | params=f"page={page}", | |
| 104 | key="results", | |
| 110 | 105 | ) |
| 111 | 106 | |
| 112 | 107 | def translations(self, person_id): |
| … | … | @@ -115,10 +110,7 @@ class Person(TMDb): |
| 115 | 110 | :param person_id: int |
| 116 | 111 | :return: |
| 117 | 112 | """ |
| 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") | |
| 122 | 114 | |
| 123 | 115 | def latest(self): |
| 124 | 116 | """ |
| … | … | @@ -134,9 +126,7 @@ class Person(TMDb): |
| 134 | 126 | :return: |
| 135 | 127 | """ |
| 136 | 128 | 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" | |
| 140 | 130 | ) |
| 141 | 131 | |
| 142 | 132 | def search(self, term, page=1): |
| … | … | @@ -146,6 +136,8 @@ class Person(TMDb): |
| 146 | 136 | :param page: int |
| 147 | 137 | :return: |
| 148 | 138 | """ |
| 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 | ) | |
| 151 | 143 | return Search().people(term, page=page) |
| … | … | @@ -13,10 +13,7 @@ class Provider(TMDb): |
| 13 | 13 | Returns a list of all of the countries we have watch provider (OTT/streaming) data for. |
| 14 | 14 | :return: |
| 15 | 15 | """ |
| 16 | return self._request_obj( | |
| 17 | self._urls["regions"], | |
| 18 | key="results" | |
| 19 | ) | |
| 16 | return self._request_obj(self._urls["regions"], key="results") | |
| 20 | 17 | |
| 21 | 18 | def movie_providers(self, region=None): |
| 22 | 19 | """ |
| … | … | @@ -25,8 +22,8 @@ class Provider(TMDb): |
| 25 | 22 | """ |
| 26 | 23 | return self._request_obj( |
| 27 | 24 | 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", | |
| 30 | 27 | ) |
| 31 | 28 | |
| 32 | 29 | def tv_providers(self, region=None): |
| … | … | @@ -36,6 +33,6 @@ class Provider(TMDb): |
| 36 | 33 | """ |
| 37 | 34 | return self._request_obj( |
| 38 | 35 | 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", | |
| 41 | 38 | ) |
| … | … | @@ -26,8 +26,8 @@ class Search(TMDb): |
| 26 | 26 | """ |
| 27 | 27 | return self._request_obj( |
| 28 | 28 | 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", | |
| 31 | 31 | ) |
| 32 | 32 | |
| 33 | 33 | def collections(self, term, page=1): |
| … | … | @@ -39,8 +39,8 @@ class Search(TMDb): |
| 39 | 39 | """ |
| 40 | 40 | return self._request_obj( |
| 41 | 41 | 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", | |
| 44 | 44 | ) |
| 45 | 45 | |
| 46 | 46 | def keywords(self, term, page=1): |
| … | … | @@ -52,11 +52,20 @@ class Search(TMDb): |
| 52 | 52 | """ |
| 53 | 53 | return self._request_obj( |
| 54 | 54 | 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", | |
| 57 | 57 | ) |
| 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 | ): | |
| 60 | 69 | """ |
| 61 | 70 | Search for movies. |
| 62 | 71 | :param term: str |
| … | … | @@ -67,21 +76,17 @@ class Search(TMDb): |
| 67 | 76 | :param page: int |
| 68 | 77 | :return: |
| 69 | 78 | """ |
| 70 | params = "query=%s&page=%s" % (quote(term), page) | |
| 79 | params = f"query={quote(term)}&page={page}" | |
| 71 | 80 | 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" | |
| 73 | 82 | if region is not None: |
| 74 | params += "®ion=%s" % quote(region) | |
| 83 | params += f"®ion={quote(region)}" | |
| 75 | 84 | if year is not None: |
| 76 | params += "&year=%s" % year | |
| 85 | params += f"&year={year}" | |
| 77 | 86 | 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") | |
| 85 | 90 | |
| 86 | 91 | def multi(self, term, adult=None, region=None, page=1): |
| 87 | 92 | """ |
| … | … | @@ -93,16 +98,12 @@ class Search(TMDb): |
| 93 | 98 | :param page: int |
| 94 | 99 | :return: |
| 95 | 100 | """ |
| 96 | params = "query=%s&page=%s" % (quote(term), page) | |
| 101 | params = f"query={quote(term)}&page={page}" | |
| 97 | 102 | 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" | |
| 99 | 104 | if region is not None: |
| 100 | params += "®ion=%s" % quote(region) | |
| 101 | return self._request_obj( | |
| 102 | self._urls["multi"], | |
| 103 | params=params, | |
| 104 | key="results" | |
| 105 | ) | |
| 105 | params += f"®ion={quote(region)}" | |
| 106 | return self._request_obj(self._urls["multi"], params=params, key="results") | |
| 106 | 107 | |
| 107 | 108 | def people(self, term, adult=None, region=None, page=1): |
| 108 | 109 | """ |
| … | … | @@ -113,18 +114,21 @@ class Search(TMDb): |
| 113 | 114 | :param page: int |
| 114 | 115 | :return: |
| 115 | 116 | """ |
| 116 | params = "query=%s&page=%s" % (quote(term), page) | |
| 117 | params = f"query={quote(term)}&page={page}" | |
| 117 | 118 | 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" | |
| 119 | 120 | if region is not None: |
| 120 | params += "®ion=%s" % quote(region) | |
| 121 | return self._request_obj( | |
| 122 | self._urls["people"], | |
| 123 | params=params, | |
| 124 | key="results" | |
| 125 | ) | |
| 121 | params += f"®ion={quote(region)}" | |
| 122 | return self._request_obj(self._urls["people"], params=params, key="results") | |
| 126 | 123 | |
| 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 | ): | |
| 128 | 132 | """ |
| 129 | 133 | Search for a TV show. |
| 130 | 134 | :param term: str |
| … | … | @@ -133,14 +137,10 @@ class Search(TMDb): |
| 133 | 137 | :param page: int |
| 134 | 138 | :return: |
| 135 | 139 | """ |
| 136 | params = "query=%s&page=%s" % (quote(term), page) | |
| 140 | params = f"query={quote(term)}&page={page}" | |
| 137 | 141 | 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" | |
| 139 | 143 | 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") |
| … | … | @@ -14,7 +14,12 @@ class Season(TMDb): |
| 14 | 14 | "videos": "/tv/%s/season/%s/videos", |
| 15 | 15 | } |
| 16 | 16 | |
| 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 | ): | |
| 18 | 23 | """ |
| 19 | 24 | Get the TV season details by id. |
| 20 | 25 | :param tv_id: int |
| … | … | @@ -24,7 +29,7 @@ class Season(TMDb): |
| 24 | 29 | """ |
| 25 | 30 | return self._request_obj( |
| 26 | 31 | 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}", | |
| 28 | 33 | ) |
| 29 | 34 | |
| 30 | 35 | def account_states(self, tv_id, season_num): |
| … | … | @@ -36,8 +41,8 @@ class Season(TMDb): |
| 36 | 41 | """ |
| 37 | 42 | return self._request_obj( |
| 38 | 43 | 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", | |
| 41 | 46 | ) |
| 42 | 47 | |
| 43 | 48 | def aggregate_credits(self, tv_id, season_num): |
| … | … | @@ -61,16 +66,12 @@ class Season(TMDb): |
| 61 | 66 | :param page: int |
| 62 | 67 | :return: |
| 63 | 68 | """ |
| 64 | params = "page=%s" % page | |
| 69 | params = f"page={page}" | |
| 65 | 70 | if start_date: |
| 66 | params += "&start_date=%s" % start_date | |
| 71 | params += f"&start_date={start_date}" | |
| 67 | 72 | 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") | |
| 74 | 75 | |
| 75 | 76 | def credits(self, tv_id, season_num): |
| 76 | 77 | """ |
| … | … | @@ -100,8 +101,10 @@ class Season(TMDb): |
| 100 | 101 | """ |
| 101 | 102 | return self._request_obj( |
| 102 | 103 | 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", | |
| 105 | 108 | ) |
| 106 | 109 | |
| 107 | 110 | def translations(self, tv_id, season_num): |
| … | … | @@ -111,8 +114,7 @@ class Season(TMDb): |
| 111 | 114 | :param season_num: int |
| 112 | 115 | """ |
| 113 | 116 | 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" | |
| 116 | 118 | ) |
| 117 | 119 | |
| 118 | 120 | def videos(self, tv_id, season_num, include_video_language=None, page=1): |
| … | … | @@ -124,10 +126,7 @@ class Season(TMDb): |
| 124 | 126 | :param page: int |
| 125 | 127 | :return: |
| 126 | 128 | """ |
| 127 | params = "page=%s" % page | |
| 129 | params = f"page={page}" | |
| 128 | 130 | 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) |
| … | … | @@ -6,8 +6,7 @@ class Trending(TMDb): |
| 6 | 6 | |
| 7 | 7 | def _trending(self, media_type="all", time_window="day", page=1): |
| 8 | 8 | 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}" | |
| 11 | 10 | ) |
| 12 | 11 | |
| 13 | 12 | def all_day(self, page=1): |
| … | … | @@ -1,11 +1,13 @@ |
| 1 | 1 | import warnings |
| 2 | ||
| 2 | 3 | from lib.api.tmdbv3api.tmdb import TMDb |
| 4 | ||
| 3 | 5 | from .search import Search |
| 4 | 6 | |
| 5 | 7 | try: |
| 6 | 8 | from urllib import quote |
| 7 | 9 | except ImportError: |
| 8 | from urllib.parse import quote | |
| 10 | pass | |
| 9 | 11 | |
| 10 | 12 | |
| 11 | 13 | class TV(TMDb): |
| … | … | @@ -37,7 +39,11 @@ class TV(TMDb): |
| 37 | 39 | "top_rated": "/tv/top_rated", |
| 38 | 40 | } |
| 39 | 41 | |
| 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 | ): | |
| 41 | 47 | """ |
| 42 | 48 | Get the primary TV show details by id. |
| 43 | 49 | :param tv_id: int |
| … | … | @@ -46,7 +52,7 @@ class TV(TMDb): |
| 46 | 52 | """ |
| 47 | 53 | return self._request_obj( |
| 48 | 54 | self._urls["details"] % tv_id, |
| 49 | params="append_to_response=%s" % append_to_response, | |
| 55 | params=f"append_to_response={append_to_response}", | |
| 50 | 56 | ) |
| 51 | 57 | |
| 52 | 58 | def account_states(self, tv_id): |
| … | … | @@ -58,7 +64,7 @@ class TV(TMDb): |
| 58 | 64 | """ |
| 59 | 65 | return self._request_obj( |
| 60 | 66 | self._urls["account_states"] % tv_id, |
| 61 | params="session_id=%s" % self.session_id | |
| 67 | params=f"session_id={self.session_id}", | |
| 62 | 68 | ) |
| 63 | 69 | |
| 64 | 70 | def aggregate_credits(self, tv_id): |
| … | … | @@ -77,10 +83,7 @@ class TV(TMDb): |
| 77 | 83 | :param tv_id: int |
| 78 | 84 | :return: |
| 79 | 85 | """ |
| 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") | |
| 84 | 87 | |
| 85 | 88 | def changes(self, tv_id, start_date=None, end_date=None, page=1): |
| 86 | 89 | """ |
| … | … | @@ -91,16 +94,12 @@ class TV(TMDb): |
| 91 | 94 | :param end_date: str |
| 92 | 95 | :param page: int |
| 93 | 96 | """ |
| 94 | params = "page=%s" % page | |
| 97 | params = f"page={page}" | |
| 95 | 98 | if start_date: |
| 96 | params += "&start_date=%s" % start_date | |
| 99 | params += f"&start_date={start_date}" | |
| 97 | 100 | 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") | |
| 104 | 103 | |
| 105 | 104 | def content_ratings(self, tv_id): |
| 106 | 105 | """ |
| … | … | @@ -108,10 +107,7 @@ class TV(TMDb): |
| 108 | 107 | :param tv_id: int |
| 109 | 108 | :return: |
| 110 | 109 | """ |
| 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") | |
| 115 | 111 | |
| 116 | 112 | def credits(self, tv_id): |
| 117 | 113 | """ |
| … | … | @@ -127,10 +123,7 @@ class TV(TMDb): |
| 127 | 123 | :param tv_id: int |
| 128 | 124 | :return: |
| 129 | 125 | """ |
| 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") | |
| 134 | 127 | |
| 135 | 128 | def external_ids(self, tv_id): |
| 136 | 129 | """ |
| … | … | @@ -153,7 +146,9 @@ class TV(TMDb): |
| 153 | 146 | """ |
| 154 | 147 | return self._request_obj( |
| 155 | 148 | 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 "", | |
| 157 | 152 | ) |
| 158 | 153 | |
| 159 | 154 | def keywords(self, tv_id): |
| … | … | @@ -162,10 +157,7 @@ class TV(TMDb): |
| 162 | 157 | :param tv_id: int |
| 163 | 158 | :return: |
| 164 | 159 | """ |
| 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") | |
| 169 | 161 | |
| 170 | 162 | def recommendations(self, tv_id, page=1): |
| 171 | 163 | """ |
| … | … | @@ -176,8 +168,8 @@ class TV(TMDb): |
| 176 | 168 | """ |
| 177 | 169 | return self._request_obj( |
| 178 | 170 | self._urls["recommendations"] % tv_id, |
| 179 | params="page=%s" % page, | |
| 180 | key="results" | |
| 171 | params=f"page={page}", | |
| 172 | key="results", | |
| 181 | 173 | ) |
| 182 | 174 | |
| 183 | 175 | def reviews(self, tv_id, page=1): |
| … | … | @@ -188,9 +180,7 @@ class TV(TMDb): |
| 188 | 180 | :return: |
| 189 | 181 | """ |
| 190 | 182 | 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" | |
| 194 | 184 | ) |
| 195 | 185 | |
| 196 | 186 | def screened_theatrically(self, tv_id): |
| … | … | @@ -199,10 +189,7 @@ class TV(TMDb): |
| 199 | 189 | :param tv_id: int |
| 200 | 190 | :return: |
| 201 | 191 | """ |
| 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") | |
| 206 | 193 | |
| 207 | 194 | def similar(self, tv_id, page=1): |
| 208 | 195 | """ |
| … | … | @@ -212,9 +199,7 @@ class TV(TMDb): |
| 212 | 199 | :return: |
| 213 | 200 | """ |
| 214 | 201 | 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" | |
| 218 | 203 | ) |
| 219 | 204 | |
| 220 | 205 | def translations(self, tv_id): |
| … | … | @@ -223,10 +208,7 @@ class TV(TMDb): |
| 223 | 208 | :param tv_id: int |
| 224 | 209 | :return: |
| 225 | 210 | """ |
| 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") | |
| 230 | 212 | |
| 231 | 213 | def videos(self, tv_id, include_video_language=None, page=1): |
| 232 | 214 | """ |
| … | … | @@ -236,13 +218,10 @@ class TV(TMDb): |
| 236 | 218 | :param page: int |
| 237 | 219 | :return: |
| 238 | 220 | """ |
| 239 | params = "page=%s" % page | |
| 221 | params = f"page={page}" | |
| 240 | 222 | 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) | |
| 246 | 225 | |
| 247 | 226 | def watch_providers(self, tv_id): |
| 248 | 227 | """ |
| … | … | @@ -250,10 +229,7 @@ class TV(TMDb): |
| 250 | 229 | :param tv_id: int |
| 251 | 230 | :return: |
| 252 | 231 | """ |
| 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") | |
| 257 | 233 | |
| 258 | 234 | def rate_tv_show(self, tv_id, rating): |
| 259 | 235 | """ |
| … | … | @@ -263,9 +239,9 @@ class TV(TMDb): |
| 263 | 239 | """ |
| 264 | 240 | self._request_obj( |
| 265 | 241 | self._urls["rate_tv_show"] % tv_id, |
| 266 | params="session_id=%s" % self.session_id, | |
| 242 | params=f"session_id={self.session_id}", | |
| 267 | 243 | method="POST", |
| 268 | json={"value": rating} | |
| 244 | json={"value": rating}, | |
| 269 | 245 | ) |
| 270 | 246 | |
| 271 | 247 | def delete_rating(self, tv_id): |
| … | … | @@ -275,8 +251,8 @@ class TV(TMDb): |
| 275 | 251 | """ |
| 276 | 252 | self._request_obj( |
| 277 | 253 | 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", | |
| 280 | 256 | ) |
| 281 | 257 | |
| 282 | 258 | def latest(self): |
| … | … | @@ -294,9 +270,7 @@ class TV(TMDb): |
| 294 | 270 | :return: |
| 295 | 271 | """ |
| 296 | 272 | 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" | |
| 300 | 274 | ) |
| 301 | 275 | |
| 302 | 276 | def on_the_air(self, page=1): |
| … | … | @@ -306,9 +280,7 @@ class TV(TMDb): |
| 306 | 280 | :return: |
| 307 | 281 | """ |
| 308 | 282 | 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" | |
| 312 | 284 | ) |
| 313 | 285 | |
| 314 | 286 | def popular(self, page=1): |
| … | … | @@ -318,9 +290,7 @@ class TV(TMDb): |
| 318 | 290 | :return: |
| 319 | 291 | """ |
| 320 | 292 | 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" | |
| 324 | 294 | ) |
| 325 | 295 | |
| 326 | 296 | def top_rated(self, page=1): |
| … | … | @@ -330,9 +300,7 @@ class TV(TMDb): |
| 330 | 300 | :return: |
| 331 | 301 | """ |
| 332 | 302 | 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" | |
| 336 | 304 | ) |
| 337 | 305 | |
| 338 | 306 | def search(self, term, page=1): |
| … | … | @@ -342,6 +310,8 @@ class TV(TMDb): |
| 342 | 310 | :param page: |
| 343 | 311 | :return: |
| 344 | 312 | """ |
| 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 | ) | |
| 347 | 317 | return Search().tv_shows(term, page=page) |
| … | … | @@ -1,13 +1,9 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | ||
| 3 | 1 | import logging |
| 4 | 2 | import os |
| 5 | 3 | import time |
| 6 | 4 | |
| 7 | 5 | import requests |
| 8 | 6 | |
| 9 | from lib.utils.kodi.utils import kodilog | |
| 10 | ||
| 11 | 7 | from .as_obj import AsObj |
| 12 | 8 | from .exceptions import TMDbException |
| 13 | 9 | |
| … | … | @@ -19,7 +15,7 @@ except ImportError: |
| 19 | 15 | logger = logging.getLogger(__name__) |
| 20 | 16 | |
| 21 | 17 | |
| 22 | class TMDb(object): | |
| 18 | class TMDb: | |
| 23 | 19 | _session = None |
| 24 | 20 | TMDB_API_KEY = "TMDB_API_KEY" |
| 25 | 21 | TMDB_LANGUAGE = "TMDB_LANGUAGE" |
| … | … | @@ -178,13 +174,9 @@ class TMDb(object): |
| 178 | 174 | if self.wait_on_rate_limit: |
| 179 | 175 | logger.warning("Rate limit reached. Sleeping for: %d" % sleep_time) |
| 180 | 176 | 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) | |
| 184 | 178 | 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) | |
| 188 | 180 | |
| 189 | 181 | json = req.json() |
| 190 | 182 |
| … | … | @@ -1,15 +1,19 @@ |
| 1 | 1 | import datetime |
| 2 | 2 | |
| 3 | years_tvshows = [{'name': '2024', 'id': 2024}] | |
| 3 | years_tvshows = [{"name": "2024", "id": 2024}] | |
| 4 | ||
| 4 | 5 | |
| 5 | 6 | def get_dates(days, reverse=True): |
| 6 | 7 | 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") | |
| 11 | 12 | return str(current_date), new_date |
| 12 | 13 | |
| 14 | ||
| 13 | 15 | def 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() |
| … | … | @@ -1,106 +1,104 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | 1 | import ast |
| 2 | import contextlib | |
| 3 | 3 | import os |
| 4 | import time | |
| 5 | 4 | import sqlite3 as database |
| 6 | from 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 | ||
| 16 | import xbmcaddon | |
| 17 | from xbmcvfs import translatePath | |
| 18 | ||
| 19 | ||
| 20 | userdata_path = translatePath(xbmcaddon.Addon().getAddonInfo("profile")) | |
| 21 | databases_path = os.path.join(userdata_path, "databases/") | |
| 22 | database_path_raw = os.path.join(userdata_path, "databases") | |
| 23 | trakt_db = translatePath(os.path.join(database_path_raw, "traktcache.db")) | |
| 24 | lists_db = translatePath(os.path.join(database_path_raw, "lists.db")) | |
| 25 | maincache_db = translatePath(os.path.join(database_path_raw, "maincache.db")) | |
| 26 | paginator_db = translatePath(os.path.join(database_path_raw, "paginator.db")) | |
| 27 | database_timeout = 20 | |
| 28 | ||
| 29 | ||
| 30 | database_locations = { | |
| 31 | "trakt_db": trakt_db, | |
| 32 | "lists_db": lists_db, | |
| 33 | "maincache_db": maincache_db, | |
| 34 | "paginator_db": paginator_db, | |
| 35 | } | |
| 36 | ||
| 37 | integrity_check = { | |
| 38 | "trakt_db": ("trakt_data", "watched_status", "progress"), | |
| 39 | "maincache_db": ("maincache",), | |
| 40 | "lists_db": ("lists",), | |
| 41 | } | |
| 42 | ||
| 43 | table_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, \ | |
| 50 | last_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 | ||
| 64 | media_prop = "jacktook.%s" | |
| 65 | BASE_GET = "SELECT expires, data FROM %s WHERE id = ?" | |
| 66 | BASE_SET = "INSERT OR REPLACE INTO %s(id, data, expires) VALUES (?, ?, ?)" | |
| 67 | BASE_DELETE = "DELETE FROM %s WHERE id = ?" | |
| 68 | ||
| 69 | ||
| 70 | def 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 | ||
| 77 | def 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 | ||
| 86 | def 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 | ||
| 98 | def get_timestamp(offset=0): | |
| 99 | # Offset is in HOURS multiply by 3600 to get seconds | |
| 100 | return int(time.time()) + (offset * 3600) | |
| 101 | ||
| 102 | ||
| 103 | def check_databases_integrity(): | |
| 5 | import time | |
| 6 | ||
| 7 | import xbmcaddon | |
| 8 | from xbmcvfs import translatePath | |
| 9 | ||
| 10 | from 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 | ||
| 20 | userdata_path = translatePath(xbmcaddon.Addon().getAddonInfo("profile")) | |
| 21 | databases_path = os.path.join(userdata_path, "databases/") | |
| 22 | database_path_raw = os.path.join(userdata_path, "databases") | |
| 23 | trakt_db = translatePath(os.path.join(database_path_raw, "traktcache.db")) | |
| 24 | lists_db = translatePath(os.path.join(database_path_raw, "lists.db")) | |
| 25 | maincache_db = translatePath(os.path.join(database_path_raw, "maincache.db")) | |
| 26 | paginator_db = translatePath(os.path.join(database_path_raw, "paginator.db")) | |
| 27 | database_timeout = 20 | |
| 28 | ||
| 29 | ||
| 30 | database_locations = { | |
| 31 | "trakt_db": trakt_db, | |
| 32 | "lists_db": lists_db, | |
| 33 | "maincache_db": maincache_db, | |
| 34 | "paginator_db": paginator_db, | |
| 35 | } | |
| 36 | ||
| 37 | integrity_check = { | |
| 38 | "trakt_db": ("trakt_data", "watched_status", "progress"), | |
| 39 | "maincache_db": ("maincache",), | |
| 40 | "lists_db": ("lists",), | |
| 41 | } | |
| 42 | ||
| 43 | table_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, \ | |
| 50 | last_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 | ||
| 62 | media_prop = "jacktook.%s" | |
| 63 | BASE_GET = "SELECT expires, data FROM %s WHERE id = ?" | |
| 64 | BASE_SET = "INSERT OR REPLACE INTO %s(id, data, expires) VALUES (?, ?, ?)" | |
| 65 | BASE_DELETE = "DELETE FROM %s WHERE id = ?" | |
| 66 | ||
| 67 | ||
| 68 | def 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 | ||
| 75 | def 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 | ||
| 84 | def 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 | ||
| 96 | def get_timestamp(offset=0): | |
| 97 | # Offset is in HOURS multiply by 3600 to get seconds | |
| 98 | return int(time.time()) + (offset * 3600) | |
| 99 | ||
| 100 | ||
| 101 | def check_databases_integrity(): | |
| 104 | 102 | def _process(database_name, tables): |
| 105 | 103 | database_location = database_locations[database_name] |
| 106 | 104 | dbcon = None |
| … | … | @@ -108,42 +106,39 @@ def check_databases_integrity(): |
| 108 | 106 | dbcon = database.connect(database_location) |
| 109 | 107 | for db_table in tables: |
| 110 | 108 | 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 | ||
| 135 | class 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 | ||
| 130 | class BaseCache: | |
| 131 | def __init__(self, dbfile, table): | |
| 132 | self.table = table | |
| 133 | self.dbfile = dbfile | |
| 134 | ||
| 140 | 135 | def get(self, string): |
| 141 | 136 | result = None |
| 142 | 137 | 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) | |
| 147 | 142 | cache_data = dbcon.execute(BASE_GET % self.table, (string,)).fetchone() |
| 148 | 143 | if cache_data: |
| 149 | 144 | if cache_data[0] > current_time: |
| … | … | @@ -151,39 +146,37 @@ class BaseCache(object): |
| 151 | 146 | self.set_memory_cache(result, string, cache_data[0]) |
| 152 | 147 | else: |
| 153 | 148 | 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: | |
| 160 | 155 | dbcon = connect_database(self.dbfile) |
| 161 | 156 | 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))) | |
| 165 | 158 | self.set_memory_cache(data, string, int(expires)) |
| 166 | except: | |
| 159 | except Exception: | |
| 167 | 160 | 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 | |
| 171 | 164 | try: |
| 172 | 165 | cachedata = get_property(media_prop % string) |
| 173 | 166 | if cachedata: |
| 174 | 167 | cachedata = self._deserialize(cachedata) |
| 175 | 168 | if cachedata[0] > current_time: |
| 176 | 169 | 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): | |
| 182 | 175 | try: |
| 183 | 176 | cachedata = (expires, data) |
| 184 | 177 | cachedata_repr = self._serialize(cachedata) |
| 185 | 178 | set_property(media_prop % string, cachedata_repr) |
| 186 | except: | |
| 179 | except Exception: | |
| 187 | 180 | pass |
| 188 | 181 | |
| 189 | 182 | @staticmethod |
| … | … | @@ -193,17 +186,17 @@ class BaseCache(object): |
| 193 | 186 | @staticmethod |
| 194 | 187 | def _deserialize(data): |
| 195 | 188 | 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) |
| … | … | @@ -1,6 +1,5 @@ |
| 1 | 1 | from lib.api.trakt.base_cache import BaseCache, get_timestamp |
| 2 | 2 | |
| 3 | ||
| 4 | 3 | GET_ALL = "SELECT id FROM lists" |
| 5 | 4 | DELETE_ALL = "DELETE FROM lists" |
| 6 | 5 | DELETE_PREFIX = 'DELETE FROM lists WHERE id LIKE "%s%%"' |
| … | … | @@ -19,7 +18,7 @@ class ListsCache(BaseCache): |
| 19 | 18 | dbcon.execute(DELETE_ALL) |
| 20 | 19 | dbcon.execute("VACUUM") |
| 21 | 20 | return True |
| 22 | except: | |
| 21 | except Exception: | |
| 23 | 22 | return False |
| 24 | 23 | |
| 25 | 24 | def delete_prefix(self, prefix): |
| … | … | @@ -32,7 +31,7 @@ class ListsCache(BaseCache): |
| 32 | 31 | dbcon.execute(DELETE_PREFIX % prefix) |
| 33 | 32 | dbcon.execute("VACUUM") |
| 34 | 33 | return True |
| 35 | except: | |
| 34 | except Exception: | |
| 36 | 35 | return False |
| 37 | 36 | |
| 38 | 37 | def clean_database(self): |
| … | … | @@ -41,7 +40,7 @@ class ListsCache(BaseCache): |
| 41 | 40 | dbcon.execute(CLEAN, (get_timestamp(),)) |
| 42 | 41 | dbcon.execute("VACUUM") |
| 43 | 42 | return True |
| 44 | except: | |
| 43 | except Exception: | |
| 45 | 44 | return False |
| 46 | 45 | |
| 47 | 46 |
| … | … | @@ -1,6 +1,5 @@ |
| 1 | 1 | from lib.api.trakt.base_cache import BaseCache, get_timestamp |
| 2 | 2 | |
| 3 | ||
| 4 | 3 | GET_ALL = "SELECT id FROM maincache" |
| 5 | 4 | DELETE_ALL = "DELETE FROM maincache" |
| 6 | 5 | LIKE_SELECT = "SELECT id from maincache where id LIKE %s" |
| … | … | @@ -20,14 +19,13 @@ class MainCache(BaseCache): |
| 20 | 19 | dbcon.execute(DELETE_ALL) |
| 21 | 20 | dbcon.execute("VACUUM") |
| 22 | 21 | return True |
| 23 | except: | |
| 22 | except Exception: | |
| 24 | 23 | return False |
| 25 | 24 | |
| 26 | 25 | def delete_all_folderscrapers(self): |
| 27 | 26 | dbcon = self.manual_connect("maincache_db") |
| 28 | 27 | 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() | |
| 31 | 29 | ] |
| 32 | 30 | if not remove_list: |
| 33 | 31 | return True |
| … | … | @@ -37,7 +35,7 @@ class MainCache(BaseCache): |
| 37 | 35 | for item in remove_list: |
| 38 | 36 | self.delete_memory_cache(str(item)) |
| 39 | 37 | return True |
| 40 | except: | |
| 38 | except Exception: | |
| 41 | 39 | return False |
| 42 | 40 | |
| 43 | 41 | def clean_database(self): |
| … | … | @@ -46,7 +44,7 @@ class MainCache(BaseCache): |
| 46 | 44 | dbcon.execute(CLEAN, (get_timestamp(),)) |
| 47 | 45 | dbcon.execute("VACUUM") |
| 48 | 46 | return True |
| 49 | except: | |
| 47 | except Exception: | |
| 50 | 48 | return False |
| 51 | 49 | |
| 52 | 50 |
| … | … | @@ -1,16 +1,15 @@ |
| 1 | import json | |
| 1 | import contextlib | |
| 2 | 2 | import datetime |
| 3 | import json | |
| 3 | 4 | import random |
| 4 | 5 | import time |
| 5 | 6 | from typing import Any, Dict |
| 7 | ||
| 6 | 8 | import requests |
| 7 | from lib.utils.general.utils import set_pluging_category | |
| 8 | 9 | import xbmc |
| 9 | 10 | |
| 10 | from lib.api.trakt.lists_cache import lists_cache | |
| 11 | 11 | from lib.api.trakt.base_cache import BASE_DELETE, connect_database |
| 12 | from lib.api.trakt.lists_cache import lists_cache_object | |
| 13 | from lib.api.trakt.main_cache import cache_object | |
| 12 | from lib.api.trakt.lists_cache import lists_cache, lists_cache_object | |
| 14 | 13 | from lib.api.trakt.main_cache import cache_object |
| 15 | 14 | from lib.api.trakt.trakt_cache import ( |
| 16 | 15 | cache_trakt_object, |
| … | … | @@ -24,6 +23,13 @@ from lib.api.trakt.trakt_utils import clean_ids, sort_for_article, sort_list |
| 24 | 23 | from lib.gui.qr_progress_dialog import QRProgressDialog |
| 25 | 24 | from lib.jacktook.utils import ADDON_PATH |
| 26 | 25 | from lib.utils.debrid.qrcode_utils import make_qrcode |
| 26 | from lib.utils.general.utils import set_pluging_category | |
| 27 | from lib.utils.kodi.settings import ( | |
| 28 | EMPTY_USER, | |
| 29 | trakt_client, | |
| 30 | trakt_lists_sort_order, | |
| 31 | trakt_secret, | |
| 32 | ) | |
| 27 | 33 | from lib.utils.kodi.utils import ( |
| 28 | 34 | copy2clip, |
| 29 | 35 | get_datetime, |
| … | … | @@ -33,15 +39,8 @@ from lib.utils.kodi.utils import ( |
| 33 | 39 | set_property, |
| 34 | 40 | set_setting, |
| 35 | 41 | sleep, |
| 36 | progressDialog, | |
| 37 | 42 | translation, |
| 38 | 43 | ) |
| 39 | from lib.utils.kodi.settings import ( | |
| 40 | EMPTY_USER, | |
| 41 | trakt_client, | |
| 42 | trakt_lists_sort_order, | |
| 43 | trakt_secret, | |
| 44 | ) | |
| 45 | 44 | |
| 46 | 45 | |
| 47 | 46 | class TraktBase: |
| … | … | @@ -81,10 +80,8 @@ class TraktBase: |
| 81 | 80 | set_setting("trakt_user", EMPTY_USER) |
| 82 | 81 | set_setting("is_trakt_auth", "false") |
| 83 | 82 | notification(translation(90408), time=5000) |
| 84 | try: | |
| 83 | with contextlib.suppress(BaseException): | |
| 85 | 84 | TraktCache().clear_all_trakt_cache_data() |
| 86 | except: | |
| 87 | pass | |
| 88 | 85 | |
| 89 | 86 | def call_trakt( |
| 90 | 87 | self, |
| … | … | @@ -124,11 +121,9 @@ class TraktBase: |
| 124 | 121 | retry_after = int(response.headers.get("Retry-After", 1)) |
| 125 | 122 | kodilog(f"Trakt Rate Limit Exceeded. Retrying in {retry_after} seconds.") |
| 126 | 123 | 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) | |
| 130 | 125 | |
| 131 | kodilog("Trakt response status code: %s" % response.status_code) | |
| 126 | kodilog(f"Trakt response status code: {response.status_code}") | |
| 132 | 127 | try: |
| 133 | 128 | response.raise_for_status() |
| 134 | 129 | except requests.HTTPError as error: |
| … | … | @@ -149,36 +144,30 @@ class TraktBase: |
| 149 | 144 | 503: "Service Unavailable", |
| 150 | 145 | 504: "Gateway Timeout", |
| 151 | 146 | } |
| 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}") | |
| 155 | 148 | 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 | |
| 157 | 150 | except requests.RequestException as error: |
| 158 | 151 | error_message = f"Trakt API error: {error}" |
| 159 | raise ProviderException(error_message) | |
| 152 | raise ProviderException(error_message) from error | |
| 160 | 153 | |
| 161 | 154 | return self._process_response(response, method, pagination) |
| 162 | 155 | |
| 163 | 156 | def _send_request(self, path, params, data, headers, is_delete, method): |
| 164 | 157 | url = self.api_endpoint % path |
| 165 | kodilog("Trakt URL: %s" % url) | |
| 158 | kodilog(f"Trakt URL: {url}") | |
| 166 | 159 | if method == "post": |
| 167 | 160 | return requests.post(url, json=data, headers=headers, timeout=self.timeout) |
| 168 | 161 | elif method == "delete": |
| 169 | 162 | return requests.delete(url, json=data, headers=headers, timeout=self.timeout) |
| 170 | 163 | 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) | |
| 174 | 165 | elif data is not None: |
| 175 | 166 | return requests.post(url, json=data, headers=headers, timeout=self.timeout) |
| 176 | 167 | elif is_delete: |
| 177 | 168 | return requests.delete(url, headers=headers, timeout=self.timeout) |
| 178 | 169 | 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) | |
| 182 | 171 | |
| 183 | 172 | def _process_response(self, response, method, pagination): |
| 184 | 173 | response.encoding = "utf-8" |
| … | … | @@ -190,12 +179,8 @@ class TraktBase: |
| 190 | 179 | if method == "sort_by_headers": |
| 191 | 180 | headers = response.headers |
| 192 | 181 | 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) | |
| 199 | 184 | |
| 200 | 185 | if pagination: |
| 201 | 186 | return result, response.headers.get("X-Pagination-Page-Count") |
| … | … | @@ -334,10 +319,8 @@ class TraktAuthentication(TraktBase): |
| 334 | 319 | |
| 335 | 320 | # Generate QR code for the verification URL |
| 336 | 321 | qr_code = make_qrcode(verification_url) |
| 337 | try: | |
| 322 | with contextlib.suppress(BaseException): | |
| 338 | 323 | copy2clip(user_code) |
| 339 | except: | |
| 340 | pass | |
| 341 | 324 | |
| 342 | 325 | # Create and display QR code dialog |
| 343 | 326 | progressDialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH) |
| … | … | @@ -372,12 +355,10 @@ class TraktAuthentication(TraktBase): |
| 372 | 355 | progressDialog.update_progress(progress) |
| 373 | 356 | else: |
| 374 | 357 | break |
| 375 | except: | |
| 358 | except Exception: | |
| 376 | 359 | pass |
| 377 | try: | |
| 360 | with contextlib.suppress(BaseException): | |
| 378 | 361 | progressDialog.close_dialog() |
| 379 | except: | |
| 380 | pass | |
| 381 | 362 | return result |
| 382 | 363 | |
| 383 | 364 | def trakt_authenticate(self): |
| … | … | @@ -397,7 +378,7 @@ class TraktAuthentication(TraktBase): |
| 397 | 378 | set_setting("is_trakt_auth", "true") |
| 398 | 379 | notification(translation(90385), time=3000) |
| 399 | 380 | return True |
| 400 | except: | |
| 381 | except Exception: | |
| 401 | 382 | kodilog("Trakt user not found, setting to empty user") |
| 402 | 383 | set_setting("is_trakt_auth", "false") |
| 403 | 384 | notification(translation(90384), time=3000) |
| … | … | @@ -429,7 +410,7 @@ class TraktAuthentication(TraktBase): |
| 429 | 410 | class TraktMovies(TraktBase): |
| 430 | 411 | def trakt_movies_trending(self, page_no): |
| 431 | 412 | set_pluging_category(translation(90028)) |
| 432 | string = "trakt_movies_trending_%s" % page_no | |
| 413 | string = f"trakt_movies_trending_{page_no}" | |
| 433 | 414 | params = { |
| 434 | 415 | "path": "movies/trending", |
| 435 | 416 | "params": {"limit": 20}, |
| … | … | @@ -440,8 +421,8 @@ class TraktMovies(TraktBase): |
| 440 | 421 | def trakt_movies_trending_recent(self, page_no): |
| 441 | 422 | set_pluging_category(translation(90024)) |
| 442 | 423 | 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}" | |
| 445 | 426 | params = { |
| 446 | 427 | "path": "movies/trending", |
| 447 | 428 | "params": {"limit": 20, "years": years}, |
| … | … | @@ -457,7 +438,7 @@ class TraktMovies(TraktBase): |
| 457 | 438 | |
| 458 | 439 | def trakt_movies_most_watched(self, page_no): |
| 459 | 440 | set_pluging_category(translation(90029)) |
| 460 | string = "trakt_movies_most_watched_%s" % page_no | |
| 441 | string = f"trakt_movies_most_watched_{page_no}" | |
| 461 | 442 | params = { |
| 462 | 443 | "path": "movies/watched/daily", |
| 463 | 444 | "params": {"limit": 20}, |
| … | … | @@ -467,7 +448,7 @@ class TraktMovies(TraktBase): |
| 467 | 448 | |
| 468 | 449 | def trakt_movies_most_favorited(self, page_no): |
| 469 | 450 | set_pluging_category(translation(90030)) |
| 470 | string = "trakt_movies_most_favorited%s" % page_no | |
| 451 | string = f"trakt_movies_most_favorited{page_no}" | |
| 471 | 452 | params = { |
| 472 | 453 | "path": "movies/favorited/daily", |
| 473 | 454 | "params": {"limit": 20}, |
| … | … | @@ -477,7 +458,7 @@ class TraktMovies(TraktBase): |
| 477 | 458 | |
| 478 | 459 | def trakt_recommendations(self, media_type): |
| 479 | 460 | set_pluging_category(translation(90033)) |
| 480 | string = "trakt_recommendations_%s" % (media_type) | |
| 461 | string = f"trakt_recommendations_{media_type}" | |
| 481 | 462 | params = { |
| 482 | 463 | "path": "/recommendations/%s", |
| 483 | 464 | "path_insert": media_type, |
| … | … | @@ -521,7 +502,7 @@ class TraktMovies(TraktBase): |
| 521 | 502 | class TraktTV(TraktBase): |
| 522 | 503 | def trakt_tv_trending(self, page_no): |
| 523 | 504 | set_pluging_category(translation(90028)) |
| 524 | string = "trakt_tv_trending_%s" % page_no | |
| 505 | string = f"trakt_tv_trending_{page_no}" | |
| 525 | 506 | params = { |
| 526 | 507 | "path": "shows/trending", |
| 527 | 508 | "params": {"limit": 20}, |
| … | … | @@ -532,8 +513,8 @@ class TraktTV(TraktBase): |
| 532 | 513 | def trakt_tv_trending_recent(self, page_no): |
| 533 | 514 | set_pluging_category(translation(90024)) |
| 534 | 515 | 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}" | |
| 537 | 518 | params = { |
| 538 | 519 | "path": "shows/trending", |
| 539 | 520 | "params": {"limit": 20, "years": years}, |
| … | … | @@ -543,7 +524,7 @@ class TraktTV(TraktBase): |
| 543 | 524 | |
| 544 | 525 | def trakt_tv_most_watched(self, page_no): |
| 545 | 526 | set_pluging_category(translation(90029)) |
| 546 | string = "trakt_tv_most_watched_%s" % page_no | |
| 527 | string = f"trakt_tv_most_watched_{page_no}" | |
| 547 | 528 | params = { |
| 548 | 529 | "path": "shows/watched/daily", |
| 549 | 530 | "params": {"limit": 20}, |
| … | … | @@ -553,7 +534,7 @@ class TraktTV(TraktBase): |
| 553 | 534 | |
| 554 | 535 | def trakt_tv_most_favorited(self, page_no): |
| 555 | 536 | set_pluging_category(translation(90030)) |
| 556 | string = "trakt_tv_most_favorited_%s" % page_no | |
| 537 | string = f"trakt_tv_most_favorited_{page_no}" | |
| 557 | 538 | params = { |
| 558 | 539 | "path": "shows/favorited/daily", |
| 559 | 540 | "params": {"limit": 20}, |
| … | … | @@ -563,7 +544,7 @@ class TraktTV(TraktBase): |
| 563 | 544 | |
| 564 | 545 | def trakt_recommendations(self, media_type): |
| 565 | 546 | set_pluging_category(translation(90033)) |
| 566 | string = "trakt_recommendations_%s" % (media_type) | |
| 547 | string = f"trakt_recommendations_{media_type}" | |
| 567 | 548 | params = { |
| 568 | 549 | "path": "/recommendations/%s", |
| 569 | 550 | "path_insert": media_type, |
| … | … | @@ -621,9 +602,7 @@ class TraktTV(TraktBase): |
| 621 | 602 | continue |
| 622 | 603 | |
| 623 | 604 | 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") | |
| 627 | 606 | watched_dt = TraktTV._parse_iso_date(watched_at) |
| 628 | 607 | |
| 629 | 608 | if watched_dt: |
| … | … | @@ -645,15 +624,11 @@ class TraktTV(TraktBase): |
| 645 | 624 | start_season, start_episode = starting_point |
| 646 | 625 | |
| 647 | 626 | 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}) | |
| 651 | 628 | episodes = getattr(season_details, "episodes", []) or [] |
| 652 | 629 | for episode in episodes: |
| 653 | 630 | 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): | |
| 657 | 632 | continue |
| 658 | 633 | |
| 659 | 634 | air_date = getattr(episode, "air_date", None) |
| … | … | @@ -670,7 +645,9 @@ class TraktTV(TraktBase): |
| 670 | 645 | return None |
| 671 | 646 | |
| 672 | 647 | @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 | ): | |
| 674 | 651 | if fetcher is None: |
| 675 | 652 | from lib.clients.tmdb.utils.utils import tmdb_get as fetcher |
| 676 | 653 | |
| … | … | @@ -772,9 +749,9 @@ class TraktTV(TraktBase): |
| 772 | 749 | hidden.append(int(tmdb_id)) |
| 773 | 750 | return hidden |
| 774 | 751 | |
| 775 | string = "trakt_hidden_items_%s" % section | |
| 752 | string = f"trakt_hidden_items_{section}" | |
| 776 | 753 | params = { |
| 777 | "path": "users/hidden/%s" % section, | |
| 754 | "path": f"users/hidden/{section}", | |
| 778 | 755 | "params": {"limit": 1000}, |
| 779 | 756 | "with_auth": True, |
| 780 | 757 | "pagination": False, |
| … | … | @@ -804,9 +781,9 @@ class TraktAnime(TraktBase): |
| 804 | 781 | |
| 805 | 782 | def trakt_anime_trending(self, page_no, mode="tv"): |
| 806 | 783 | 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}" | |
| 808 | 785 | params = { |
| 809 | "path": "%s/trending" % path_prefix, | |
| 786 | "path": f"{path_prefix}/trending", | |
| 810 | 787 | "params": {"genres": "anime", "limit": 20}, |
| 811 | 788 | "page_no": page_no, |
| 812 | 789 | } |
| … | … | @@ -815,10 +792,10 @@ class TraktAnime(TraktBase): |
| 815 | 792 | def trakt_anime_trending_recent(self, page_no, mode="tv"): |
| 816 | 793 | path_prefix = self._get_path_prefix(mode) |
| 817 | 794 | 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}" | |
| 820 | 797 | params = { |
| 821 | "path": "%s/trending" % path_prefix, | |
| 798 | "path": f"{path_prefix}/trending", | |
| 822 | 799 | "params": {"genres": "anime", "limit": 20, "years": years}, |
| 823 | 800 | "page_no": page_no, |
| 824 | 801 | } |
| … | … | @@ -826,9 +803,9 @@ class TraktAnime(TraktBase): |
| 826 | 803 | |
| 827 | 804 | def trakt_anime_most_watched(self, page_no, mode="tv"): |
| 828 | 805 | 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}" | |
| 830 | 807 | params = { |
| 831 | "path": "%s/watched/daily" % path_prefix, | |
| 808 | "path": f"{path_prefix}/watched/daily", | |
| 832 | 809 | "params": {"genres": "anime", "limit": 20}, |
| 833 | 810 | "page_no": page_no, |
| 834 | 811 | } |
| … | … | @@ -836,9 +813,9 @@ class TraktAnime(TraktBase): |
| 836 | 813 | |
| 837 | 814 | def trakt_anime_most_favorited(self, page_no, mode="tv"): |
| 838 | 815 | 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}" | |
| 840 | 817 | params = { |
| 841 | "path": "%s/favorited/daily" % path_prefix, | |
| 818 | "path": f"{path_prefix}/favorited/daily", | |
| 842 | 819 | "params": {"genres": "anime", "limit": 20}, |
| 843 | 820 | "page_no": page_no, |
| 844 | 821 | } |
| … | … | @@ -925,9 +902,7 @@ class TraktLists(TraktBase): |
| 925 | 902 | for episode in episodes: |
| 926 | 903 | episode_number = episode.get("number") |
| 927 | 904 | if episode_number: |
| 928 | season_payload["episodes"].append( | |
| 929 | {"number": int(episode_number)} | |
| 930 | ) | |
| 905 | season_payload["episodes"].append({"number": int(episode_number)}) | |
| 931 | 906 | payload["shows"][0]["seasons"].append(season_payload) |
| 932 | 907 | return payload |
| 933 | 908 | |
| … | … | @@ -936,7 +911,7 @@ class TraktLists(TraktBase): |
| 936 | 911 | def trakt_watchlist(self, media_type): |
| 937 | 912 | kodilog("Fetching trakt watchlist") |
| 938 | 913 | 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) | |
| 940 | 915 | return result |
| 941 | 916 | |
| 942 | 917 | def add_to_watchlist(self, media_type, ids): |
| … | … | @@ -954,7 +929,9 @@ class TraktLists(TraktBase): |
| 954 | 929 | with_auth=True, |
| 955 | 930 | pagination=False, |
| 956 | 931 | ) |
| 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 | ) | |
| 958 | 935 | clear_trakt_collection_watchlist_data("watchlist", original_media_type) |
| 959 | 936 | return result |
| 960 | 937 | |
| … | … | @@ -972,7 +949,9 @@ class TraktLists(TraktBase): |
| 972 | 949 | with_auth=True, |
| 973 | 950 | pagination=False, |
| 974 | 951 | ) |
| 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 | ) | |
| 976 | 955 | clear_trakt_collection_watchlist_data("watchlist", original_media_type) |
| 977 | 956 | return result |
| 978 | 957 | |
| … | … | @@ -1011,7 +990,9 @@ class TraktLists(TraktBase): |
| 1011 | 990 | with_auth=True, |
| 1012 | 991 | pagination=False, |
| 1013 | 992 | ) |
| 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 | ) | |
| 1015 | 996 | clear_trakt_collection_watchlist_data("collection", original_media_type) |
| 1016 | 997 | return result |
| 1017 | 998 | |
| … | … | @@ -1029,7 +1010,9 @@ class TraktLists(TraktBase): |
| 1029 | 1010 | with_auth=True, |
| 1030 | 1011 | pagination=False, |
| 1031 | 1012 | ) |
| 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 | ) | |
| 1033 | 1016 | clear_trakt_collection_watchlist_data("collection", original_media_type) |
| 1034 | 1017 | return result |
| 1035 | 1018 | |
| … | … | @@ -1091,7 +1074,7 @@ class TraktLists(TraktBase): |
| 1091 | 1074 | def mark_as_watched(self, media_type, season, episode, ids): |
| 1092 | 1075 | payload = self._history_payload(media_type, season, episode, ids) |
| 1093 | 1076 | |
| 1094 | kodilog("Payload: %s" % payload) | |
| 1077 | kodilog(f"Payload: {payload}") | |
| 1095 | 1078 | |
| 1096 | 1079 | response = self.call_trakt( |
| 1097 | 1080 | "sync/history", |
| … | … | @@ -1154,9 +1137,7 @@ class TraktLists(TraktBase): |
| 1154 | 1137 | }, |
| 1155 | 1138 | "title": item[media_key]["title"], |
| 1156 | 1139 | "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), | |
| 1160 | 1141 | } |
| 1161 | 1142 | for item in raw_data |
| 1162 | 1143 | ] |
| … | … | @@ -1231,7 +1212,7 @@ class TraktLists(TraktBase): |
| 1231 | 1212 | |
| 1232 | 1213 | return normalized |
| 1233 | 1214 | |
| 1234 | string = "trakt_search_lists_%s_%s" % (search_title, page_no) | |
| 1215 | string = f"trakt_search_lists_{search_title}_{page_no}" | |
| 1235 | 1216 | return cache_object(_process, string, "dummy_arg", False, 4) |
| 1236 | 1217 | |
| 1237 | 1218 | def trakt_favorites(self, media_type): |
| … | … | @@ -1253,7 +1234,7 @@ class TraktLists(TraktBase): |
| 1253 | 1234 | ] |
| 1254 | 1235 | |
| 1255 | 1236 | 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}" | |
| 1257 | 1238 | params = { |
| 1258 | 1239 | "path": "users/me/favorites/%s/%s", |
| 1259 | 1240 | "path_insert": (media_type, "title"), |
| … | … | @@ -1278,7 +1259,7 @@ class TraktLists(TraktBase): |
| 1278 | 1259 | if i["type"] in ("movie", "show") |
| 1279 | 1260 | ] |
| 1280 | 1261 | |
| 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}" | |
| 1282 | 1263 | if list_type == "my_lists" and trakt_id: |
| 1283 | 1264 | params = { |
| 1284 | 1265 | "path": "users/me/lists/%s/items", |
| … | … | @@ -1307,7 +1288,7 @@ class TraktLists(TraktBase): |
| 1307 | 1288 | |
| 1308 | 1289 | def trakt_trending_popular_lists(self, list_type, page_no): |
| 1309 | 1290 | 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}" | |
| 1311 | 1292 | params = { |
| 1312 | 1293 | "path": "lists/%s", |
| 1313 | 1294 | "path_insert": list_type, |
| … | … | @@ -1404,7 +1385,7 @@ class TraktLists(TraktBase): |
| 1404 | 1385 | |
| 1405 | 1386 | def delete_list(self, trakt_id): |
| 1406 | 1387 | response = self.call_trakt( |
| 1407 | "users/me/lists/%s" % trakt_id, | |
| 1388 | f"users/me/lists/{trakt_id}", | |
| 1408 | 1389 | method="delete", |
| 1409 | 1390 | with_auth=True, |
| 1410 | 1391 | pagination=False, |
| … | … | @@ -1413,25 +1394,33 @@ class TraktLists(TraktBase): |
| 1413 | 1394 | clear_trakt_list_contents_data("my_lists") |
| 1414 | 1395 | return response |
| 1415 | 1396 | |
| 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 | ) | |
| 1435 | 1424 | clear_trakt_list_data("liked_lists") |
| 1436 | 1425 | clear_trakt_list_contents_data("liked_lists") |
| 1437 | 1426 | return response |
| … | … | @@ -1447,23 +1436,15 @@ class TraktLists(TraktBase): |
| 1447 | 1436 | ) |
| 1448 | 1437 | for key in ("tmdb", "tvdb"): |
| 1449 | 1438 | if key in normalized_ids: |
| 1450 | try: | |
| 1439 | with contextlib.suppress(TypeError, ValueError): | |
| 1451 | 1440 | normalized_ids[key] = int(normalized_ids[key]) |
| 1452 | except (TypeError, ValueError): | |
| 1453 | pass | |
| 1454 | 1441 | |
| 1455 | payload = { | |
| 1456 | media_key: [ | |
| 1457 | { | |
| 1458 | "ids": normalized_ids | |
| 1459 | } | |
| 1460 | ] | |
| 1461 | } | |
| 1442 | payload = {media_key: [{"ids": normalized_ids}]} | |
| 1462 | 1443 | return payload |
| 1463 | 1444 | |
| 1464 | 1445 | def add_item_to_list(self, trakt_id, media_type, ids): |
| 1465 | 1446 | response = self.call_trakt( |
| 1466 | "users/me/lists/%s/items" % trakt_id, | |
| 1447 | f"users/me/lists/{trakt_id}/items", | |
| 1467 | 1448 | data=self._list_item_payload(media_type, ids), |
| 1468 | 1449 | with_auth=True, |
| 1469 | 1450 | pagination=False, |
| … | … | @@ -1474,7 +1455,7 @@ class TraktLists(TraktBase): |
| 1474 | 1455 | |
| 1475 | 1456 | def remove_item_from_list(self, trakt_id, media_type, ids): |
| 1476 | 1457 | response = self.call_trakt( |
| 1477 | "users/me/lists/%s/items/remove" % trakt_id, | |
| 1458 | f"users/me/lists/{trakt_id}/items/remove", | |
| 1478 | 1459 | data=self._list_item_payload(media_type, ids), |
| 1479 | 1460 | with_auth=True, |
| 1480 | 1461 | pagination=False, |
| … | … | @@ -1496,8 +1477,10 @@ class TraktLists(TraktBase): |
| 1496 | 1477 | class TraktScrobble(TraktBase): |
| 1497 | 1478 | @staticmethod |
| 1498 | 1479 | def _scrobble_payload(data): |
| 1499 | from lib.utils.kodi.utils import ADDON_VERSION | |
| 1500 | 1480 | from datetime import datetime |
| 1481 | ||
| 1482 | from lib.utils.kodi.utils import ADDON_VERSION | |
| 1483 | ||
| 1501 | 1484 | progress = data.get("progress", 0) |
| 1502 | 1485 | if isinstance(progress, str): |
| 1503 | 1486 | try: |
| … | … | @@ -1508,7 +1491,7 @@ class TraktScrobble(TraktBase): |
| 1508 | 1491 | payload: Dict[str, Any] = { |
| 1509 | 1492 | "progress": progress, |
| 1510 | 1493 | "app_version": ADDON_VERSION, |
| 1511 | "app_date": datetime.now().strftime("%Y-%m-%d") | |
| 1494 | "app_date": datetime.now().strftime("%Y-%m-%d"), | |
| 1512 | 1495 | } |
| 1513 | 1496 | |
| 1514 | 1497 | if data["mode"] == "movies": |
| … | … | @@ -1556,9 +1539,7 @@ class TraktScrobble(TraktBase): |
| 1556 | 1539 | |
| 1557 | 1540 | # Check local cache first |
| 1558 | 1541 | 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)) | |
| 1562 | 1543 | else: |
| 1563 | 1544 | cached_progress = trakt_watched_cache.get_progress( |
| 1564 | 1545 | "episode", str(tmdb_id), season, episode |
| … | … | @@ -1578,9 +1559,7 @@ class TraktScrobble(TraktBase): |
| 1578 | 1559 | local_tmdb = int(tmdb_id) |
| 1579 | 1560 | if trakt_tmdb == local_tmdb: |
| 1580 | 1561 | 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}%") | |
| 1584 | 1563 | return progress |
| 1585 | 1564 | elif item["type"] == "episode": |
| 1586 | 1565 | trakt_tmdb = int(item["show"]["ids"]["tmdb"]) |
| … | … | @@ -1592,9 +1571,7 @@ class TraktScrobble(TraktBase): |
| 1592 | 1571 | and item["episode"]["number"] == int(episode) |
| 1593 | 1572 | ): |
| 1594 | 1573 | 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}%") | |
| 1598 | 1575 | return progress |
| 1599 | 1576 | except Exception: |
| 1600 | 1577 | continue |
| … | … | @@ -1630,7 +1607,7 @@ class TraktCache(TraktBase): |
| 1630 | 1607 | dbcon.execute(BASE_DELETE % table) |
| 1631 | 1608 | dbcon.execute("VACUUM") |
| 1632 | 1609 | return True |
| 1633 | except: | |
| 1610 | except Exception: | |
| 1634 | 1611 | return False |
| 1635 | 1612 | |
| 1636 | 1613 | def clear_cache(self, cache_type): |
| … | … | @@ -1,6 +1,5 @@ |
| 1 | 1 | from lib.api.trakt.base_cache import connect_database |
| 2 | 2 | |
| 3 | ||
| 4 | 3 | SELECT = "SELECT id FROM trakt_data" |
| 5 | 4 | DELETE = "DELETE FROM trakt_data WHERE id=?" |
| 6 | 5 | DELETE_LIKE = 'DELETE FROM trakt_data WHERE id LIKE "%s"' |
| … | … | @@ -24,7 +23,7 @@ class TraktCache: |
| 24 | 23 | cache_data = dbcon.execute(TC_BASE_GET, (string,)).fetchone() |
| 25 | 24 | if cache_data: |
| 26 | 25 | result = eval(cache_data[0]) |
| 27 | except: | |
| 26 | except Exception: | |
| 28 | 27 | pass |
| 29 | 28 | return result |
| 30 | 29 | |
| … | … | @@ -32,14 +31,14 @@ class TraktCache: |
| 32 | 31 | try: |
| 33 | 32 | dbcon = connect_database("trakt_db") |
| 34 | 33 | dbcon.execute(TC_BASE_SET, (string, repr(data))) |
| 35 | except: | |
| 34 | except Exception: | |
| 36 | 35 | return None |
| 37 | 36 | |
| 38 | 37 | def delete(self, string): |
| 39 | 38 | try: |
| 40 | 39 | dbcon = connect_database("trakt_db") |
| 41 | 40 | dbcon.execute(TC_BASE_DELETE, (string,)) |
| 42 | except: | |
| 41 | except Exception: | |
| 43 | 42 | pass |
| 44 | 43 | |
| 45 | 44 | def clear_all(self): |
| … | … | @@ -47,7 +46,7 @@ class TraktCache: |
| 47 | 46 | dbcon = connect_database("trakt_db") |
| 48 | 47 | dbcon.execute("DELETE FROM trakt_data") |
| 49 | 48 | dbcon.execute("VACUUM") |
| 50 | except: | |
| 49 | except Exception: | |
| 51 | 50 | pass |
| 52 | 51 | |
| 53 | 52 | |
| … | … | @@ -90,7 +89,7 @@ class TraktWatched: |
| 90 | 89 | self._delete("DELETE FROM watched", ()) |
| 91 | 90 | self._delete("DELETE FROM progress", ()) |
| 92 | 91 | self._delete("DELETE FROM watched_status", ()) |
| 93 | except: | |
| 92 | except Exception: | |
| 94 | 93 | pass |
| 95 | 94 | |
| 96 | 95 | def _executemany(self, command, insert_list): |
| … | … | @@ -114,16 +113,14 @@ class TraktWatched: |
| 114 | 113 | |
| 115 | 114 | result = dbcon.execute(command, args).fetchone() |
| 116 | 115 | return result is not None |
| 117 | except: | |
| 116 | except Exception: | |
| 118 | 117 | return False |
| 119 | 118 | |
| 120 | 119 | def get_progress(self, db_type, media_id, season=None, episode=None): |
| 121 | 120 | try: |
| 122 | 121 | dbcon = connect_database("trakt_db") |
| 123 | 122 | 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=?" | |
| 127 | 124 | args = (db_type, media_id) |
| 128 | 125 | else: |
| 129 | 126 | command = "SELECT resume_point FROM progress WHERE db_type=? AND media_id=? AND season=? AND episode=?" |
| … | … | @@ -133,7 +130,7 @@ class TraktWatched: |
| 133 | 130 | if result: |
| 134 | 131 | return float(result[0]) |
| 135 | 132 | return 0.0 |
| 136 | except: | |
| 133 | except Exception: | |
| 137 | 134 | return 0.0 |
| 138 | 135 | |
| 139 | 136 | |
| … | … | @@ -160,17 +157,17 @@ def reset_activity(latest_activities): |
| 160 | 157 | cached_data = default_activities() |
| 161 | 158 | dbcon.execute(DELETE, (string,)) |
| 162 | 159 | trakt_cache.set(string, latest_activities) |
| 163 | except: | |
| 160 | except Exception: | |
| 164 | 161 | cached_data = default_activities() |
| 165 | 162 | return cached_data |
| 166 | 163 | |
| 167 | 164 | |
| 168 | 165 | def clear_trakt_hidden_data(list_type): |
| 169 | string = "trakt_hidden_items_%s" % list_type | |
| 166 | string = f"trakt_hidden_items_{list_type}" | |
| 170 | 167 | try: |
| 171 | 168 | dbcon = connect_database("trakt_db") |
| 172 | 169 | dbcon.execute(DELETE, (string,)) |
| 173 | except: | |
| 170 | except Exception: | |
| 174 | 171 | pass |
| 175 | 172 | |
| 176 | 173 | |
| … | … | @@ -179,11 +176,11 @@ def clear_trakt_collection_watchlist_data(list_type, media_type): |
| 179 | 176 | media_type = "movie" |
| 180 | 177 | if media_type in ("tvshows", "shows"): |
| 181 | 178 | media_type = "tvshow" |
| 182 | string = "trakt_%s_%s" % (list_type, media_type) | |
| 179 | string = f"trakt_{list_type}_{media_type}" | |
| 183 | 180 | try: |
| 184 | 181 | dbcon = connect_database("trakt_db") |
| 185 | 182 | dbcon.execute(DELETE, (string,)) |
| 186 | except: | |
| 183 | except Exception: | |
| 187 | 184 | pass |
| 188 | 185 | |
| 189 | 186 | |
| … | … | @@ -192,16 +189,16 @@ def clear_trakt_list_contents_data(list_type): |
| 192 | 189 | try: |
| 193 | 190 | dbcon = connect_database("trakt_db") |
| 194 | 191 | dbcon.execute(DELETE_LIKE % string) |
| 195 | except: | |
| 192 | except Exception: | |
| 196 | 193 | pass |
| 197 | 194 | |
| 198 | 195 | |
| 199 | 196 | def clear_trakt_list_data(list_type): |
| 200 | string = "trakt_%s" % list_type | |
| 197 | string = f"trakt_{list_type}" | |
| 201 | 198 | try: |
| 202 | 199 | dbcon = connect_database("trakt_db") |
| 203 | 200 | dbcon.execute(DELETE, (string,)) |
| 204 | except: | |
| 201 | except Exception: | |
| 205 | 202 | pass |
| 206 | 203 | |
| 207 | 204 | |
| … | … | @@ -209,7 +206,7 @@ def clear_trakt_calendar(): |
| 209 | 206 | try: |
| 210 | 207 | dbcon = connect_database("trakt_db") |
| 211 | 208 | dbcon.execute(DELETE_LIKE % "trakt_get_my_calendar_%") |
| 212 | except: | |
| 209 | except Exception: | |
| 213 | 210 | return |
| 214 | 211 | |
| 215 | 212 | |
| … | … | @@ -217,7 +214,7 @@ def clear_trakt_recommendations(): |
| 217 | 214 | try: |
| 218 | 215 | dbcon = connect_database("trakt_db") |
| 219 | 216 | dbcon.execute(DELETE_LIKE % "trakt_recommendations_%") |
| 220 | except: | |
| 217 | except Exception: | |
| 221 | 218 | return |
| 222 | 219 | |
| 223 | 220 | |
| … | … | @@ -225,7 +222,7 @@ def clear_trakt_favorites(): |
| 225 | 222 | try: |
| 226 | 223 | dbcon = connect_database("trakt_db") |
| 227 | 224 | dbcon.execute(DELETE_LIKE % "trakt_favorites_%") |
| 228 | except: | |
| 225 | except Exception: | |
| 229 | 226 | return |
| 230 | 227 | |
| 231 | 228 | |
| … | … | @@ -236,7 +233,7 @@ def clear_trakt_watchlist(): |
| 236 | 233 | try: |
| 237 | 234 | dbcon = connect_database("trakt_db") |
| 238 | 235 | dbcon.execute(DELETE_LIKE % "trakt_watchlist_%") |
| 239 | except: | |
| 236 | except Exception: | |
| 240 | 237 | pass |
| 241 | 238 | |
| 242 | 239 |
| … | … | @@ -1,11 +1,13 @@ |
| 1 | from datetime import datetime | |
| 1 | import json | |
| 2 | 2 | import random |
| 3 | 3 | import re |
| 4 | 4 | import time |
| 5 | import json | |
| 5 | from datetime import datetime | |
| 6 | ||
| 6 | 7 | import xbmc |
| 7 | from lib.utils.kodi.utils import action_url_run, kodilog | |
| 8 | ||
| 8 | 9 | from lib.utils.kodi.settings import get_setting |
| 10 | from lib.utils.kodi.utils import action_url_run, kodilog | |
| 9 | 11 | |
| 10 | 12 | |
| 11 | 13 | def is_trakt_auth(): |
| … | … | @@ -35,31 +37,37 @@ def add_trakt_watchlist_context_menu(media_type, ids, include_add=True, include_ |
| 35 | 37 | "imdb": ids.get("imdb_id") or ids.get("imdb"), |
| 36 | 38 | } |
| 37 | 39 | ) |
| 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", | |
| 45 | 50 | ), |
| 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", | |
| 54 | 59 | ), |
| 55 | "remove", | |
| 56 | ), | |
| 57 | ], include_add, include_remove) | |
| 60 | ], | |
| 61 | include_add, | |
| 62 | include_remove, | |
| 63 | ) | |
| 58 | 64 | |
| 59 | 65 | |
| 60 | 66 | def 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 | |
| 62 | 68 | ): |
| 69 | if ids is None: | |
| 70 | ids = {} | |
| 63 | 71 | filtered_ids = clean_ids( |
| 64 | 72 | { |
| 65 | 73 | "tmdb": ids.get("tmdb_id") or ids.get("tmdb"), |
| … | … | @@ -67,30 +75,34 @@ def add_trakt_watched_context_menu( |
| 67 | 75 | "imdb": ids.get("imdb_id") or ids.get("imdb"), |
| 68 | 76 | } |
| 69 | 77 | ) |
| 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", | |
| 79 | 90 | ), |
| 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", | |
| 90 | 101 | ), |
| 91 | "remove", | |
| 92 | ), | |
| 93 | ], include_add, include_remove) | |
| 102 | ], | |
| 103 | include_add, | |
| 104 | include_remove, | |
| 105 | ) | |
| 94 | 106 | |
| 95 | 107 | |
| 96 | 108 | def 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 |
| 101 | 113 | "imdb": ids.get("imdb_id") or ids.get("imdb"), |
| 102 | 114 | } |
| 103 | 115 | ) |
| 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", | |
| 111 | 126 | ), |
| 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", | |
| 120 | 135 | ), |
| 121 | "remove", | |
| 122 | ), | |
| 123 | ], include_add, include_remove) | |
| 136 | ], | |
| 137 | include_add, | |
| 138 | include_remove, | |
| 139 | ) | |
| 124 | 140 | |
| 125 | 141 | |
| 126 | 142 | def 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_ |
| 131 | 147 | "imdb": ids.get("imdb_id") or ids.get("imdb"), |
| 132 | 148 | } |
| 133 | 149 | ) |
| 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", | |
| 141 | 160 | ), |
| 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", | |
| 150 | 169 | ), |
| 151 | "remove", | |
| 152 | ), | |
| 153 | ], include_add, include_remove) | |
| 170 | ], | |
| 171 | include_add, | |
| 172 | include_remove, | |
| 173 | ) | |
| 154 | 174 | |
| 155 | 175 | |
| 156 | 176 | def 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 |
| 161 | 181 | "imdb": ids.get("imdb_id") or ids.get("imdb"), |
| 162 | 182 | } |
| 163 | 183 | ) |
| 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", | |
| 171 | 194 | ), |
| 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", | |
| 180 | 203 | ), |
| 181 | "remove", | |
| 182 | ), | |
| 183 | ], include_add, include_remove) | |
| 204 | ], | |
| 205 | include_add, | |
| 206 | include_remove, | |
| 207 | ) | |
| 184 | 208 | |
| 185 | 209 | |
| 186 | 210 | def clean_ids(ids_dict): |
| … | … | @@ -200,7 +224,7 @@ def jsondate_to_datetime(jsondate_object, resformat, remove_time=False): |
| 200 | 224 | def datetime_workaround(data, str_format): |
| 201 | 225 | try: |
| 202 | 226 | datetime_object = datetime.strptime(data, str_format) |
| 203 | except: | |
| 227 | except Exception: | |
| 204 | 228 | datetime_object = datetime(*(time.strptime(data, str_format)[0:6])) |
| 205 | 229 | return datetime_object |
| 206 | 230 | |
| … | … | @@ -219,29 +243,19 @@ def sort_list(sort_key, sort_direction, list_data): |
| 219 | 243 | reverse=reverse, |
| 220 | 244 | ) |
| 221 | 245 | 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) | |
| 225 | 247 | 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) | |
| 229 | 249 | 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) | |
| 233 | 251 | 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) | |
| 237 | 253 | 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) | |
| 241 | 255 | if sort_key == "random": |
| 242 | 256 | return sorted(list_data, key=lambda k: random.random()) |
| 243 | 257 | return list_data |
| 244 | except: | |
| 258 | except Exception: | |
| 245 | 259 | return list_data |
| 246 | 260 | |
| 247 | 261 | |
| … | … | @@ -264,7 +278,7 @@ def title_key(title): |
| 264 | 278 | else: |
| 265 | 279 | offset = 0 |
| 266 | 280 | return title[offset:] |
| 267 | except: | |
| 281 | except Exception: | |
| 268 | 282 | return title |
| 269 | 283 | |
| 270 | 284 |
| … | … | @@ -1,7 +1,10 @@ |
| 1 | import requests | |
| 2 | 1 | from datetime import timedelta |
| 2 | ||
| 3 | import requests | |
| 4 | ||
| 3 | 5 | from lib.db.cached import cache |
| 4 | from lib.utils.kodi.settings import is_cache_enabled, get_cache_expiration | |
| 6 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled | |
| 7 | ||
| 5 | 8 | |
| 6 | 9 | class TVDBAPI: |
| 7 | 10 | def __init__(self): |
| … | … | @@ -15,7 +18,7 @@ class TVDBAPI: |
| 15 | 18 | def get_token(self): |
| 16 | 19 | identifier = "tvdb_token" |
| 17 | 20 | token = cache.get(identifier) |
| 18 | if token: | |
| 21 | if token: | |
| 19 | 22 | return token |
| 20 | 23 | else: |
| 21 | 24 | res = requests.post(self.baseUrl + "login", json=self.apiKey, headers=self.headers) |
| … | … | @@ -30,12 +33,7 @@ class TVDBAPI: |
| 30 | 33 | |
| 31 | 34 | def get_request(self, url): |
| 32 | 35 | 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"}) | |
| 39 | 37 | url = self.baseUrl + url |
| 40 | 38 | response = requests.get(url, headers=self.headers) |
| 41 | 39 | if response: |
| … | … | @@ -47,13 +45,13 @@ class TVDBAPI: |
| 47 | 45 | |
| 48 | 46 | def get_imdb_id(self, tvdb_id): |
| 49 | 47 | imdb_id = None |
| 50 | url = "series/{}/extended".format(tvdb_id) | |
| 48 | url = f"series/{tvdb_id}/extended" | |
| 51 | 49 | data = self.get_request(url) |
| 52 | 50 | if data: |
| 53 | 51 | imdb_id = [x.get("id") for x in data["remoteIds"] if x.get("type") == 2] |
| 54 | 52 | return imdb_id[0] if imdb_id else None |
| 55 | 53 | |
| 56 | 54 | def get_seasons(self, tvdb_id): |
| 57 | url = "seasons/{}/extended".format(tvdb_id) | |
| 55 | url = f"seasons/{tvdb_id}/extended" | |
| 58 | 56 | data = self.get_request(url) |
| 59 | 57 | return data |
| … | … | @@ -1,10 +1,11 @@ |
| 1 | 1 | import os |
| 2 | import xml.etree.ElementTree as ET | |
| 2 | 3 | from urllib.parse import unquote, urlparse |
| 4 | ||
| 3 | 5 | import requests |
| 4 | 6 | from requests.auth import HTTPBasicAuth |
| 5 | import xml.etree.ElementTree as ET | |
| 6 | from lib.utils.kodi.utils import kodilog | |
| 7 | 7 | |
| 8 | from lib.utils.kodi.utils import kodilog | |
| 8 | 9 | |
| 9 | 10 | VIDEO_EXTS = { |
| 10 | 11 | ".mkv", |
| … | … | @@ -24,9 +25,7 @@ TEXT_EXTS = {".txt", ".log", ".nfo", ".xml", ".json", ".md", ".srt"} |
| 24 | 25 | |
| 25 | 26 | |
| 26 | 27 | class 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): | |
| 30 | 29 | self.username = username |
| 31 | 30 | self.password = password |
| 32 | 31 | kodilog(f"WebDAV Client initialized with hostname: {hostname}") |
| … | … | @@ -89,9 +88,7 @@ class WebDAVClient: |
| 89 | 88 | |
| 90 | 89 | headers = {"Depth": "1"} |
| 91 | 90 | 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) | |
| 95 | 92 | r.raise_for_status() |
| 96 | 93 | except requests.exceptions.RequestException as e: |
| 97 | 94 | # Check if it's an HTTP error with a response |
| … | … | @@ -136,20 +133,14 @@ class WebDAVClient: |
| 136 | 133 | |
| 137 | 134 | # Build Auth URL |
| 138 | 135 | 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 "" | |
| 145 | 137 | ) |
| 138 | clean_host = self.server_root.replace("http://", "").replace("https://", "") | |
| 146 | 139 | |
| 147 | 140 | # Extract only the path from href to avoid issues with absolute URLs |
| 148 | 141 | # (e.g. server returning http://localhost:8080/...) |
| 149 | 142 | 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('/')}" | |
| 153 | 144 | |
| 154 | 145 | items.append( |
| 155 | 146 | { |
| … | … | @@ -171,9 +162,7 @@ class WebDAVClient: |
| 171 | 162 | try: |
| 172 | 163 | headers = {"Depth": "1"} |
| 173 | 164 | 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) | |
| 177 | 166 | r.raise_for_status() |
| 178 | 167 | |
| 179 | 168 | # Parse XML to make sure server responded properly |
| … | … | @@ -181,7 +170,7 @@ class WebDAVClient: |
| 181 | 170 | |
| 182 | 171 | return { |
| 183 | 172 | "success": True, |
| 184 | "message": f"WebDAV server reachable", | |
| 173 | "message": "WebDAV server reachable", | |
| 185 | 174 | } |
| 186 | 175 | |
| 187 | 176 | except requests.exceptions.RequestException as e: |
| … | … | @@ -1,5 +1,7 @@ |
| 1 | 1 | import requests |
| 2 | 2 | |
| 3 | from lib.utils.kodi.utils import get_setting | |
| 4 | ||
| 3 | 5 | |
| 4 | 6 | class AniList: |
| 5 | 7 | def __init__(self, client_id, client_secret): |
| … | … | @@ -78,8 +80,8 @@ class AniList: |
| 78 | 80 | # GraphQL Anilist |
| 79 | 81 | BASE_GRAPH = """ |
| 80 | 82 | query ( |
| 81 | $page: Int, | |
| 82 | $perPage: Int, | |
| 83 | $page: Int, | |
| 84 | $perPage: Int, | |
| 83 | 85 | $sort: [MediaSort], |
| 84 | 86 | $format:[MediaFormat], |
| 85 | 87 | $season: MediaSeason, |
| … | … | @@ -88,7 +90,7 @@ BASE_GRAPH = """ |
| 88 | 90 | ) { |
| 89 | 91 | Page (page: $page, perPage: $perPage) { |
| 90 | 92 | ANIME: media ( |
| 91 | type: $type, | |
| 93 | type: $type, | |
| 92 | 94 | format_in: $format, |
| 93 | 95 | season: $season, |
| 94 | 96 | status: $status, |
| … | … | @@ -118,14 +120,14 @@ BASE_GRAPH = """ |
| 118 | 120 | |
| 119 | 121 | SEARCH = """ |
| 120 | 122 | query ( |
| 121 | $query: String, | |
| 122 | $page: Int, | |
| 123 | $query: String, | |
| 124 | $page: Int, | |
| 123 | 125 | $perPage: Int, |
| 124 | 126 | $format:[MediaFormat] |
| 125 | 127 | ) { |
| 126 | 128 | Page (page: $page, perPage: $perPage) { |
| 127 | 129 | ANIME: media ( |
| 128 | search: $query, | |
| 130 | search: $query, | |
| 129 | 131 | type: ANIME, |
| 130 | 132 | format_in: $format, |
| 131 | 133 | ) { |
| … | … | @@ -149,7 +151,7 @@ SEARCH = """ |
| 149 | 151 | SEARCH_ID = """ |
| 150 | 152 | query ($id: Int) { |
| 151 | 153 | Media ( |
| 152 | id: $id, | |
| 154 | id: $id, | |
| 153 | 155 | type: ANIME |
| 154 | 156 | ) { |
| 155 | 157 | id |
| … | … | @@ -172,7 +174,5 @@ SEARCH_ID = """ |
| 172 | 174 | def anilist_client(): |
| 173 | 175 | return AniList( |
| 174 | 176 | get_setting("anilist_client_id", "14375"), |
| 175 | get_setting( | |
| 176 | "anilist_client_secret", "tOJ5CJA9JM2pmJrHM8XaZgnM9XgL7HaLTM3krdML" | |
| 177 | ), | |
| 177 | get_setting("anilist_client_secret", "tOJ5CJA9JM2pmJrHM8XaZgnM9XgL7HaLTM3krdML"), | |
| 178 | 178 | ) |
| … | … | @@ -9,7 +9,7 @@ class AniZipApi: |
| 9 | 9 | res = self.make_request(url="mappings", params=params) |
| 10 | 10 | if res: |
| 11 | 11 | return res["mappings"] |
| 12 | ||
| 12 | ||
| 13 | 13 | def episodes(self, anilist_id): |
| 14 | 14 | params = {"anilist_id": anilist_id} |
| 15 | 15 | res = self.make_request(url="mappings", params=params) |
| … | … | @@ -1,6 +1,8 @@ |
| 1 | 1 | from abc import ABC, abstractmethod |
| 2 | from typing import Any, Callable, List, Optional | |
| 3 | ||
| 2 | 4 | from requests import Session |
| 3 | from typing import Any, List, Optional, Callable | |
| 5 | ||
| 4 | 6 | from lib.domain.torrent import TorrentStream |
| 5 | 7 | from lib.utils.kodi.utils import notification |
| 6 | 8 |
| … | … | @@ -1,10 +1,11 @@ |
| 1 | import threading | |
| 1 | 2 | from datetime import datetime |
| 2 | from typing import Dict, List, Any, Optional | |
| 3 | from typing import Any, Dict, List, Optional | |
| 3 | 4 | |
| 4 | 5 | from lib.api.debrid.alldebrid import AllDebrid |
| 5 | 6 | from lib.api.debrid.base import ProviderException |
| 6 | 7 | from lib.clients.debrid.common import get_file_name, get_packed_release_message |
| 7 | from lib.utils.kodi.utils import dialog_text, get_setting, kodilog, notification, translation | |
| 8 | from lib.domain.torrent import TorrentStream | |
| 8 | 9 | from lib.utils.general.utils import ( |
| 9 | 10 | DebridType, |
| 10 | 11 | IndexerType, |
| … | … | @@ -13,8 +14,13 @@ from lib.utils.general.utils import ( |
| 13 | 14 | info_hash_to_magnet, |
| 14 | 15 | supported_video_extensions, |
| 15 | 16 | ) |
| 16 | from lib.domain.torrent import TorrentStream | |
| 17 | import threading | |
| 17 | from lib.utils.kodi.utils import ( | |
| 18 | dialog_text, | |
| 19 | get_setting, | |
| 20 | kodilog, | |
| 21 | notification, | |
| 22 | translation, | |
| 23 | ) | |
| 18 | 24 | |
| 19 | 25 | |
| 20 | 26 | class AllDebridHelper: |
| … | … | @@ -33,9 +39,7 @@ class AllDebridHelper: |
| 33 | 39 | # Checks if torrents are cached in AllDebrid. |
| 34 | 40 | torr_available = self.client.get_user_torrent_list() |
| 35 | 41 | 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 [] | |
| 39 | 43 | ) |
| 40 | 44 | torr_available_hashes = [ |
| 41 | 45 | m.get("hash") for m in magnets if isinstance(m, dict) and m.get("hash") |
| … | … | @@ -49,7 +53,7 @@ class AllDebridHelper: |
| 49 | 53 | if res.infoHash in torr_available_hashes: |
| 50 | 54 | res.isCached = True |
| 51 | 55 | cached_results.append(res) |
| 52 | elif res.isCached == True: | |
| 56 | elif res.isCached: | |
| 53 | 57 | cached_results.append(res) |
| 54 | 58 | else: |
| 55 | 59 | res.isCached = False |
| … | … | @@ -58,9 +62,7 @@ class AllDebridHelper: |
| 58 | 62 | if get_setting("show_uncached"): |
| 59 | 63 | cached_results.extend(uncached_results) |
| 60 | 64 | |
| 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]]: | |
| 64 | 66 | |
| 65 | 67 | result = self.client.add_magnet(info_hash_to_magnet(info_hash)) |
| 66 | 68 | magnet = result.get("data", {}).get("magnets", [])[0] |
| … | … | @@ -98,9 +100,7 @@ class AllDebridHelper: |
| 98 | 100 | season = data["tv_data"].get("season", "") |
| 99 | 101 | episode = data["tv_data"].get("episode", "") |
| 100 | 102 | |
| 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) | |
| 104 | 104 | if not matched: |
| 105 | 105 | raise ProviderException("No matching episode found in torrent.") |
| 106 | 106 | |
| … | … | @@ -141,7 +141,6 @@ class AllDebridHelper: |
| 141 | 141 | |
| 142 | 142 | def get_pack_link(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: |
| 143 | 143 | """Gets a direct download link for a file inside a Real-Debrid torrent pack.""" |
| 144 | ||
| 145 | 144 | pack_info = data.get("pack_info", {}) |
| 146 | 145 | link = pack_info.get("url", "") |
| 147 | 146 | |
| … | … | @@ -170,8 +169,7 @@ class AllDebridHelper: |
| 170 | 169 | (f.get("l"), get_file_name(f)) |
| 171 | 170 | for f in flat_files |
| 172 | 171 | 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] | |
| 175 | 173 | ) |
| 176 | 174 | ] |
| 177 | 175 | if not files: |
| … | … | @@ -194,8 +192,8 @@ class AllDebridHelper: |
| 194 | 192 | try: |
| 195 | 193 | response = self.client.get_user_info() |
| 196 | 194 | 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 | |
| 199 | 197 | status = "Premium" if user["isPremium"] else "Not Active" |
| 200 | 198 | expires = datetime.fromtimestamp(user["premiumUntil"]) |
| 201 | 199 | days_remaining = (expires - datetime.today()).days |
| … | … | @@ -1,9 +1,8 @@ |
| 1 | 1 | from typing import Any, Dict, Optional |
| 2 | 2 | |
| 3 | 3 | from lib.api.debrid.base import ProviderException |
| 4 | from lib.utils.kodi.utils import translation | |
| 5 | 4 | from lib.utils.general.utils import supported_video_extensions |
| 6 | ||
| 5 | from lib.utils.kodi.utils import translation | |
| 7 | 6 | |
| 8 | 7 | PACKED_RELEASE_FALLBACK_TEMPLATE = ( |
| 9 | 8 | "{provider} cannot directly play packed releases (.rar/.zip). " |
| … | … | @@ -50,9 +49,7 @@ def ensure_direct_playable_file(filename: Optional[str]) -> None: |
| 50 | 49 | raise ProviderException(get_packed_release_message("This provider")) |
| 51 | 50 | |
| 52 | 51 | |
| 53 | def ensure_direct_playable_file_for_provider( | |
| 54 | filename: Optional[str], provider_name: str | |
| 55 | ) -> None: | |
| 52 | def ensure_direct_playable_file_for_provider(filename: Optional[str], provider_name: str) -> None: | |
| 56 | 53 | if not filename: |
| 57 | 54 | return |
| 58 | 55 |
| … | … | @@ -1,9 +1,10 @@ |
| 1 | 1 | from datetime import datetime, timezone |
| 2 | from typing import Dict, List, Any, Optional | |
| 2 | from typing import Any, Dict, List, Optional | |
| 3 | ||
| 3 | 4 | from lib.api.debrid.base import ProviderException |
| 4 | 5 | from lib.api.debrid.debrider import Debrider |
| 5 | 6 | from lib.clients.debrid.common import get_file_name, get_packed_release_message |
| 6 | from lib.utils.kodi.utils import dialog_text, get_setting, notification | |
| 7 | from lib.domain.torrent import TorrentStream | |
| 7 | 8 | from lib.utils.general.utils import ( |
| 8 | 9 | DebridType, |
| 9 | 10 | IndexerType, |
| … | … | @@ -15,14 +16,12 @@ from lib.utils.general.utils import ( |
| 15 | 16 | set_cached, |
| 16 | 17 | supported_video_extensions, |
| 17 | 18 | ) |
| 18 | from lib.domain.torrent import TorrentStream | |
| 19 | from lib.utils.kodi.utils import dialog_text, get_setting, notification | |
| 19 | 20 | |
| 20 | 21 | |
| 21 | 22 | class DebriderHelper: |
| 22 | 23 | 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()) | |
| 26 | 25 | |
| 27 | 26 | def check_cached( |
| 28 | 27 | self, |
| … | … | @@ -125,9 +124,7 @@ class DebriderHelper: |
| 125 | 124 | not response.get("message", "") |
| 126 | 125 | or "task added successfully" not in response.get("message", "").lower() |
| 127 | 126 | ): |
| 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}") | |
| 131 | 128 | except Exception as e: |
| 132 | 129 | notification(str(e)) |
| 133 | 130 | raise |
| … | … | @@ -1,8 +1,9 @@ |
| 1 | 1 | from datetime import datetime |
| 2 | from typing import Dict, List, Any, Optional | |
| 2 | from typing import Any, Dict, List, Optional | |
| 3 | ||
| 3 | 4 | from lib.api.debrid.easydebrid import EasyDebrid |
| 4 | 5 | from lib.clients.debrid.common import get_file_name, get_packed_release_message |
| 5 | from lib.utils.kodi.utils import dialog_text, get_setting, notification, translation | |
| 6 | from lib.domain.torrent import TorrentStream | |
| 6 | 7 | from lib.utils.general.utils import ( |
| 7 | 8 | DebridType, |
| 8 | 9 | IndexerType, |
| … | … | @@ -14,14 +15,12 @@ from lib.utils.general.utils import ( |
| 14 | 15 | set_cached, |
| 15 | 16 | supported_video_extensions, |
| 16 | 17 | ) |
| 17 | from lib.domain.torrent import TorrentStream | |
| 18 | from lib.utils.kodi.utils import dialog_text, get_setting, notification, translation | |
| 18 | 19 | |
| 19 | 20 | |
| 20 | 21 | class EasyDebridHelper: |
| 21 | 22 | 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()) | |
| 25 | 24 | |
| 26 | 25 | def check_cached( |
| 27 | 26 | self, |
| … | … | @@ -49,9 +48,7 @@ class EasyDebridHelper: |
| 49 | 48 | if res in filtered_results: |
| 50 | 49 | # Get index in filtered results for matching cached response |
| 51 | 50 | 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 | |
| 55 | 52 | res.isCached = bool(is_cached) |
| 56 | 53 | if res.isCached: |
| 57 | 54 | cached_results.append(res) |
| … | … | @@ -0,0 +1,168 @@ |
| 1 | import copy | |
| 2 | from typing import Any, Dict, List, Optional | |
| 3 | ||
| 4 | from lib.api.debrid.offcloud import Offcloud | |
| 5 | from lib.clients.debrid.common import get_file_name, get_packed_release_message | |
| 6 | from lib.domain.torrent import TorrentStream | |
| 7 | from 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 | ) | |
| 17 | from lib.utils.kodi.utils import dialog_text, get_setting, kodilog, notification | |
| 18 | ||
| 19 | EXTENSIONS = supported_video_extensions()[:-1] | |
| 20 | ||
| 21 | ||
| 22 | class 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)) |
| … | … | @@ -1,11 +1,12 @@ |
| 1 | 1 | import copy |
| 2 | from typing import Dict, List, Any, Optional | |
| 2 | from typing import Any, Dict, List, Optional | |
| 3 | ||
| 3 | 4 | from lib.api.debrid.premiumize import Premiumize |
| 4 | 5 | from lib.clients.debrid.common import ( |
| 5 | 6 | ensure_direct_playable_file_for_provider, |
| 6 | 7 | get_file_name, |
| 7 | 8 | ) |
| 8 | from lib.utils.kodi.utils import get_setting, kodilog, notification | |
| 9 | from lib.domain.torrent import TorrentStream | |
| 9 | 10 | from lib.utils.general.utils import ( |
| 10 | 11 | DebridType, |
| 11 | 12 | IndexerType, |
| … | … | @@ -16,7 +17,7 @@ from lib.utils.general.utils import ( |
| 16 | 17 | set_cached, |
| 17 | 18 | supported_video_extensions, |
| 18 | 19 | ) |
| 19 | from lib.domain.torrent import TorrentStream | |
| 20 | from lib.utils.kodi.utils import get_setting, kodilog, notification | |
| 20 | 21 | |
| 21 | 22 | |
| 22 | 23 | class PremiumizeHelper: |
| … | … | @@ -59,9 +60,15 @@ class PremiumizeHelper: |
| 59 | 60 | error_msg = response_data.get("message", "Unknown error") |
| 60 | 61 | kodilog(f"Failed to get link from Premiumize: {error_msg}") |
| 61 | 62 | 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 | ) | |
| 63 | 67 | 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 | ) | |
| 65 | 72 | else: |
| 66 | 73 | notification(f"Premiumize: {error_msg}", "Premiumize Error") |
| 67 | 74 | return None |
| … | … | @@ -71,9 +78,7 @@ class PremiumizeHelper: |
| 71 | 78 | if data["tv_data"]: |
| 72 | 79 | season = data["tv_data"].get("season", "") |
| 73 | 80 | 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) | |
| 77 | 82 | if not content: |
| 78 | 83 | return |
| 79 | 84 | data["url"] = content[0].get("stream_link") |
| … | … | @@ -82,9 +87,7 @@ class PremiumizeHelper: |
| 82 | 87 | data["is_pack"] = True |
| 83 | 88 | return data |
| 84 | 89 | 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") | |
| 88 | 91 | data["url"] = content[0].get("stream_link") |
| 89 | 92 | return data |
| 90 | 93 | |
| … | … | @@ -102,9 +105,15 @@ class PremiumizeHelper: |
| 102 | 105 | error_msg = response_data.get("message", "Unknown error") |
| 103 | 106 | kodilog(f"Failed to get pack info from Premiumize: {error_msg}") |
| 104 | 107 | 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 | ) | |
| 106 | 112 | 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 | ) | |
| 108 | 117 | else: |
| 109 | 118 | notification(f"Premiumize: {error_msg}", "Premiumize Error") |
| 110 | 119 | return None |
| … | … | @@ -2,7 +2,7 @@ import copy |
| 2 | 2 | import threading |
| 3 | 3 | import time |
| 4 | 4 | from datetime import datetime |
| 5 | from typing import Any, List, Dict, Optional, cast | |
| 5 | from typing import Any, Dict, List, Optional, cast | |
| 6 | 6 | |
| 7 | 7 | from lib.api.debrid.base import ProviderException |
| 8 | 8 | from lib.api.debrid.realdebrid import RealDebrid |
| … | … | @@ -10,12 +10,7 @@ from lib.clients.debrid.common import ( |
| 10 | 10 | ensure_direct_playable_file_for_provider, |
| 11 | 11 | get_file_name, |
| 12 | 12 | ) |
| 13 | from lib.utils.kodi.logging import kodilog | |
| 14 | from lib.utils.kodi.utils import ( | |
| 15 | get_setting, | |
| 16 | dialog_text, | |
| 17 | translation, | |
| 18 | ) | |
| 13 | from lib.domain.torrent import TorrentStream | |
| 19 | 14 | from lib.utils.general.utils import ( |
| 20 | 15 | DebridType, |
| 21 | 16 | IndexerType, |
| … | … | @@ -26,7 +21,12 @@ from lib.utils.general.utils import ( |
| 26 | 21 | set_cached, |
| 27 | 22 | supported_video_extensions, |
| 28 | 23 | ) |
| 29 | from lib.domain.torrent import TorrentStream | |
| 24 | from lib.utils.kodi.logging import kodilog | |
| 25 | from lib.utils.kodi.utils import ( | |
| 26 | dialog_text, | |
| 27 | get_setting, | |
| 28 | translation, | |
| 29 | ) | |
| 30 | 30 | |
| 31 | 31 | |
| 32 | 32 | class LinkNotFoundError(Exception): |
| … | … | @@ -49,9 +49,7 @@ class RealDebridHelper: |
| 49 | 49 | # Checks if torrents are cached in Real-Debrid. |
| 50 | 50 | torr_available = self.client.get_user_torrent_list() |
| 51 | 51 | 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") | |
| 55 | 53 | ] |
| 56 | 54 | |
| 57 | 55 | for res in copy.deepcopy(results): |
| … | … | @@ -62,7 +60,7 @@ class RealDebridHelper: |
| 62 | 60 | if res.infoHash in torr_available_hashes: |
| 63 | 61 | res.isCached = True |
| 64 | 62 | cached_results.append(res) |
| 65 | elif res.isCached == True: | |
| 63 | elif res.isCached: | |
| 66 | 64 | cached_results.append(res) |
| 67 | 65 | else: |
| 68 | 66 | res.isCached = False |
| … | … | @@ -75,9 +73,7 @@ class RealDebridHelper: |
| 75 | 73 | """Adds a magnet link to Real-Debrid and returns the torrent ID.""" |
| 76 | 74 | try: |
| 77 | 75 | 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}" | |
| 81 | 77 | ) |
| 82 | 78 | torrent_info = self.client.get_available_torrent(info_hash) |
| 83 | 79 | if not torrent_info: |
| … | … | @@ -91,7 +87,7 @@ class RealDebridHelper: |
| 91 | 87 | self._handle_torrent_status(torrent_info, is_pack) |
| 92 | 88 | return torrent_info.get("id") |
| 93 | 89 | except Exception as e: |
| 94 | raise ProviderException(str(e)) | |
| 90 | raise ProviderException(str(e)) from e | |
| 95 | 91 | |
| 96 | 92 | def add_torrent_file( |
| 97 | 93 | self, |
| … | … | @@ -116,11 +112,9 @@ class RealDebridHelper: |
| 116 | 112 | self._handle_torrent_status(torrent_info, is_pack) |
| 117 | 113 | return torrent_info.get("id") |
| 118 | 114 | except Exception as e: |
| 119 | raise ProviderException(str(e)) | |
| 115 | raise ProviderException(str(e)) from e | |
| 120 | 116 | |
| 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]: | |
| 124 | 118 | """Processes torrent_info status and handles errors or file selection.""" |
| 125 | 119 | status = torrent_info["status"] |
| 126 | 120 | torrent_id = torrent_info.get("id", "unknown") |
| … | … | @@ -128,10 +122,12 @@ class RealDebridHelper: |
| 128 | 122 | self.client.delete_torrent(torrent_info["id"]) |
| 129 | 123 | raise ProviderException(f"Torrent cannot be downloaded: {status}") |
| 130 | 124 | 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 | ) | |
| 132 | 128 | return torrent_id |
| 133 | 129 | elif status == "waiting_files_selection": |
| 134 | if "files" in torrent_info and torrent_info["files"]: | |
| 130 | if torrent_info.get("files"): | |
| 135 | 131 | self.handle_file_selection(torrent_info, is_pack) |
| 136 | 132 | else: |
| 137 | 133 | raise ProviderException("No files available for this torrent yet.") |
| … | … | @@ -142,9 +138,7 @@ class RealDebridHelper: |
| 142 | 138 | extensions = supported_video_extensions()[:-1] |
| 143 | 139 | |
| 144 | 140 | 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) | |
| 148 | 142 | ] |
| 149 | 143 | |
| 150 | 144 | if video_files: |
| … | … | @@ -156,9 +150,7 @@ class RealDebridHelper: |
| 156 | 150 | if torrents_ids: |
| 157 | 151 | self.client.select_files(torrent_info["id"], ",".join(torrents_ids)) |
| 158 | 152 | |
| 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]]: | |
| 162 | 154 | """Gets a direct download link for a Real-Debrid torrent.""" |
| 163 | 155 | torrent_id = self.add_magnet(info_hash) |
| 164 | 156 | torrent_info = self.client.get_torrent_info(torrent_id) |
| … | … | @@ -178,9 +170,7 @@ class RealDebridHelper: |
| 178 | 170 | # --- Single-file torrent --- |
| 179 | 171 | if len(links) == 1: |
| 180 | 172 | 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") | |
| 184 | 174 | data["url"] = create_download_for_link(links[0]) |
| 185 | 175 | return data |
| 186 | 176 | |
| … | … | @@ -190,27 +180,21 @@ class RealDebridHelper: |
| 190 | 180 | season = tv_data.get("season") |
| 191 | 181 | episode = tv_data.get("episode") |
| 192 | 182 | |
| 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) | |
| 196 | 184 | if not possible_matches: |
| 197 | 185 | raise ProviderException("No matching episode found in torrent.") |
| 198 | 186 | |
| 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) | |
| 202 | 188 | if not match_file: |
| 203 | 189 | raise ValueError("File is not cached") |
| 204 | 190 | |
| 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") | |
| 208 | 192 | |
| 209 | 193 | selected_files = [f for f in files if f.get("selected") == 1] |
| 210 | 194 | try: |
| 211 | 195 | 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 | |
| 214 | 198 | |
| 215 | 199 | data["url"] = create_download_for_link(links[file_index]) |
| 216 | 200 | return data |
| … | … | @@ -218,9 +202,7 @@ class RealDebridHelper: |
| 218 | 202 | selected_files = [f for f in files if f.get("selected") == 1] |
| 219 | 203 | if selected_files: |
| 220 | 204 | 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") | |
| 224 | 206 | try: |
| 225 | 207 | file_index = selected_files.index(largest_file) |
| 226 | 208 | except ValueError: |
| … | … | @@ -235,7 +217,6 @@ class RealDebridHelper: |
| 235 | 217 | |
| 236 | 218 | def get_pack_link(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: |
| 237 | 219 | """Gets a direct download link for a file inside a Real-Debrid torrent pack.""" |
| 238 | ||
| 239 | 220 | pack_info = cast(Dict[str, Any], data.get("pack_info", {}) or {}) |
| 240 | 221 | file_position = pack_info.get("file_position", "") |
| 241 | 222 | torrent_id = pack_info.get("torrent_id", "") |
| … | … | @@ -281,9 +262,7 @@ class RealDebridHelper: |
| 281 | 262 | try: |
| 282 | 263 | expires = datetime.strptime(expiration, "%Y-%m-%dT%H:%M:%S.%fZ") |
| 283 | 264 | 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])) | |
| 287 | 266 | |
| 288 | 267 | days_remaining = (expires - datetime.today()).days |
| 289 | 268 | body = [ |
| … | … | @@ -1,8 +1,10 @@ |
| 1 | 1 | import copy |
| 2 | from typing import Dict, List, Any, Optional | |
| 2 | import time | |
| 3 | from typing import Any, Dict, List, Optional | |
| 4 | ||
| 3 | 5 | from lib.api.debrid.torbox import Torbox |
| 4 | 6 | from lib.clients.debrid.common import get_file_name, get_packed_release_message |
| 5 | from lib.utils.kodi.utils import get_setting, notification, dialog_text, kodilog, translation | |
| 7 | from lib.domain.torrent import TorrentStream | |
| 6 | 8 | from lib.utils.general.utils import ( |
| 7 | 9 | DebridType, |
| 8 | 10 | IndexerType, |
| … | … | @@ -13,7 +15,13 @@ from lib.utils.general.utils import ( |
| 13 | 15 | set_cached, |
| 14 | 16 | supported_video_extensions, |
| 15 | 17 | ) |
| 16 | from lib.domain.torrent import TorrentStream | |
| 18 | from lib.utils.kodi.utils import ( | |
| 19 | dialog_text, | |
| 20 | get_setting, | |
| 21 | kodilog, | |
| 22 | notification, | |
| 23 | translation, | |
| 24 | ) | |
| 17 | 25 | |
| 18 | 26 | EXTENSIONS = supported_video_extensions()[:-1] |
| 19 | 27 | |
| … | … | @@ -42,9 +50,7 @@ class TorboxHelper: |
| 42 | 50 | |
| 43 | 51 | if hashes: |
| 44 | 52 | 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" | |
| 48 | 54 | ) |
| 49 | 55 | response = self.client.get_torrent_instant_availability(hashes) |
| 50 | 56 | raw_data = response.get("data", []) |
| … | … | @@ -58,9 +64,7 @@ class TorboxHelper: |
| 58 | 64 | missing_hashes = len(results) - len(hashes) |
| 59 | 65 | if missing_hashes: |
| 60 | 66 | 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" | |
| 64 | 68 | ) |
| 65 | 69 | |
| 66 | 70 | for res in copy.deepcopy(results): |
| … | … | @@ -78,9 +82,7 @@ class TorboxHelper: |
| 78 | 82 | |
| 79 | 83 | def add_torbox_torrent(self, info_hash): |
| 80 | 84 | 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}" | |
| 84 | 86 | ) |
| 85 | 87 | torrent_info = self.client.get_available_torrent(info_hash) |
| 86 | 88 | if ( |
| … | … | @@ -99,8 +101,10 @@ class TorboxHelper: |
| 99 | 101 | |
| 100 | 102 | if "Found Cached" in response.get("detail", ""): |
| 101 | 103 | 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 | ) | |
| 104 | 108 | return response |
| 105 | 109 | |
| 106 | 110 | def add_torrent_file(self, torrent_data: bytes, torrent_name: str = "torrent.torrent"): |
| … | … | @@ -164,13 +168,7 @@ class TorboxHelper: |
| 164 | 168 | ) |
| 165 | 169 | |
| 166 | 170 | 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}" | |
| 174 | 172 | ) |
| 175 | 173 | |
| 176 | 174 | return downloads |
| … | … | @@ -180,16 +178,12 @@ class TorboxHelper: |
| 180 | 178 | file_id = data.get("file_id", "") |
| 181 | 179 | |
| 182 | 180 | 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()) | |
| 186 | 182 | if response_data: |
| 187 | 183 | data["url"] = response_data.get("data", {}) |
| 188 | 184 | return data |
| 189 | 185 | 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}" | |
| 193 | 187 | ) |
| 194 | 188 | return None |
| 195 | 189 | |
| … | … | @@ -197,9 +191,30 @@ class TorboxHelper: |
| 197 | 191 | if not torrent_info: |
| 198 | 192 | return None |
| 199 | 193 | |
| 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 | ||
| 200 | 215 | video_files = [ |
| 201 | 216 | item |
| 202 | for item in torrent_info["files"] | |
| 217 | for item in torrent_files | |
| 203 | 218 | if any(get_file_name(item).lower().endswith(ext) for ext in EXTENSIONS) |
| 204 | 219 | ] |
| 205 | 220 | if not video_files: |
| … | … | @@ -263,16 +278,11 @@ class TorboxHelper: |
| 263 | 278 | user = response.get("data", {}) |
| 264 | 279 | customer_email = user.get("customer_email", "") |
| 265 | 280 | plan = user.get("plan", 0) |
| 266 | ||
| 281 | ||
| 267 | 282 | # 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"} | |
| 274 | 284 | plan_name = plans.get(plan, f"Plan {plan}") |
| 275 | ||
| 285 | ||
| 276 | 286 | body = [ |
| 277 | 287 | f"[B]Account:[/B] {customer_email}", |
| 278 | 288 | f"[B]Plan:[/B] {plan_name}", |
| … | … | @@ -282,5 +292,5 @@ class TorboxHelper: |
| 282 | 292 | |
| 283 | 293 | days = self.client.days_remaining() |
| 284 | 294 | if days is not None: |
| 285 | body.append(f"[B]Days Remaining:[/B] {days}") | |
| 295 | body.append(f"[B]Days Remaining:[/B] {days}") | |
| 286 | 296 | dialog_text(translation(90656), "\n".join(body)) |
| … | … | @@ -1,9 +1,10 @@ |
| 1 | 1 | import re |
| 2 | from typing import Any, Callable, List, Optional, Tuple | |
| 2 | 3 | from urllib.parse import quote |
| 4 | ||
| 3 | 5 | from lib.clients.base import BaseClient, TorrentStream |
| 6 | from lib.utils.general.utils import Indexer, IndexerType | |
| 4 | 7 | from lib.utils.kodi.utils import kodilog |
| 5 | from lib.utils.general.utils import IndexerType, Indexer | |
| 6 | from typing import List, Optional, Any, Callable, Tuple | |
| 7 | 8 | |
| 8 | 9 | VIDEO_EXTENSIONS = ( |
| 9 | 10 | "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 = { |
| 31 | 32 | |
| 32 | 33 | |
| 33 | 34 | class 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: | |
| 37 | 36 | super().__init__("https://members.easynews.com", notification) |
| 38 | 37 | self.user = user |
| 39 | 38 | self.password = password |
| … | … | @@ -47,6 +46,7 @@ class Easynews(BaseClient): |
| 47 | 46 | media_type: str, |
| 48 | 47 | season: Optional[int] = None, |
| 49 | 48 | episode: Optional[int] = None, |
| 49 | **kwargs: Any, | |
| 50 | 50 | ) -> List[TorrentStream]: |
| 51 | 51 | search_query = query |
| 52 | 52 | if mode == "tv" and season: |
| … | … | @@ -107,9 +107,7 @@ class Easynews(BaseClient): |
| 107 | 107 | if re.match(r"^\d+s", duration) or re.match(r"^[0-5]m", duration): |
| 108 | 108 | continue |
| 109 | 109 | |
| 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}") | |
| 113 | 111 | title = post_title |
| 114 | 112 | |
| 115 | 113 | results.append( |
| … | … | @@ -182,6 +180,7 @@ class Easynews(BaseClient): |
| 182 | 180 | |
| 183 | 181 | def get_info(self) -> None: |
| 184 | 182 | from datetime import datetime |
| 183 | ||
| 185 | 184 | from lib.utils.kodi.utils import dialog_text, kodilog, translation |
| 186 | 185 | |
| 187 | 186 | try: |
| … | … | @@ -1,5 +1,6 @@ |
| 1 | 1 | import re |
| 2 | from typing import List, Dict, Any, Optional, Callable | |
| 2 | from typing import Any, Callable, Dict, List, Optional | |
| 3 | ||
| 3 | 4 | from lib.clients.base import BaseClient, TorrentStream |
| 4 | 5 | from lib.utils.kodi.utils import convert_size_to_bytes, translation |
| 5 | 6 | |
| … | … | @@ -24,14 +25,14 @@ class Elfhosted(BaseClient): |
| 24 | 25 | else: |
| 25 | 26 | self.handle_exception(translation(30231)) |
| 26 | 27 | return None |
| 27 | ||
| 28 | ||
| 28 | 29 | res = self.session.get(url, timeout=10) |
| 29 | 30 | if res.status_code != 200: |
| 30 | 31 | return |
| 31 | 32 | response = self.parse_response(res) |
| 32 | 33 | return response |
| 33 | 34 | except Exception as e: |
| 34 | self.handle_exception(f"{translation(30231)}: {str(e)}") | |
| 35 | self.handle_exception(f"{translation(30231)}: {e!s}") | |
| 35 | 36 | |
| 36 | 37 | def parse_response(self, res: Any) -> List[TorrentStream]: |
| 37 | 38 | res = res.json() |
| … | … | @@ -0,0 +1,311 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | import sys | |
| 4 | from typing import Any, Callable, List, Optional | |
| 5 | ||
| 6 | from lib.clients.base import BaseClient | |
| 7 | from lib.domain.torrent import TorrentStream | |
| 8 | from lib.utils.general.utils import Indexer | |
| 9 | from lib.utils.kodi.utils import kodilog | |
| 10 | ||
| 11 | _BYTES_PER_GB = 1073741824 | |
| 12 | ||
| 13 | ||
| 14 | class 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 [] |
| … | … | @@ -1,4 +1,5 @@ |
| 1 | 1 | import re |
| 2 | ||
| 2 | 3 | import requests |
| 3 | 4 | |
| 4 | 5 | |
| … | … | @@ -37,13 +38,9 @@ def extract_season(res): |
| 37 | 38 | if name is not None |
| 38 | 39 | ] |
| 39 | 40 | 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")] | |
| 43 | 42 | |
| 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")] | |
| 47 | 44 | |
| 48 | 45 | s_ids = [s[0] for s in s_ids if s] |
| 49 | 46 | |
| … | … | @@ -51,10 +48,8 @@ def extract_season(res): |
| 51 | 48 | regex = r"\s(\d+)$" |
| 52 | 49 | cour = False |
| 53 | 50 | 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()): | |
| 58 | 53 | cour = True |
| 59 | 54 | break |
| 60 | 55 | if not cour: |
| … | … | @@ -63,23 +58,15 @@ def extract_season(res): |
| 63 | 58 | for lang, name in res.get("title").items() |
| 64 | 59 | if name is not None |
| 65 | 60 | ] |
| 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")] | |
| 70 | 62 | else: |
| 71 | 63 | for name in res.get("title"): |
| 72 | 64 | if " part " in name.lower() or " cour " in name.lower(): |
| 73 | 65 | cour = True |
| 74 | 66 | break |
| 75 | 67 | 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")] | |
| 83 | 70 | s_ids = [s[0] for s in s_ids if s and int(s[0]) < 20] |
| 84 | 71 | |
| 85 | 72 | return s_ids |
| … | … | @@ -1,5 +1,7 @@ |
| 1 | import requests | |
| 2 | 1 | from datetime import timedelta |
| 2 | ||
| 3 | import requests | |
| 4 | ||
| 3 | 5 | from lib.db.cached import MemoryCache |
| 4 | 6 | from lib.utils.kodi.utils import kodilog |
| 5 | 7 | |
| … | … | @@ -14,6 +16,7 @@ _SENTINEL = "__introdb_none__" |
| 14 | 16 | def get_segments(imdb_id, season, episode): |
| 15 | 17 | """ |
| 16 | 18 | Fetch intro/recap/outro segment timestamps from IntroDB for a given episode. |
| 19 | ||
| 17 | 20 | Results are cached in memory for 24 hours to avoid redundant API calls. |
| 18 | 21 | |
| 19 | 22 | Args: |
| … | … | @@ -26,6 +29,7 @@ def get_segments(imdb_id, season, episode): |
| 26 | 29 | or None if the request fails or no data is available. |
| 27 | 30 | """ |
| 28 | 31 | if not imdb_id or not season or not episode: |
| 32 | ||
| 29 | 33 | return None |
| 30 | 34 | |
| 31 | 35 | cache_key = f"{imdb_id}.S{season}E{episode}" |
| … | … | @@ -43,9 +47,7 @@ def get_segments(imdb_id, season, episode): |
| 43 | 47 | if cached == _SENTINEL: |
| 44 | 48 | kodilog(f"IntroDB: Cache hit (no data) for {imdb_id} S{season}E{episode}") |
| 45 | 49 | 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}") | |
| 49 | 51 | return cached |
| 50 | 52 | |
| 51 | 53 | try: |
| … | … | @@ -89,7 +91,5 @@ def get_segments(imdb_id, season, episode): |
| 89 | 91 | kodilog(f"IntroDB: Request failed for {imdb_id} S{season}E{episode}: {e}") |
| 90 | 92 | return None |
| 91 | 93 | 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}") | |
| 95 | 95 | return None |
| … | … | @@ -1,13 +1,14 @@ |
| 1 | from urllib.parse import quote | |
| 2 | from typing import Any, List, Optional | |
| 3 | 1 | import concurrent |
| 2 | from typing import Any, List, Optional | |
| 3 | from urllib.parse import quote | |
| 4 | ||
| 4 | 5 | import requests |
| 5 | 6 | |
| 6 | 7 | from lib.clients.base import BaseClient |
| 7 | 8 | from lib.domain.torrent import TorrentStream |
| 8 | 9 | from lib.jacktook.utils import kodilog |
| 9 | from lib.utils.kodi.logging import summarize_locator_for_log | |
| 10 | 10 | from lib.utils.general.utils import USER_AGENT_HEADER |
| 11 | from lib.utils.kodi.logging import summarize_locator_for_log | |
| 11 | 12 | from lib.utils.kodi.settings import get_jackett_timeout |
| 12 | 13 | from lib.utils.kodi.utils import get_setting, notification, translation |
| 13 | 14 | from lib.utils.parsers import xmltodict |
| … | … | @@ -97,9 +98,7 @@ class Jackett(BaseClient): |
| 97 | 98 | year=year, |
| 98 | 99 | ) |
| 99 | 100 | 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()) | |
| 103 | 102 | ) |
| 104 | 103 | if get_setting("include_season_packs"): |
| 105 | 104 | url_season = self._build_url( |
| … | … | @@ -132,9 +131,7 @@ class Jackett(BaseClient): |
| 132 | 131 | year=year, |
| 133 | 132 | ) |
| 134 | 133 | 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()) | |
| 138 | 135 | ) |
| 139 | 136 | |
| 140 | 137 | for future in concurrent.futures.as_completed(futures): |
| … | … | @@ -147,7 +144,7 @@ class Jackett(BaseClient): |
| 147 | 144 | notification(f"{translation(30229)} ({response.status_code})") |
| 148 | 145 | return results if results else None |
| 149 | 146 | except Exception as e: |
| 150 | self.handle_exception(f"{translation(30229)}: {str(e)}") | |
| 147 | self.handle_exception(f"{translation(30229)}: {e!s}") | |
| 151 | 148 | return None |
| 152 | 149 | |
| 153 | 150 | def search( |
| … | … | @@ -173,7 +170,7 @@ class Jackett(BaseClient): |
| 173 | 170 | year=year, |
| 174 | 171 | ) |
| 175 | 172 | except Exception as e: |
| 176 | self.handle_exception(f"{translation(30229)}: {str(e)}") | |
| 173 | self.handle_exception(f"{translation(30229)}: {e!s}") | |
| 177 | 174 | return None |
| 178 | 175 | |
| 179 | 176 | def parse_response(self, res: Any) -> Optional[List[TorrentStream]]: |
| … | … | @@ -190,7 +187,7 @@ class Jackett(BaseClient): |
| 190 | 187 | kodilog(f"Parsed {len(results)} results from Jackett") |
| 191 | 188 | return results |
| 192 | 189 | 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}") | |
| 194 | 191 | return None |
| 195 | 192 | |
| 196 | 193 | def extract_result(self, results: List[TorrentStream], item: dict) -> None: |
| … | … | @@ -1,12 +1,11 @@ |
| 1 | from typing import List, Dict, Optional, Any, Callable | |
| 1 | from typing import Any, Callable, Dict, List, Optional | |
| 2 | ||
| 2 | 3 | from lib.clients.base import BaseClient, TorrentStream |
| 3 | 4 | from lib.utils.kodi.utils import kodilog, translation |
| 4 | 5 | |
| 5 | 6 | |
| 6 | 7 | class 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: | |
| 10 | 9 | super().__init__(host, notification) |
| 11 | 10 | self.token = token |
| 12 | 11 | if token: |
| … | … | @@ -20,6 +19,7 @@ class Jackgram(BaseClient): |
| 20 | 19 | media_type: str, |
| 21 | 20 | season: Optional[int], |
| 22 | 21 | episode: Optional[int], |
| 22 | **kwargs: Any, | |
| 23 | 23 | ) -> Optional[List[TorrentStream]]: |
| 24 | 24 | try: |
| 25 | 25 | if mode == "tv" or media_type == "tv": |
| … | … | @@ -1,3 +1,7 @@ |
| 1 | import json | |
| 2 | ||
| 3 | from xbmcplugin import addDirectoryItem | |
| 4 | ||
| 1 | 5 | from lib.clients.tmdb.utils.utils import tmdb_get |
| 2 | 6 | from lib.utils.general.utils import ( |
| 3 | 7 | Indexer, |
| … | … | @@ -9,7 +13,6 @@ from lib.utils.general.utils import ( |
| 9 | 13 | set_media_infoTag, |
| 10 | 14 | set_watched_title, |
| 11 | 15 | ) |
| 12 | ||
| 13 | 16 | from lib.utils.kodi.utils import ( |
| 14 | 17 | ADDON_HANDLE, |
| 15 | 18 | apply_section_view, |
| … | … | @@ -22,9 +25,6 @@ from lib.utils.kodi.utils import ( |
| 22 | 25 | translation, |
| 23 | 26 | ) |
| 24 | 27 | |
| 25 | from xbmcplugin import addDirectoryItem | |
| 26 | import json | |
| 27 | ||
| 28 | 28 | |
| 29 | 29 | def check_jackgram_active(): |
| 30 | 30 | jackgram_enabled = get_setting("jackgram_enabled") |
| … | … | @@ -49,9 +49,7 @@ def list_jackgram_latest_movies(query): |
| 49 | 49 | end_of_directory(cache=False) |
| 50 | 50 | return |
| 51 | 51 | 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) | |
| 55 | 53 | |
| 56 | 54 | |
| 57 | 55 | def list_jackgram_latest_series(query): |
| … | … | @@ -61,9 +59,7 @@ def list_jackgram_latest_series(query): |
| 61 | 59 | end_of_directory(cache=False) |
| 62 | 60 | return |
| 63 | 61 | 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) | |
| 67 | 63 | |
| 68 | 64 | |
| 69 | 65 | def list_jackgram_raw_files(query): |
| … | … | @@ -73,9 +69,7 @@ def list_jackgram_raw_files(query): |
| 73 | 69 | end_of_directory(cache=False) |
| 74 | 70 | return |
| 75 | 71 | 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) | |
| 79 | 73 | |
| 80 | 74 | |
| 81 | 75 | def add_jackgram_raw_file_item(item): |
| … | … | @@ -109,8 +103,8 @@ def add_jackgram_title_item(entry): |
| 109 | 103 | # Normalize mode |
| 110 | 104 | mode = "movies" if api_type == "movie" else api_type |
| 111 | 105 | |
| 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") | |
| 114 | 108 | entry["ids"] = {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id} |
| 115 | 109 | |
| 116 | 110 | list_item = make_list_item(label=title) |
| … | … | @@ -1,4 +1,7 @@ |
| 1 | 1 | from urllib.parse import quote |
| 2 | ||
| 3 | from xbmcplugin import addDirectoryItem | |
| 4 | ||
| 2 | 5 | from lib.clients.tmdb.utils.utils import mdblist_get, tmdb_get |
| 3 | 6 | from lib.utils.general.utils import ( |
| 4 | 7 | build_list_item, |
| … | … | @@ -15,8 +18,6 @@ from lib.utils.kodi.utils import ( |
| 15 | 18 | translation, |
| 16 | 19 | ) |
| 17 | 20 | |
| 18 | from xbmcplugin import addDirectoryItem | |
| 19 | ||
| 20 | 21 | |
| 21 | 22 | def search_mdbd_lists(params): |
| 22 | 23 | mode = params.get("mode", "movie") |
| … | … | @@ -1,15 +1,16 @@ |
| 1 | 1 | import re |
| 2 | from typing import List, Dict, Any, Optional, Callable | |
| 2 | from typing import Any, Callable, Dict, List, Optional | |
| 3 | ||
| 4 | from requests.utils import urlparse | |
| 3 | 5 | |
| 4 | 6 | from lib.clients.base import BaseClient, TorrentStream |
| 5 | from lib.utils.kodi.utils import convert_size_to_bytes, get_setting, translation | |
| 6 | 7 | from lib.utils.general.utils import ( |
| 7 | 8 | MEDIA_FUSION_DEFAULT_KEY, |
| 8 | 9 | USER_AGENT_HEADER, |
| 9 | 10 | get_cached, |
| 10 | 11 | set_cached, |
| 11 | 12 | ) |
| 12 | from requests.utils import urlparse | |
| 13 | from lib.utils.kodi.utils import convert_size_to_bytes, get_setting, translation | |
| 13 | 14 | |
| 14 | 15 | |
| 15 | 16 | class MediaFusion(BaseClient): |
| … | … | @@ -31,9 +32,7 @@ class MediaFusion(BaseClient): |
| 31 | 32 | } |
| 32 | 33 | path = self.session.post(self.encryption_url, json=config_json, timeout=4.0) |
| 33 | 34 | 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("/") | |
| 37 | 36 | set_cached(data=api_key, path="md.rd.key") |
| 38 | 37 | return api_key |
| 39 | 38 | else: |
| … | … | @@ -69,13 +68,13 @@ class MediaFusion(BaseClient): |
| 69 | 68 | else: |
| 70 | 69 | self.handle_exception(translation(30233)) |
| 71 | 70 | return None |
| 72 | ||
| 71 | ||
| 73 | 72 | res = self.session.get(url, headers=USER_AGENT_HEADER, timeout=10) |
| 74 | 73 | if res.status_code != 200: |
| 75 | 74 | return |
| 76 | 75 | return self.parse_response(res.json()) |
| 77 | 76 | except Exception as e: |
| 78 | self.handle_exception(f"{translation(30233)}: {str(e)}") | |
| 77 | self.handle_exception(f"{translation(30233)}: {e!s}") | |
| 79 | 78 | |
| 80 | 79 | def parse_response(self, res: Dict[str, Any]) -> List[TorrentStream]: |
| 81 | 80 | results = [] |
| … | … | @@ -1,4 +1,5 @@ |
| 1 | 1 | import re |
| 2 | ||
| 2 | 3 | from lib.clients.base import BaseClient |
| 3 | 4 | from lib.utils.general.utils import USER_AGENT_HEADER |
| 4 | 5 | from lib.utils.kodi.utils import translation |
| … | … | @@ -20,7 +21,7 @@ class Peerflix(BaseClient): |
| 20 | 21 | return |
| 21 | 22 | return self.parse_response(res) |
| 22 | 23 | except Exception as e: |
| 23 | self.handle_exception(f"{translation(30234)}: {str(e)}") | |
| 24 | self.handle_exception(f"{translation(30234)}: {e!s}") | |
| 24 | 25 | |
| 25 | 26 | def parse_response(self, res): |
| 26 | 27 | res = res.json() |
| … | … | @@ -33,7 +34,7 @@ class Peerflix(BaseClient): |
| 33 | 34 | "indexer": "Peerflix", |
| 34 | 35 | "guid": item["infoHash"], |
| 35 | 36 | "infoHash": item["infoHash"], |
| 36 | "size":item["sizebytes"] or 0, | |
| 37 | "size": item["sizebytes"] or 0, | |
| 37 | 38 | "seeders": item.get("seed", 0) or 0, |
| 38 | 39 | "languages": [item["language"]], |
| 39 | 40 | "fullLanguages": [item["language"]], |
| … | … | @@ -1,17 +1,15 @@ |
| 1 | import concurrent.futures | |
| 2 | from typing import Any, Callable, Dict, List, Optional | |
| 3 | ||
| 1 | 4 | from lib.clients.base import BaseClient, TorrentStream |
| 2 | 5 | from lib.jacktook.utils import kodilog |
| 3 | 6 | from lib.utils.kodi.logging import summarize_locator_for_log |
| 4 | from lib.utils.kodi.utils import get_setting, notification, translation | |
| 5 | 7 | from lib.utils.kodi.settings import get_prowlarr_timeout |
| 6 | ||
| 7 | from typing import Dict, List, Optional, Any, Callable | |
| 8 | import concurrent.futures | |
| 8 | from lib.utils.kodi.utils import get_setting, notification, translation | |
| 9 | 9 | |
| 10 | 10 | |
| 11 | 11 | class 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: | |
| 15 | 13 | super().__init__(host, notification) |
| 16 | 14 | self.base_url = f"{self.host}:{port}/api/v1/search" |
| 17 | 15 | self.apikey = apikey |
| … | … | @@ -52,9 +50,7 @@ class Prowlarr(BaseClient): |
| 52 | 50 | } |
| 53 | 51 | if indexers: |
| 54 | 52 | 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() | |
| 58 | 54 | ] |
| 59 | 55 | if indexer_ids: |
| 60 | 56 | params_ep["indexerIds"] = indexer_ids |
| … | … | @@ -76,9 +72,7 @@ class Prowlarr(BaseClient): |
| 76 | 72 | } |
| 77 | 73 | if indexers: |
| 78 | 74 | 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() | |
| 82 | 76 | ] |
| 83 | 77 | if indexer_ids: |
| 84 | 78 | params_season["indexerIds"] = indexer_ids |
| … | … | @@ -102,9 +96,7 @@ class Prowlarr(BaseClient): |
| 102 | 96 | params["categories"] = [2000, 5070, 5000, 127720, 140679] |
| 103 | 97 | if indexers: |
| 104 | 98 | 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() | |
| 108 | 100 | ] |
| 109 | 101 | if indexer_ids: |
| 110 | 102 | params["indexerIds"] = indexer_ids |
| … | … | @@ -128,7 +120,7 @@ class Prowlarr(BaseClient): |
| 128 | 120 | results.extend(res) |
| 129 | 121 | return results if results else None |
| 130 | 122 | except Exception as e: |
| 131 | self.handle_exception(f"{translation(30230)}: {str(e)}") | |
| 123 | self.handle_exception(f"{translation(30230)}: {e!s}") | |
| 132 | 124 | return None |
| 133 | 125 | |
| 134 | 126 | @staticmethod |
| … | … | @@ -1,18 +1,18 @@ |
| 1 | 1 | import json |
| 2 | ||
| 2 | 3 | import requests |
| 3 | 4 | |
| 5 | ||
| 4 | 6 | class SIMKL: |
| 5 | 7 | def __init__(self): |
| 6 | self.ClientID = ( | |
| 7 | "59dfdc579d244e1edf6f89874d521d37a69a95a1abd349910cb056a1872ba2c8" | |
| 8 | ) | |
| 8 | self.ClientID = "59dfdc579d244e1edf6f89874d521d37a69a95a1abd349910cb056a1872ba2c8" | |
| 9 | 9 | self.base_url = "https://api.simkl.com/" |
| 10 | 10 | self.imagePath = "https://wsrv.nl/?url=https://simkl.in/episodes/%s_w.webp" |
| 11 | 11 | |
| 12 | 12 | def _to_url(self, url=""): |
| 13 | 13 | if url.startswith("/"): |
| 14 | 14 | url = url[1:] |
| 15 | return "%s/%s" % (self.base_url[:-1], url) | |
| 15 | return f"{self.base_url[:-1]}/{url}" | |
| 16 | 16 | |
| 17 | 17 | def make_request(self, endpoint, params): |
| 18 | 18 | res = requests.get( |
| … | … | @@ -1,17 +1,16 @@ |
| 1 | import re | |
| 2 | from typing import Any, Dict, List, Optional | |
| 3 | ||
| 1 | 4 | from lib.api.stremio.addon_manager import Addon, build_addon_instance_label |
| 2 | from lib.api.stremio.models import Stream, Meta, MetaPreview | |
| 5 | from lib.api.stremio.models import Meta, MetaPreview, Stream | |
| 3 | 6 | from lib.clients.base import BaseClient, TorrentStream |
| 4 | ||
| 7 | from lib.clients.stremio.helpers import get_addon_display_name | |
| 5 | 8 | from lib.utils.debrid.debrid_utils import process_external_cache |
| 6 | 9 | from lib.utils.general.utils import USER_AGENT_HEADER, IndexerType, info_hash_to_magnet |
| 7 | 10 | from lib.utils.kodi.settings import get_int_setting |
| 8 | 11 | from lib.utils.kodi.utils import convert_size_to_bytes, get_setting, kodilog |
| 9 | 12 | from lib.utils.localization.language_detection import find_languages_in_string |
| 10 | 13 | |
| 11 | import re | |
| 12 | from typing import List, Dict, Optional, Any | |
| 13 | ||
| 14 | ||
| 15 | 14 | EXCLUDED_RD_ADDONS = ["org.nuvio.streams", "org.mycine.addon"] |
| 16 | 15 | |
| 17 | 16 | |
| … | … | @@ -107,7 +106,15 @@ class StremioAddonClient(BaseClient): |
| 107 | 106 | def __init__(self, addon: Addon) -> None: |
| 108 | 107 | super().__init__(None, None) |
| 109 | 108 | 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] | |
| 111 | 118 | |
| 112 | 119 | def search( |
| 113 | 120 | self, |
| … | … | @@ -145,17 +152,13 @@ class StremioAddonClient(BaseClient): |
| 145 | 152 | if res.status_code != 200: |
| 146 | 153 | return [] |
| 147 | 154 | 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") | |
| 151 | 156 | return response |
| 152 | 157 | 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}") | |
| 154 | 159 | return [] |
| 155 | 160 | |
| 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]: | |
| 159 | 162 | if not is_external_cache: |
| 160 | 163 | res_json = res.json() |
| 161 | 164 | streams = res_json.get("streams", []) |
| … | … | @@ -175,9 +178,7 @@ class StremioAddonClient(BaseClient): |
| 175 | 178 | |
| 176 | 179 | for item in streams: |
| 177 | 180 | 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 "") | |
| 181 | 182 | |
| 182 | 183 | if is_external_cache: |
| 183 | 184 | match = re.search(r"\b\w{40}\b", stream.url or "") |
| … | … | @@ -187,7 +188,7 @@ class StremioAddonClient(BaseClient): |
| 187 | 188 | else: |
| 188 | 189 | info_hash = stream.infoHash |
| 189 | 190 | url = stream.url |
| 190 | is_cached = True if url else False | |
| 191 | is_cached = bool(url) | |
| 191 | 192 | |
| 192 | 193 | stream_type = IndexerType.STREMIO_DEBRID if url else IndexerType.TORRENT |
| 193 | 194 | stream_provider = stream.get_provider() or parsed["provider"] |
| … | … | @@ -199,10 +200,11 @@ class StremioAddonClient(BaseClient): |
| 199 | 200 | TorrentStream( |
| 200 | 201 | title=stream.get_parsed_title(), |
| 201 | 202 | type=stream_type, |
| 202 | indexer=self.addon.manifest.name.split(" ")[0], | |
| 203 | indexer=self.indexer_name, | |
| 203 | 204 | subindexer=stream_subindexer, |
| 204 | 205 | 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, | |
| 206 | 208 | addonInstanceLabel=self.instance_label, |
| 207 | 209 | guid=info_hash_to_magnet(info_hash) if info_hash else "", |
| 208 | 210 | infoHash=info_hash if info_hash else "", |
| … | … | @@ -229,14 +231,14 @@ class StremioAddonClient(BaseClient): |
| 229 | 231 | "languages": [], |
| 230 | 232 | } |
| 231 | 233 | # 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) | |
| 234 | 236 | size = size_match.group(1) if size_match else None |
| 235 | 237 | if size: |
| 236 | 238 | size = convert_size_to_bytes(size) |
| 237 | 239 | |
| 238 | 240 | # Extract seeders |
| 239 | seeders_pattern = r"👤 (\d+)" | |
| 241 | seeders_pattern = r"[👥👤] (\d+)" | |
| 240 | 242 | seeders_match = re.search(seeders_pattern, desc) |
| 241 | 243 | seeders = int(seeders_match.group(1)) if seeders_match else None |
| 242 | 244 |
| … | … | @@ -1,28 +1,44 @@ |
| 1 | import requests | |
| 2 | from datetime import timedelta | |
| 3 | 1 | from collections import Counter |
| 2 | from datetime import timedelta | |
| 3 | ||
| 4 | import requests | |
| 5 | import xbmcgui | |
| 6 | ||
| 7 | from lib.api.stremio.addon_manager import build_addon_instance_key | |
| 8 | from 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 | ) | |
| 19 | from 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 | ) | |
| 4 | 32 | from lib.db.cached import cache |
| 5 | 33 | from lib.utils.general.utils import USER_AGENT_HEADER |
| 34 | from lib.utils.kodi.settings import get_int_setting | |
| 6 | 35 | from lib.utils.kodi.utils import ( |
| 7 | kodilog, | |
| 36 | ADDON, | |
| 8 | 37 | get_setting, |
| 38 | kodilog, | |
| 9 | 39 | set_setting, |
| 10 | ADDON, | |
| 11 | 40 | translation, |
| 12 | 41 | ) |
| 13 | from lib.utils.kodi.settings import get_int_setting | |
| 14 | from 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 | ) | |
| 23 | from lib.clients.stremio.helpers import get_addons, ping_addons, get_addon_merge_key | |
| 24 | from lib.api.stremio.addon_manager import build_addon_instance_key | |
| 25 | import xbmcgui | |
| 26 | 42 | |
| 27 | 43 | |
| 28 | 44 | def _ping_addons_with_progress(addons): |
| … | … | @@ -54,13 +70,13 @@ def _ping_addons_with_progress(addons): |
| 54 | 70 | |
| 55 | 71 | def _build_addon_options(addons): |
| 56 | 72 | """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) | |
| 58 | 74 | options = [] |
| 59 | 75 | |
| 60 | 76 | for addon in addons: |
| 61 | name = addon.manifest.name | |
| 77 | name = get_addon_display_name(addon) | |
| 62 | 78 | if name_counts[name] > 1: |
| 63 | label = addon.label() | |
| 79 | label = addon.label().replace(addon.manifest.name, name, 1) | |
| 64 | 80 | else: |
| 65 | 81 | label = name |
| 66 | 82 | |
| … | … | @@ -76,6 +92,50 @@ def _build_addon_options(addons): |
| 76 | 92 | return options |
| 77 | 93 | |
| 78 | 94 | |
| 95 | def _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 | ||
| 107 | def _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 | ||
| 114 | def _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 | ||
| 130 | def _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 | ||
| 79 | 139 | def _show_addon_multiselect(title, addons, selected_ids): |
| 80 | 140 | """Show multiselect dialog and return selected addon keys.""" |
| 81 | 141 | options = _build_addon_options(addons) |
| … | … | @@ -124,10 +184,7 @@ def _filter_excluded_addons(addons): |
| 124 | 184 | addon |
| 125 | 185 | for addon in addons |
| 126 | 186 | 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") | |
| 131 | 188 | ] |
| 132 | 189 | |
| 133 | 190 | |
| … | … | @@ -217,10 +274,9 @@ def stremio_toggle_catalogs(params, check_availability=False): |
| 217 | 274 | ): |
| 218 | 275 | is_tv = True |
| 219 | 276 | 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 | |
| 224 | 280 | if not is_tv: |
| 225 | 281 | filtered_addons.append(addon) |
| 226 | 282 | addons = filtered_addons |
| … | … | @@ -264,10 +320,11 @@ def stremio_toggle_tv_addons(params, check_availability=False): |
| 264 | 320 | ): |
| 265 | 321 | addons.append(addon) |
| 266 | 322 | 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 | |
| 271 | 328 | |
| 272 | 329 | addons = _deduplicate_addons(addons) |
| 273 | 330 | addons = _filter_excluded_addons(addons) |
| … | … | @@ -299,9 +356,7 @@ def stremio_toggle_tv_addons(params, check_availability=False): |
| 299 | 356 | |
| 300 | 357 | def add_custom_stremio_addon(params): |
| 301 | 358 | 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) | |
| 305 | 360 | if not url: |
| 306 | 361 | dialog.ok(translation(90522), translation(90524)) |
| 307 | 362 | return |
| … | … | @@ -326,15 +381,11 @@ def add_custom_stremio_addon(params): |
| 326 | 381 | if not id_: |
| 327 | 382 | dialog.ok(translation(90522), translation(90526)) |
| 328 | 383 | 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}) | |
| 332 | 385 | |
| 333 | 386 | resources = manifest.get("resources", []) |
| 334 | 387 | # 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)): | |
| 338 | 389 | resources = [resources] |
| 339 | 390 | |
| 340 | 391 | # Determine capabilities |
| … | … | @@ -357,9 +408,10 @@ def add_custom_stremio_addon(params): |
| 357 | 408 | is_catalog = True |
| 358 | 409 | elif isinstance(res, str): |
| 359 | 410 | if res == "stream": |
| 360 | # For string resource, rely on top-elevel types | |
| 411 | # For string resource, rely on top-level types. | |
| 361 | 412 | 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 | |
| 363 | 415 | if "tv" in types or "channel" in types: |
| 364 | 416 | is_tv_stream = True |
| 365 | 417 | if res == "catalog": |
| … | … | @@ -389,9 +441,7 @@ def add_custom_stremio_addon(params): |
| 389 | 441 | |
| 390 | 442 | # Add to selected catalogs if catalog |
| 391 | 443 | 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)) | |
| 395 | 445 | if addon_key not in selected_catalog_keys: |
| 396 | 446 | selected_catalog_keys.append(addon_key) |
| 397 | 447 | cache.set( |
| … | … | @@ -433,11 +483,7 @@ def remove_custom_stremio_addon(params=None): |
| 433 | 483 | options = [] |
| 434 | 484 | for addon in removable_addons: |
| 435 | 485 | 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") | |
| 441 | 487 | desc = manifest.get("description", "") |
| 442 | 488 | item = xbmcgui.ListItem(label=name, label2=desc) |
| 443 | 489 | |
| … | … | @@ -467,12 +513,9 @@ def remove_custom_stremio_addon(params=None): |
| 467 | 513 | to_remove_keys.add(addon_key) |
| 468 | 514 | |
| 469 | 515 | # 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] | |
| 475 | 517 | cache.set(STREMIO_USER_ADDONS, new_user_addons, timedelta(days=365 * 20)) |
| 518 | _clear_aliases_for_addon_keys(to_remove_keys) | |
| 476 | 519 | |
| 477 | 520 | # Remove from selected stream/catalogs/tv if present |
| 478 | 521 | for cache_key in [ |
| … | … | @@ -483,13 +526,65 @@ def remove_custom_stremio_addon(params=None): |
| 483 | 526 | selected_keys = decode_selected_ids(cache.get(cache_key)) |
| 484 | 527 | if selected_keys: |
| 485 | 528 | 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)) | |
| 489 | 530 | |
| 490 | 531 | xbmcgui.Dialog().ok(translation(90531), translation(90534)) |
| 491 | 532 | |
| 492 | 533 | |
| 534 | def 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 | ||
| 554 | def 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 | ||
| 493 | 588 | def stremio_bypass_addons_select(params=None): |
| 494 | 589 | addon_manager = get_addons() |
| 495 | 590 | addons = addon_manager.get_addons_with_resource("stream") |
| … | … | @@ -503,9 +598,7 @@ def stremio_bypass_addons_select(params=None): |
| 503 | 598 | |
| 504 | 599 | selected_ids = [] |
| 505 | 600 | 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: | |
| 509 | 602 | selected_ids.append(addon.key()) |
| 510 | 603 | |
| 511 | 604 | title = translation(90213) |
| … | … | @@ -1,5 +1,14 @@ |
| 1 | 1 | from datetime import timedelta |
| 2 | ||
| 3 | import xbmcgui | |
| 4 | ||
| 2 | 5 | from lib.api.stremio.api_client import Stremio |
| 6 | from lib.clients.stremio.constants import ( | |
| 7 | STREMIO_ADDONS_CATALOGS_KEY, | |
| 8 | STREMIO_ADDONS_KEY, | |
| 9 | STREMIO_USER_ADDONS, | |
| 10 | ) | |
| 11 | from lib.clients.stremio.helpers import merge_addons_lists | |
| 3 | 12 | from lib.db.cached import cache |
| 4 | 13 | from lib.utils.kodi.utils import ( |
| 5 | 14 | get_setting, |
| … | … | @@ -7,21 +16,11 @@ from lib.utils.kodi.utils import ( |
| 7 | 16 | set_setting, |
| 8 | 17 | translation, |
| 9 | 18 | ) |
| 10 | from lib.clients.stremio.constants import ( | |
| 11 | STREMIO_ADDONS_KEY, | |
| 12 | STREMIO_ADDONS_CATALOGS_KEY, | |
| 13 | STREMIO_USER_ADDONS, | |
| 14 | ) | |
| 15 | from lib.clients.stremio.helpers import merge_addons_lists | |
| 16 | import xbmcgui | |
| 17 | 19 | |
| 18 | 20 | |
| 19 | 21 | def stremio_login(params): |
| 20 | 22 | dialog = xbmcgui.Dialog() |
| 21 | dialog.ok( | |
| 22 | translation(90612), | |
| 23 | translation(90613) | |
| 24 | ) | |
| 23 | dialog.ok(translation(90612), translation(90613)) | |
| 25 | 24 | |
| 26 | 25 | email = dialog.input(heading=translation(90614), type=xbmcgui.INPUT_ALPHANUM) |
| 27 | 26 | if not email: |
| … | … | @@ -46,9 +45,7 @@ def log_in(email, password, dialog): |
| 46 | 45 | # Only merge user account addons with custom addons |
| 47 | 46 | user_account_addons = stremio.get_my_addons() or [] |
| 48 | 47 | 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"] | |
| 52 | 49 | all_addons = merge_addons_lists(user_account_addons, custom_addons) |
| 53 | 50 | cache.set(STREMIO_USER_ADDONS, all_addons, timedelta(days=365 * 20)) |
| 54 | 51 | |
| … | … | @@ -99,9 +96,7 @@ def stremio_logout(params): |
| 99 | 96 | cache.set(STREMIO_ADDONS_CATALOGS_KEY, None, timedelta(seconds=1)) |
| 100 | 97 | # Do not clear custom addons, only clear login state and user (login) addons |
| 101 | 98 | 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"] | |
| 105 | 100 | cache.set(STREMIO_USER_ADDONS, custom_addons, timedelta(days=365 * 20)) |
| 106 | 101 | |
| 107 | 102 | set_setting("stremio_loggedin", "false") |
| … | … | @@ -1,10 +1,16 @@ |
| 1 | 1 | import json |
| 2 | 2 | import os |
| 3 | from datetime import datetime, timedelta | |
| 4 | 3 | from dataclasses import asdict |
| 4 | from datetime import datetime, timedelta | |
| 5 | 5 | from lib.api.stremio.models import extract_proxy_headers |
| 6 | from urllib.parse import quote | |
| 7 | ||
| 8 | from xbmcplugin import addDirectoryItem, setContent | |
| 9 | ||
| 6 | 10 | from lib.clients.stremio.helpers import ( |
| 7 | 11 | get_addon_by_base_url, |
| 12 | get_addon_display_name, | |
| 13 | get_catalog_display_name, | |
| 8 | 14 | get_selected_catalogs_addons, |
| 9 | 15 | get_selected_tv_addons, |
| 10 | 16 | ) |
| … | … | @@ -12,30 +18,32 @@ from lib.clients.tmdb.utils.utils import ( |
| 12 | 18 | add_tmdb_episode_context_menu, |
| 13 | 19 | add_tmdb_movie_context_menu, |
| 14 | 20 | add_tmdb_show_context_menu, |
| 21 | tmdb_get, | |
| 15 | 22 | ) |
| 16 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 17 | from lib.utils.general.utils import add_next_button, truncate_text | |
| 18 | 23 | from lib.db.cached import cache |
| 19 | 24 | from lib.db.pickle_db import PickleDatabase |
| 20 | from lib.utils.stremio.catalogs_utils import catalogs_get_cache | |
| 25 | from lib.utils.general.utils import ( | |
| 26 | IndexerType, | |
| 27 | add_next_button, | |
| 28 | info_hash_to_magnet, | |
| 29 | truncate_text, | |
| 30 | ) | |
| 31 | from lib.utils.kodi.settings import get_cache_expiration | |
| 21 | 32 | from lib.utils.kodi.utils import ( |
| 33 | ADDON_HANDLE, | |
| 34 | ADDON_PATH, | |
| 22 | 35 | add_directory_items_batch, |
| 23 | 36 | build_url, |
| 24 | 37 | container_update, |
| 25 | 38 | end_of_directory, |
| 26 | 39 | kodi_play_media, |
| 40 | kodilog, | |
| 27 | 41 | make_list_item, |
| 28 | 42 | notification, |
| 29 | 43 | show_keyboard, |
| 30 | kodilog, | |
| 31 | 44 | translation, |
| 32 | 45 | ) |
| 33 | from lib.utils.general.utils import info_hash_to_magnet, IndexerType | |
| 34 | ||
| 35 | from xbmcplugin import addDirectoryItem, setContent | |
| 36 | from lib.utils.kodi.settings import get_cache_expiration | |
| 37 | from lib.utils.kodi.utils import ADDON_HANDLE, ADDON_PATH | |
| 38 | ||
| 46 | from lib.utils.stremio.catalogs_utils import catalogs_get_cache | |
| 39 | 47 | |
| 40 | 48 | CATALOG_PAGE_SIZE = 25 |
| 41 | 49 | |
| … | … | @@ -67,9 +75,7 @@ def _show_search_catalog_history(params): |
| 67 | 75 | |
| 68 | 76 | for (text,) in cache.get_list(key=history_key): |
| 69 | 77 | 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")}) | |
| 73 | 79 | addDirectoryItem( |
| 74 | 80 | ADDON_HANDLE, |
| 75 | 81 | build_url( |
| … | … | @@ -225,14 +231,14 @@ def list_stremio_catalogs(menu_type="", sub_menu_type=""): |
| 225 | 231 | directory_items = [] |
| 226 | 232 | |
| 227 | 233 | for addon in selected_addons: |
| 228 | addon_name = addon.manifest.name | |
| 234 | addon_name = get_addon_display_name(addon) | |
| 229 | 235 | addon_types = addon.manifest.types |
| 230 | 236 | |
| 231 | 237 | if menu_type not in addon_types: |
| 232 | 238 | continue |
| 233 | 239 | |
| 234 | 240 | for catalog in addon.manifest.catalogs: |
| 235 | catalog_name = catalog.name | |
| 241 | catalog_name = get_catalog_display_name(addon, catalog) | |
| 236 | 242 | catalog_id = catalog.id |
| 237 | 243 | catalog_type = catalog.type |
| 238 | 244 | |
| … | … | @@ -243,15 +249,10 @@ def list_stremio_catalogs(menu_type="", sub_menu_type=""): |
| 243 | 249 | if menu_type == "anime" and target_type == "series": |
| 244 | 250 | allowed_types.append("anime") |
| 245 | 251 | |
| 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: | |
| 250 | 253 | continue |
| 251 | 254 | |
| 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) | |
| 255 | 256 | |
| 256 | 257 | if search_capabilities: |
| 257 | 258 | listitem = make_list_item(label=f"{translation(90006)} {catalog_name}") |
| … | … | @@ -276,8 +277,7 @@ def list_stremio_catalogs(menu_type="", sub_menu_type=""): |
| 276 | 277 | ) |
| 277 | 278 | |
| 278 | 279 | if catalog_name or catalog_id: |
| 279 | addon_name = addon.manifest.name | |
| 280 | if addon_name == "Cinemeta": | |
| 280 | if addon.manifest.name == "Cinemeta": | |
| 281 | 281 | label = f"{addon_name} - {catalog_name or catalog_id}" |
| 282 | 282 | else: |
| 283 | 283 | label = catalog_name or catalog_id |
| … | … | @@ -378,9 +378,7 @@ def list_catalog(params): |
| 378 | 378 | skip=skip + len(metas), |
| 379 | 379 | ) |
| 380 | 380 | 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) | |
| 384 | 382 | |
| 385 | 383 | end_of_directory() |
| 386 | 384 | |
| … | … | @@ -517,9 +515,7 @@ def add_meta_items(metas, params): |
| 517 | 515 | meta_id=meta_id, |
| 518 | 516 | ) |
| 519 | 517 | 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") | |
| 523 | 519 | else: |
| 524 | 520 | url = build_url( |
| 525 | 521 | "list_stremio_seasons", |
| … | … | @@ -635,18 +631,13 @@ def add_meta_items(metas, params): |
| 635 | 631 | elif meta_type in ["series", "anime"]: |
| 636 | 632 | context_menu.extend( |
| 637 | 633 | _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) | |
| 641 | 635 | ) |
| 642 | 636 | ) |
| 643 | 637 | |
| 644 | 638 | _append_context_menu_items(list_item, context_menu) |
| 645 | 639 | |
| 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) | |
| 650 | 641 | |
| 651 | 642 | |
| 652 | 643 | def list_stremio_seasons(params): |
| … | … | @@ -666,8 +657,7 @@ def list_stremio_seasons(params): |
| 666 | 657 | return |
| 667 | 658 | |
| 668 | 659 | 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 | |
| 671 | 661 | ) |
| 672 | 662 | items = [] |
| 673 | 663 | for season in available_seasons: |
| … | … | @@ -680,9 +670,7 @@ def list_stremio_seasons(params): |
| 680 | 670 | ) |
| 681 | 671 | list_item = make_list_item(label=f"{translation(90512)} {season}") |
| 682 | 672 | 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") | |
| 686 | 674 | info_tag.setTitle(meta_data.name) |
| 687 | 675 | info_tag.setPlot(truncate_text(meta_data.description or "")) |
| 688 | 676 | info_tag.setRating(float(meta_data.imdbRating or 0)) |
| … | … | @@ -732,14 +720,12 @@ def list_stremio_episodes(params): |
| 732 | 720 | imdb_id = meta_data.imdb_id |
| 733 | 721 | res = tmdb_get("find_by_imdb_id", imdb_id) |
| 734 | 722 | if getattr(res, "tv_results", []): |
| 735 | tmdb_id = str(getattr(res, "tv_results")[0]["id"]) | |
| 723 | tmdb_id = str(res.tv_results[0]["id"]) | |
| 736 | 724 | |
| 737 | 725 | if not tmdb_id and not imdb_id and meta_data.name: |
| 738 | 726 | kodilog(f"No IDs found for {meta_data.name}. Searching TMDB...") |
| 739 | 727 | 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}) | |
| 743 | 729 | if results and results.results: |
| 744 | 730 | try: |
| 745 | 731 | tmdb_id = str(results.results[0].id) |
| … | … | @@ -754,16 +740,12 @@ def list_stremio_episodes(params): |
| 754 | 740 | kodilog(f"Resolved IMDB ID: {imdb_id} from TMDB ID: {tmdb_id}") |
| 755 | 741 | |
| 756 | 742 | 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) | |
| 760 | 744 | items = [] |
| 761 | 745 | for video in videos: |
| 762 | 746 | try: |
| 763 | 747 | 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) | |
| 767 | 749 | except (ValueError, TypeError): |
| 768 | 750 | continue |
| 769 | 751 | |
| … | … | @@ -773,7 +755,7 @@ def list_stremio_episodes(params): |
| 773 | 755 | title = video.title or getattr(video, "name", "") or meta_data.name |
| 774 | 756 | |
| 775 | 757 | tv_data = { |
| 776 | "name": title, | |
| 758 | "name": quote(title), | |
| 777 | 759 | "episode": episode, |
| 778 | 760 | "season": season, |
| 779 | 761 | } |
| … | … | @@ -802,9 +784,7 @@ def list_stremio_episodes(params): |
| 802 | 784 | |
| 803 | 785 | list_item = make_list_item(label=f"{season}x{episode}. {title}") |
| 804 | 786 | 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") | |
| 808 | 788 | info_tag.setTitle(title) |
| 809 | 789 | info_tag.setPlot(truncate_text(video.overview or "")) |
| 810 | 790 | info_tag.setRating(float(meta_data.imdbRating or 0)) |
| … | … | @@ -837,14 +817,17 @@ def list_stremio_episodes(params): |
| 837 | 817 | ] |
| 838 | 818 | context_menu_ids = _resolve_tmdb_ids_for_context_menu(ids, "series") |
| 839 | 819 | 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 | ) | |
| 846 | 828 | ) |
| 847 | ) + context_menu | |
| 829 | + context_menu | |
| 830 | ) | |
| 848 | 831 | else: |
| 849 | 832 | context_menu.insert( |
| 850 | 833 | 0, |
| … | … | @@ -27,6 +27,8 @@ def decode_selected_ids(raw): |
| 27 | 27 | |
| 28 | 28 | STREMIO_ADDONS_KEY = "stremio_addons" |
| 29 | 29 | STREMIO_ADDONS_CATALOGS_KEY = "stremio_catalog_addons" |
| 30 | STREMIO_ADDON_ALIASES_KEY = "stremio_addon_aliases" | |
| 31 | STREMIO_CATALOG_ALIASES_KEY = "stremio_catalog_aliases" | |
| 30 | 32 | STREMIO_TV_ADDONS_KEY = "stremio_tv_addons" |
| 31 | 33 | STREMIO_USER_ADDONS = "stremio_user_addons" |
| 32 | 34 | TORRENTIO_PROVIDERS_KEY = "torrentio.providers" |
| … | … | @@ -1,26 +1,97 @@ |
| 1 | from typing import List, Callable, Optional | |
| 2 | import requests | |
| 3 | 1 | import concurrent.futures |
| 2 | from datetime import timedelta | |
| 3 | from typing import Callable, List, Optional | |
| 4 | ||
| 5 | import requests | |
| 6 | ||
| 4 | 7 | from lib.api.stremio.addon_manager import ( |
| 5 | 8 | Addon, |
| 6 | 9 | AddonManager, |
| 7 | 10 | build_addon_instance_key, |
| 8 | normalize_transport_url, | |
| 9 | 11 | ) |
| 10 | 12 | from lib.api.stremio.api_client import Stremio |
| 11 | from lib.db.cached import cache | |
| 12 | from lib.utils.kodi.utils import get_setting, kodilog | |
| 13 | 13 | from lib.clients.stremio.constants import ( |
| 14 | STREMIO_ADDONS_KEY, | |
| 14 | STREMIO_ADDON_ALIASES_KEY, | |
| 15 | 15 | STREMIO_ADDONS_CATALOGS_KEY, |
| 16 | STREMIO_ADDONS_KEY, | |
| 17 | STREMIO_CATALOG_ALIASES_KEY, | |
| 16 | 18 | STREMIO_TV_ADDONS_KEY, |
| 17 | 19 | STREMIO_USER_ADDONS, |
| 18 | 20 | decode_selected_ids, |
| 19 | 21 | ) |
| 22 | from lib.db.cached import cache | |
| 23 | from lib.utils.kodi.utils import get_setting, kodilog | |
| 24 | ||
| 25 | ||
| 20 | 26 | def get_addon_merge_key(addon): |
| 21 | 27 | return build_addon_instance_key(addon) |
| 22 | 28 | |
| 23 | 29 | |
| 30 | def _get_addon_alias_key(addon): | |
| 31 | if hasattr(addon, "key"): | |
| 32 | return addon.key() | |
| 33 | return get_addon_merge_key(addon) | |
| 34 | ||
| 35 | ||
| 36 | def _get_aliases(cache_key): | |
| 37 | aliases = cache.get(cache_key) or {} | |
| 38 | if not isinstance(aliases, dict): | |
| 39 | return {} | |
| 40 | return aliases | |
| 41 | ||
| 42 | ||
| 43 | def _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 | ||
| 53 | def get_catalog_alias_key(addon, catalog): | |
| 54 | return f"{_get_addon_alias_key(addon)}|{catalog.type}|{catalog.id}" | |
| 55 | ||
| 56 | ||
| 57 | def 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 | ||
| 62 | def 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 | ||
| 69 | def set_addon_alias(addon, alias): | |
| 70 | _set_alias(STREMIO_ADDON_ALIASES_KEY, _get_addon_alias_key(addon), alias) | |
| 71 | ||
| 72 | ||
| 73 | def clear_addon_alias(addon): | |
| 74 | set_addon_alias(addon, "") | |
| 75 | ||
| 76 | ||
| 77 | def get_addon_alias(addon): | |
| 78 | return _get_aliases(STREMIO_ADDON_ALIASES_KEY).get(_get_addon_alias_key(addon), "") | |
| 79 | ||
| 80 | ||
| 81 | def set_catalog_alias(addon, catalog, alias): | |
| 82 | _set_alias(STREMIO_CATALOG_ALIASES_KEY, get_catalog_alias_key(addon, catalog), alias) | |
| 83 | ||
| 84 | ||
| 85 | def clear_catalog_alias(addon, catalog): | |
| 86 | set_catalog_alias(addon, catalog, "") | |
| 87 | ||
| 88 | ||
| 89 | def get_catalog_alias(addon, catalog): | |
| 90 | return _get_aliases(STREMIO_CATALOG_ALIASES_KEY).get( | |
| 91 | get_catalog_alias_key(addon, catalog), "" | |
| 92 | ) | |
| 93 | ||
| 94 | ||
| 24 | 95 | def merge_addons_lists(*lists): |
| 25 | 96 | seen = set() |
| 26 | 97 | merged = [] |
| … | … | @@ -76,9 +147,7 @@ def _resolve_selected_addons(catalog: AddonManager, selected_ids_list: List[str] |
| 76 | 147 | def get_addons(): |
| 77 | 148 | all_user_addons = cache.get(STREMIO_USER_ADDONS) or [] |
| 78 | 149 | 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"] | |
| 82 | 151 | user_addons = list(cached_account_addons) |
| 83 | 152 | |
| 84 | 153 | logged_in = get_setting("stremio_loggedin") |
| … | … | @@ -1,10 +1,8 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 1 | from lib.gui.qr_progress_dialog import QRProgressDialog | |
| 2 | 2 | from lib.services.webserver import StremioWebServer, get_local_ip |
| 3 | 3 | from lib.utils.debrid.qrcode_utils import make_qrcode |
| 4 | from lib.gui.qr_progress_dialog import QRProgressDialog | |
| 5 | 4 | from lib.utils.kodi.utils import ADDON_PATH, kodilog, translation |
| 6 | 5 | |
| 7 | ||
| 8 | 6 | _SERVER_PORT = 8081 |
| 9 | 7 | |
| 10 | 8 |
| … | … | @@ -1,15 +1,17 @@ |
| 1 | 1 | import os |
| 2 | 2 | from datetime import timedelta |
| 3 | ||
| 4 | import xbmcgui | |
| 5 | ||
| 6 | from lib.clients.stremio.constants import ( | |
| 7 | TORRENTIO_PROVIDERS_KEY, | |
| 8 | all_torrentio_providers, | |
| 9 | ) | |
| 3 | 10 | from lib.db.cached import cache |
| 4 | 11 | from lib.utils.kodi.utils import ( |
| 5 | 12 | ADDON_PATH, |
| 6 | 13 | translation, |
| 7 | 14 | ) |
| 8 | from lib.clients.stremio.constants import ( | |
| 9 | TORRENTIO_PROVIDERS_KEY, | |
| 10 | all_torrentio_providers, | |
| 11 | ) | |
| 12 | import xbmcgui | |
| 13 | 15 | |
| 14 | 16 | |
| 15 | 17 | def torrentio_toggle_providers(params): |
| … | … | @@ -18,11 +20,9 @@ def torrentio_toggle_providers(params): |
| 18 | 20 | |
| 19 | 21 | options = [] |
| 20 | 22 | selected_indexes = [] |
| 21 | for i, (key, name, logo) in enumerate(all_torrentio_providers): | |
| 23 | for i, (key, name, _logo) in enumerate(all_torrentio_providers): | |
| 22 | 24 | 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")}) | |
| 26 | 26 | options.append(item) |
| 27 | 27 | if key in selected_ids: |
| 28 | 28 | selected_indexes.append(i) |
| … | … | @@ -37,6 +37,4 @@ def torrentio_toggle_providers(params): |
| 37 | 37 | return |
| 38 | 38 | |
| 39 | 39 | 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)) |
| … | … | @@ -1,5 +1,11 @@ |
| 1 | 1 | import traceback |
| 2 | from os import path as ospath | |
| 3 | from os import stat | |
| 4 | from time import sleep | |
| 5 | ||
| 2 | 6 | import requests |
| 7 | import xbmcgui | |
| 8 | ||
| 3 | 9 | from lib.clients.subtitle.utils import get_deepl_language_code, slugify |
| 4 | 10 | from lib.utils.kodi.utils import ( |
| 5 | 11 | ADDON_PROFILE_PATH, |
| … | … | @@ -7,10 +13,6 @@ from lib.utils.kodi.utils import ( |
| 7 | 13 | kodilog, |
| 8 | 14 | translation, |
| 9 | 15 | ) |
| 10 | from os import path as ospath, stat | |
| 11 | from time import sleep | |
| 12 | ||
| 13 | import xbmcgui | |
| 14 | 16 | |
| 15 | 17 | |
| 16 | 18 | def show_dialog(title, message): |
| … | … | @@ -29,9 +31,11 @@ class DeepLTranslator: |
| 29 | 31 | def _handle_deepl_response(self, response, context=""): |
| 30 | 32 | """ |
| 31 | 33 | Handle DeepL API responses, showing a Kodi dialog and logging errors for non-200 codes. |
| 34 | ||
| 32 | 35 | Returns True if response is OK, otherwise False. |
| 33 | 36 | """ |
| 34 | 37 | if response.status_code == 200: |
| 38 | ||
| 35 | 39 | return True |
| 36 | 40 | |
| 37 | 41 | status_messages = { |
| … | … | @@ -48,7 +52,7 @@ class DeepLTranslator: |
| 48 | 52 | f"Unexpected error occurred (status code: {response.status_code}).", |
| 49 | 53 | ) |
| 50 | 54 | |
| 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" | |
| 52 | 56 | self.notification(heading=translation(90652), message=msg) |
| 53 | 57 | return False |
| 54 | 58 | |
| … | … | @@ -64,9 +68,11 @@ class DeepLTranslator: |
| 64 | 68 | ): |
| 65 | 69 | """ |
| 66 | 70 | Download the translated document from DeepL and save it as an .srt file. |
| 71 | ||
| 67 | 72 | The file path is determined based on whether season/episode info is provided. |
| 68 | 73 | """ |
| 69 | 74 | if season and episode: |
| 75 | ||
| 70 | 76 | new_subtitle_file_path = ( |
| 71 | 77 | f"{ADDON_PROFILE_PATH}Subtitles/{imdb_id}/{season}/{episode}/" |
| 72 | 78 | f"Translated_Subtitle No.{idx}.S{season}E{episode}.{lang_name}.srt" |
| … | … | @@ -90,9 +96,7 @@ class DeepLTranslator: |
| 90 | 96 | "Content-Type": "application/json", |
| 91 | 97 | } |
| 92 | 98 | |
| 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) | |
| 96 | 100 | if not self._handle_deepl_response(response, "result download"): |
| 97 | 101 | raise Exception("DeepL API error during result download") |
| 98 | 102 | |
| … | … | @@ -110,7 +114,7 @@ class DeepLTranslator: |
| 110 | 114 | total_character_count = 0 |
| 111 | 115 | for file_path in subtitle_paths: |
| 112 | 116 | try: |
| 113 | with open(file_path, "r", encoding="utf-8") as f: | |
| 117 | with open(file_path, encoding="utf-8") as f: | |
| 114 | 118 | content = f.read() |
| 115 | 119 | |
| 116 | 120 | lines = content.split("\n") |
| … | … | @@ -158,9 +162,11 @@ class DeepLTranslator: |
| 158 | 162 | def validate_file_limits(self, file_path): |
| 159 | 163 | """ |
| 160 | 164 | Check if a file meets size and character count limits. |
| 165 | ||
| 161 | 166 | Raises Exception and notifies the user if not. |
| 162 | 167 | """ |
| 163 | 168 | MAX_FILE_SIZE_KB = 150 |
| 169 | ||
| 164 | 170 | MAX_CHARACTERS = 1000000 |
| 165 | 171 | |
| 166 | 172 | stats = stat(file_path) |
| … | … | @@ -171,7 +177,7 @@ class DeepLTranslator: |
| 171 | 177 | self.notification(msg) |
| 172 | 178 | raise Exception(msg) |
| 173 | 179 | |
| 174 | with open(file_path, "r", encoding="utf-8") as f: | |
| 180 | with open(file_path, encoding="utf-8") as f: | |
| 175 | 181 | content = f.read() |
| 176 | 182 | |
| 177 | 183 | character_count = len(content) |
| … | … | @@ -181,16 +187,16 @@ class DeepLTranslator: |
| 181 | 187 | self.notification(msg) |
| 182 | 188 | raise Exception(msg) |
| 183 | 189 | |
| 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}") | |
| 187 | 191 | |
| 188 | 192 | def filter_files_within_limits(self, file_paths): |
| 189 | 193 | """ |
| 190 | 194 | Returns a list of file paths that are within the allowed limits. |
| 195 | ||
| 191 | 196 | Notifies the user for each file that is skipped. |
| 192 | 197 | """ |
| 193 | 198 | valid_files = [] |
| 199 | ||
| 194 | 200 | for file_path in file_paths: |
| 195 | 201 | try: |
| 196 | 202 | self.validate_file_limits(file_path) |
| … | … | @@ -209,9 +215,7 @@ class DeepLTranslator: |
| 209 | 215 | "Authorization": f"DeepL-Auth-Key {self.api_key}", |
| 210 | 216 | "Content-Type": "application/json", |
| 211 | 217 | } |
| 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) | |
| 215 | 219 | |
| 216 | 220 | kodilog("Check status response text: " + response.text) |
| 217 | 221 | |
| … | … | @@ -242,13 +246,11 @@ class DeepLTranslator: |
| 242 | 246 | total_characters = 0 |
| 243 | 247 | for file_path in subtitle_paths: |
| 244 | 248 | try: |
| 245 | with open(file_path, "r", encoding="utf-8") as f: | |
| 249 | with open(file_path, encoding="utf-8") as f: | |
| 246 | 250 | content = f.read() |
| 247 | 251 | total_characters += len(content) |
| 248 | 252 | 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}") | |
| 252 | 254 | |
| 253 | 255 | estimated_cost = (total_characters / 1_000_000) * PRICE_PER_MILLION |
| 254 | 256 | return total_characters, estimated_cost |
| … | … | @@ -256,9 +258,11 @@ class DeepLTranslator: |
| 256 | 258 | def get_free_characters_left(self): |
| 257 | 259 | """ |
| 258 | 260 | Query DeepL API for remaining free characters. |
| 261 | ||
| 259 | 262 | Returns the number of free characters left, or 0 if unavailable. |
| 260 | 263 | """ |
| 261 | 264 | url = f"{self.base_url}/usage" |
| 265 | ||
| 262 | 266 | headers = {"Authorization": f"DeepL-Auth-Key {self.api_key}"} |
| 263 | 267 | try: |
| 264 | 268 | response = requests.get(url, headers=headers) |
| … | … | @@ -275,13 +279,13 @@ class DeepLTranslator: |
| 275 | 279 | def prompt_user_for_cost(self, subtitle_paths): |
| 276 | 280 | """ |
| 277 | 281 | Show a dialog to the user with the estimated translation cost and ask for confirmation. |
| 282 | ||
| 278 | 283 | Only show cost if free usage is exhausted. |
| 279 | 284 | Returns True if user confirms, False otherwise. |
| 280 | 285 | """ |
| 281 | 286 | 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) | |
| 285 | 289 | |
| 286 | 290 | if free_chars_left > total_characters: |
| 287 | 291 | message = translation(90666) % (free_chars_left, total_characters) |
| … | … | @@ -342,9 +346,11 @@ class DeepLTranslator: |
| 342 | 346 | def translate_multiple_subtitles(self, sub_paths, imdbid, season, episode): |
| 343 | 347 | """ |
| 344 | 348 | Process multiple subtitle files by translating each one. |
| 349 | ||
| 345 | 350 | Only files within limits are processed. |
| 346 | 351 | """ |
| 347 | 352 | # Filter files within limits and notify user for skipped files |
| 353 | ||
| 348 | 354 | valid_sub_paths = self.filter_files_within_limits(sub_paths) |
| 349 | 355 | if not valid_sub_paths: |
| 350 | 356 | self.notification("No subtitle files are within the allowed limits.") |
| … | … | @@ -1,5 +1,10 @@ |
| 1 | import os | |
| 2 | from typing import Any, Callable, Dict, List, Optional | |
| 3 | ||
| 1 | 4 | import requests |
| 2 | from typing import Callable, List, Optional, Dict, Any | |
| 5 | import xbmc | |
| 6 | import xbmcgui | |
| 7 | ||
| 3 | 8 | from lib.clients.subtitle.utils import ( |
| 4 | 9 | get_language_code, |
| 5 | 10 | language_code_to_name, |
| … | … | @@ -12,8 +17,6 @@ from lib.utils.kodi.utils import ( |
| 12 | 17 | kodilog, |
| 13 | 18 | translation, |
| 14 | 19 | ) |
| 15 | import xbmcgui | |
| 16 | import xbmc | |
| 17 | 20 | |
| 18 | 21 | |
| 19 | 22 | class OpenSubtitleStremioClient: |
| … | … | @@ -39,9 +42,7 @@ class OpenSubtitleStremioClient: |
| 39 | 42 | try: |
| 40 | 43 | response = requests.get(url) |
| 41 | 44 | 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}") | |
| 45 | 46 | return None |
| 46 | 47 | data = response.json() |
| 47 | 48 | kodilog(f"OpenSubtitles Subtitles Response: {data}", level=xbmc.LOGDEBUG) |
| … | … | @@ -56,6 +57,7 @@ class OpenSubtitleStremioClient: |
| 56 | 57 | imdb_id: str, |
| 57 | 58 | season: Optional[int] = None, |
| 58 | 59 | episode: Optional[int] = None, |
| 60 | auto_select: bool = False, | |
| 59 | 61 | ) -> Optional[List[Dict[str, Any]]]: |
| 60 | 62 | subtitles = self._fetch_subtitles_data(mode, imdb_id, season, episode) |
| 61 | 63 | if not subtitles: |
| … | … | @@ -64,16 +66,13 @@ class OpenSubtitleStremioClient: |
| 64 | 66 | sub_language = get_setting("subtitle_language") |
| 65 | 67 | auto_subtitle_download = get_setting("auto_subtitle_download") |
| 66 | 68 | 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)] | |
| 70 | 70 | if filtered: |
| 71 | 71 | return filtered |
| 72 | return [] | |
| 72 | 73 | |
| 73 | 74 | 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"])) | |
| 77 | 76 | for i, s in enumerate(subtitles) |
| 78 | 77 | ] |
| 79 | 78 | |
| … | … | @@ -96,12 +95,13 @@ class OpenSubtitleStremioClient: |
| 96 | 95 | title: str, |
| 97 | 96 | season: Optional[int] = None, |
| 98 | 97 | episode: Optional[int] = None, |
| 98 | folder_path: Optional[str] = None, | |
| 99 | 99 | ) -> List[str]: |
| 100 | 100 | file_paths = [] |
| 101 | 101 | for idx, subtitle in enumerate(subtitles): |
| 102 | 102 | try: |
| 103 | 103 | file_path = self.download_subtitle( |
| 104 | subtitle, idx, imdb_id, title, season, episode | |
| 104 | subtitle, idx, imdb_id, title, season, episode, folder_path | |
| 105 | 105 | ) |
| 106 | 106 | if file_path: |
| 107 | 107 | file_paths.append(file_path) |
| … | … | @@ -118,6 +118,7 @@ class OpenSubtitleStremioClient: |
| 118 | 118 | title: str, |
| 119 | 119 | season: Optional[int] = None, |
| 120 | 120 | episode: Optional[int] = None, |
| 121 | folder_path: Optional[str] = None, | |
| 121 | 122 | ) -> Optional[str]: |
| 122 | 123 | url = subtitle.get("url", "") |
| 123 | 124 | lang = subtitle.get("lang") |
| … | … | @@ -125,27 +126,30 @@ class OpenSubtitleStremioClient: |
| 125 | 126 | |
| 126 | 127 | title = slugify_title(title) |
| 127 | 128 | |
| 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") | |
| 138 | 131 | 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") | |
| 140 | 148 | |
| 141 | 149 | 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) | |
| 145 | 151 | 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}") | |
| 149 | 153 | raise Exception(f"HTTP {response.status_code}") |
| 150 | 154 | with open(file_path, "wb") as file: |
| 151 | 155 | for chunk in response.iter_content(chunk_size=8192): |
| … | … | @@ -1,7 +1,10 @@ |
| 1 | 1 | import json |
| 2 | 2 | import os |
| 3 | ||
| 4 | 3 | from typing import Any, Dict, List, Optional |
| 4 | ||
| 5 | import xbmc | |
| 6 | import xbmcgui | |
| 7 | ||
| 5 | 8 | from lib.clients.subtitle.deepl import DeepLTranslator |
| 6 | 9 | from lib.clients.subtitle.opensubstremio import OpenSubtitleStremioClient |
| 7 | 10 | from lib.utils.kodi.utils import ( |
| … | … | @@ -11,14 +14,9 @@ from lib.utils.kodi.utils import ( |
| 11 | 14 | translation, |
| 12 | 15 | ) |
| 13 | 16 | |
| 14 | import xbmc | |
| 15 | import xbmcgui | |
| 16 | ||
| 17 | 17 | |
| 18 | 18 | class 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]: | |
| 22 | 20 | """Send a JSON-RPC request to Kodi.""" |
| 23 | 21 | request_data = { |
| 24 | 22 | "jsonrpc": "2.0", |
| … | … | @@ -48,6 +46,7 @@ class SubtitleManager(KodiJsonRpcClient): |
| 48 | 46 | def get_kodi_preferred_subtitle_language(self, iso_format: bool = False) -> str: |
| 49 | 47 | """ |
| 50 | 48 | Get the preferred subtitle language from Kodi settings. |
| 49 | ||
| 51 | 50 | Returns the language in ISO format if iso_format is True. |
| 52 | 51 | """ |
| 53 | 52 | subtitle_language = self.json_rpc( |
| … | … | @@ -67,33 +66,40 @@ class SubtitleManager(KodiJsonRpcClient): |
| 67 | 66 | subtitle_files.append(os.path.join(root, f)) |
| 68 | 67 | return subtitle_files |
| 69 | 68 | |
| 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]]: | |
| 71 | 76 | self.last_fetch_status = None |
| 72 | title = self.data.get("title") | |
| 77 | title = title or self.data.get("title") | |
| 73 | 78 | 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") | |
| 75 | 80 | tv_data = self.data.get("tv_data", {}) |
| 76 | 81 | episode = tv_data.get("episode") |
| 77 | 82 | season = tv_data.get("season") |
| 78 | 83 | |
| 79 | 84 | if not imdb_id: |
| 80 | 85 | kodilog("No IMDb ID found for the current video, skipping subtitles") |
| 86 | self.last_fetch_status = "no_imdb" | |
| 81 | 87 | return None |
| 82 | 88 | |
| 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) | |
| 86 | 94 | ) |
| 87 | if mode == "tv" | |
| 88 | else os.path.join(ADDON_PROFILE_PATH, "Subtitles", imdb_id) | |
| 89 | ) | |
| 90 | 95 | |
| 91 | 96 | if not os.path.exists(folder_path): |
| 92 | 97 | os.makedirs(folder_path) |
| 93 | 98 | |
| 94 | 99 | subtitle_files = self.get_downloaded_subtitle_paths(folder_path) |
| 95 | 100 | 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"): | |
| 97 | 103 | return subtitle_files |
| 98 | 104 | |
| 99 | 105 | dialog = xbmcgui.Dialog() |
| … | … | @@ -106,7 +112,9 @@ class SubtitleManager(KodiJsonRpcClient): |
| 106 | 112 | if use_existing: |
| 107 | 113 | return subtitle_files |
| 108 | 114 | |
| 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 | ) | |
| 110 | 118 | if subtitles is None: |
| 111 | 119 | self.last_fetch_status = "not_found" |
| 112 | 120 | self.notification(translation(90252)) |
| … | … | @@ -118,21 +126,30 @@ class SubtitleManager(KodiJsonRpcClient): |
| 118 | 126 | return None |
| 119 | 127 | |
| 120 | 128 | 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, | |
| 122 | 135 | ) |
| 123 | 136 | |
| 124 | 137 | 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 | ||
| 125 | 144 | dialog = xbmcgui.Dialog() |
| 126 | 145 | yes = dialog.yesno( |
| 127 | 146 | translation(90254), |
| 128 | 147 | translation(90255), |
| 129 | 148 | ) |
| 130 | 149 | 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 | |
| 135 | 152 | ) |
| 136 | return translated_subtitles_paths | |
| 153 | return translated_subtitles_paths or subtitle_paths | |
| 137 | 154 | |
| 138 | 155 | return subtitle_paths |
| … | … | @@ -55,7 +55,7 @@ def get_deepl_language_code(language_name): |
| 55 | 55 | "Türkçe": "tr", |
| 56 | 56 | "Українська": "uk", |
| 57 | 57 | } |
| 58 | return language_mapping.get(language_name, None) | |
| 58 | return language_mapping.get(language_name) | |
| 59 | 59 | |
| 60 | 60 | |
| 61 | 61 | def get_language_code(language_name): |
| … | … | @@ -95,7 +95,7 @@ def get_language_code(language_name): |
| 95 | 95 | "Chinese": "zho", |
| 96 | 96 | } |
| 97 | 97 | |
| 98 | return language_map.get(language_name, None) | |
| 98 | return language_map.get(language_name) | |
| 99 | 99 | |
| 100 | 100 | |
| 101 | 101 | def language_code_to_name(code): |
| … | … | @@ -3,7 +3,6 @@ from lib.clients.tmdb.utils.utils import ( |
| 3 | 3 | get_tmdb_movie_details, |
| 4 | 4 | get_tmdb_show_details, |
| 5 | 5 | ) |
| 6 | ||
| 7 | 6 | from lib.db.pickle_db import PickleDatabase |
| 8 | 7 | from lib.utils.general.utils import ( |
| 9 | 8 | Animation, |
| … | … | @@ -14,13 +13,12 @@ from lib.utils.general.utils import ( |
| 14 | 13 | set_media_infoTag, |
| 15 | 14 | set_pluging_category, |
| 16 | 15 | ) |
| 17 | ||
| 18 | 16 | from lib.utils.kodi.utils import ( |
| 19 | 17 | end_of_directory, |
| 20 | 18 | kodilog, |
| 21 | 19 | make_list_item, |
| 22 | show_keyboard, | |
| 23 | 20 | notification, |
| 21 | show_keyboard, | |
| 24 | 22 | translation, |
| 25 | 23 | ) |
| 26 | 24 | |
| … | … | @@ -57,9 +55,7 @@ class TmdbAnimeClient(BaseTmdbClient): |
| 57 | 55 | notification("No results found") |
| 58 | 56 | return |
| 59 | 57 | 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) | |
| 63 | 59 | end_of_directory() |
| 64 | 60 | |
| 65 | 61 | @staticmethod |
| … | … | @@ -88,12 +84,12 @@ class TmdbAnimeClient(BaseTmdbClient): |
| 88 | 84 | if mode == "movies": |
| 89 | 85 | title = res.title |
| 90 | 86 | 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", "") | |
| 92 | 88 | tvdb_id = "" |
| 93 | 89 | elif mode == "tv": |
| 94 | 90 | title = res.name |
| 95 | 91 | show_details = get_tmdb_show_details(tmdb_id) |
| 96 | external_ids = getattr(show_details, "external_ids") | |
| 92 | external_ids = show_details.external_ids | |
| 97 | 93 | imdb_id = external_ids.get("imdb_id", "") |
| 98 | 94 | tvdb_id = external_ids.get("tvdb_id", "") |
| 99 | 95 | else: |
| … | … | @@ -1,6 +1,20 @@ |
| 1 | 1 | from datetime import datetime |
| 2 | 2 | |
| 3 | 3 | from lib.api.tmdbv3api.as_obj import AsObj |
| 4 | from 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 | ) | |
| 12 | from 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 | ) | |
| 4 | 18 | from lib.utils.general.utils import set_pluging_category |
| 5 | 19 | from lib.utils.kodi.utils import ( |
| 6 | 20 | add_directory_items_batch, |
| … | … | @@ -11,29 +25,13 @@ from lib.utils.kodi.utils import ( |
| 11 | 25 | notification, |
| 12 | 26 | translation, |
| 13 | 27 | ) |
| 14 | from 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 | ) | |
| 20 | from 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 | ) | |
| 28 | 28 | |
| 29 | 29 | |
| 30 | 30 | class BaseTmdbClient: |
| 31 | 31 | @staticmethod |
| 32 | 32 | def add_media_directory_item(list_item, mode, title, ids, media_type="", batch=False): |
| 33 | 33 | 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) | |
| 37 | 35 | if is_trakt_auth(): |
| 38 | 36 | context_menu += ( |
| 39 | 37 | add_trakt_watchlist_context_menu("movies", ids) |
| … | … | @@ -1,8 +1,13 @@ |
| 1 | from lib.clients.tmdb.base import BaseTmdbClient | |
| 1 | 2 | from lib.clients.tmdb.utils.collections_utils import ( |
| 2 | 3 | POPULAR_COLLECTIONS, |
| 3 | 4 | TOP_RATED_COLLECTIONS, |
| 4 | 5 | ) |
| 5 | from lib.clients.tmdb.base import BaseTmdbClient | |
| 6 | from lib.clients.tmdb.utils.utils import ( | |
| 7 | add_kodi_dir_item, | |
| 8 | get_tmdb_movie_details, | |
| 9 | tmdb_get, | |
| 10 | ) | |
| 6 | 11 | from lib.db.pickle_db import PickleDatabase |
| 7 | 12 | from lib.utils.general.utils import ( |
| 8 | 13 | add_next_button, |
| … | … | @@ -10,24 +15,16 @@ from lib.utils.general.utils import ( |
| 10 | 15 | set_media_infoTag, |
| 11 | 16 | set_pluging_category, |
| 12 | 17 | ) |
| 13 | ||
| 14 | 18 | from lib.utils.kodi.utils import ( |
| 15 | 19 | add_directory_items_batch, |
| 16 | 20 | apply_section_view, |
| 17 | 21 | build_url, |
| 18 | 22 | end_of_directory, |
| 19 | 23 | make_list_item, |
| 20 | show_keyboard, | |
| 21 | 24 | notification, |
| 25 | show_keyboard, | |
| 22 | 26 | translation, |
| 23 | 27 | ) |
| 24 | from lib.clients.tmdb.utils.utils import ( | |
| 25 | tmdb_get, | |
| 26 | add_kodi_dir_item, | |
| 27 | get_tmdb_movie_details, | |
| 28 | ) | |
| 29 | ||
| 30 | ||
| 31 | 28 | |
| 32 | 29 | |
| 33 | 30 | class TmdbCollections(BaseTmdbClient): |
| … | … | @@ -51,7 +48,7 @@ class TmdbCollections(BaseTmdbClient): |
| 51 | 48 | imdb_id = "" |
| 52 | 49 | details = get_tmdb_movie_details(tmdb_id) |
| 53 | 50 | if details: |
| 54 | imdb_id = getattr(details, "external_ids").get("imdb_id", "") | |
| 51 | imdb_id = details.external_ids.get("imdb_id", "") | |
| 55 | 52 | |
| 56 | 53 | add_kodi_dir_item( |
| 57 | 54 | list_item=movie_item, |
| … | … | @@ -110,15 +107,11 @@ class TmdbCollections(BaseTmdbClient): |
| 110 | 107 | end_of_directory() |
| 111 | 108 | return |
| 112 | 109 | |
| 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) | |
| 116 | 111 | |
| 117 | 112 | # Add "Next" button |
| 118 | 113 | 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) | |
| 122 | 115 | |
| 123 | 116 | end_of_directory() |
| 124 | 117 | apply_section_view("view.movies", content_type="movies") |
| … | … | @@ -137,9 +130,7 @@ class TmdbCollections(BaseTmdbClient): |
| 137 | 130 | end_of_directory() |
| 138 | 131 | return |
| 139 | 132 | |
| 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) | |
| 143 | 134 | |
| 144 | 135 | # Add "Next" button |
| 145 | 136 | if end_index < len(TOP_RATED_COLLECTIONS): |
| … | … | @@ -162,7 +153,7 @@ class TmdbCollections(BaseTmdbClient): |
| 162 | 153 | return |
| 163 | 154 | |
| 164 | 155 | if page == 1: |
| 165 | PickleDatabase().set_key("collection_search_query", query) | |
| 156 | PickleDatabase().set_key("collection_search_query", query) | |
| 166 | 157 | |
| 167 | 158 | results = tmdb_get("search_collections", params={"query": query, "page": page}) |
| 168 | 159 | if not results or getattr(results, "total_results", 0) == 0: |
| … | … | @@ -170,9 +161,7 @@ class TmdbCollections(BaseTmdbClient): |
| 170 | 161 | end_of_directory() |
| 171 | 162 | return |
| 172 | 163 | |
| 173 | execute_thread_pool( | |
| 174 | getattr(results, "results"), TmdbCollections._add_collection_item | |
| 175 | ) | |
| 164 | execute_thread_pool(results.results, TmdbCollections._add_collection_item) | |
| 176 | 165 | |
| 177 | 166 | add_next_button( |
| 178 | 167 | "handle_collection_query", |
| … | … | @@ -192,6 +181,6 @@ class TmdbCollections(BaseTmdbClient): |
| 192 | 181 | Helper function to extract collection ID from movie details. |
| 193 | 182 | Designed to be used with execute_thread_pool. |
| 194 | 183 | """ |
| 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: | |
| 197 | 186 | collection_ids_set.add(movie_details.get("belongs_to_collection").get("id")) |
| … | … | @@ -1,4 +1,5 @@ |
| 1 | 1 | import json |
| 2 | ||
| 2 | 3 | from lib.clients.tmdb.base import BaseTmdbClient |
| 3 | 4 | from lib.clients.tmdb.utils.utils import add_kodi_dir_item, tmdb_get |
| 4 | 5 | from lib.utils.general.utils import ( |
| … | … | @@ -11,10 +12,9 @@ from lib.utils.general.utils import ( |
| 11 | 12 | from lib.utils.kodi.utils import ( |
| 12 | 13 | build_url, |
| 13 | 14 | end_of_directory, |
| 14 | kodilog, | |
| 15 | 15 | make_list_item, |
| 16 | show_keyboard, | |
| 17 | 16 | notification, |
| 17 | show_keyboard, | |
| 18 | 18 | translation, |
| 19 | 19 | ) |
| 20 | 20 | |
| … | … | @@ -41,9 +41,7 @@ class PeopleClient(BaseTmdbClient): |
| 41 | 41 | end_of_directory(cache=False) |
| 42 | 42 | return |
| 43 | 43 | |
| 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) | |
| 47 | 45 | |
| 48 | 46 | add_next_button( |
| 49 | 47 | "handle_tmdb_query", |
| … | … | @@ -82,7 +80,7 @@ class PeopleClient(BaseTmdbClient): |
| 82 | 80 | return |
| 83 | 81 | PeopleClient.show_people_details(person, mode) |
| 84 | 82 | |
| 85 | execute_thread_pool(getattr(credits, "cast"), get_media_credits) | |
| 83 | execute_thread_pool(credits.cast, get_media_credits) | |
| 86 | 84 | |
| 87 | 85 | end_of_directory() |
| 88 | 86 | |
| … | … | @@ -95,9 +93,7 @@ class PeopleClient(BaseTmdbClient): |
| 95 | 93 | notification(translation(90389)) |
| 96 | 94 | return |
| 97 | 95 | |
| 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) | |
| 101 | 97 | |
| 102 | 98 | add_next_button( |
| 103 | 99 | "handle_tmdb_query", |
| … | … | @@ -117,9 +113,7 @@ class PeopleClient(BaseTmdbClient): |
| 117 | 113 | notification(translation(90389)) |
| 118 | 114 | return |
| 119 | 115 | |
| 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) | |
| 123 | 117 | |
| 124 | 118 | add_next_button( |
| 125 | 119 | "handle_tmdb_query", |
| … | … | @@ -150,8 +144,8 @@ class PeopleClient(BaseTmdbClient): |
| 150 | 144 | |
| 151 | 145 | tmdb_id = credit.get("id") |
| 152 | 146 | 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") | |
| 155 | 149 | |
| 156 | 150 | ids = {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id} |
| 157 | 151 | |
| … | … | @@ -189,9 +183,7 @@ class PeopleClient(BaseTmdbClient): |
| 189 | 183 | set_media_infoTag(list_item, data=details, mode=mode) |
| 190 | 184 | add_kodi_dir_item( |
| 191 | 185 | 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")), | |
| 195 | 187 | is_folder=False, |
| 196 | 188 | ) |
| 197 | 189 | |
| … | … | @@ -200,7 +192,7 @@ class PeopleClient(BaseTmdbClient): |
| 200 | 192 | from lib.gui.actor_info_window import ActorInfoWindow |
| 201 | 193 | from lib.utils.kodi.utils import ADDON_PATH |
| 202 | 194 | |
| 203 | mode = params.get("mode") | |
| 195 | params.get("mode") | |
| 204 | 196 | person_id = params.get("person_id") |
| 205 | 197 | window = ActorInfoWindow( |
| 206 | 198 | "actor_info.xml", |
| … | … | @@ -218,17 +210,13 @@ class PeopleClient(BaseTmdbClient): |
| 218 | 210 | |
| 219 | 211 | if mode == "movies": |
| 220 | 212 | 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")) | |
| 224 | 214 | if not person_credits: |
| 225 | 215 | notification(translation(90421)) |
| 226 | 216 | return |
| 227 | 217 | elif mode == "tv": |
| 228 | 218 | 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")) | |
| 232 | 220 | if not person_credits: |
| 233 | 221 | notification(translation(90421)) |
| 234 | 222 | return |
| … | … | @@ -240,12 +228,8 @@ class PeopleClient(BaseTmdbClient): |
| 240 | 228 | def get_date(c): |
| 241 | 229 | return c.get("release_date") or c.get("first_air_date") or "" |
| 242 | 230 | |
| 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) | |
| 246 | 232 | |
| 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) | |
| 250 | 234 | |
| 251 | 235 | end_of_directory() |
| … | … | @@ -1,17 +1,25 @@ |
| 1 | from concurrent.futures import ThreadPoolExecutor | |
| 2 | from datetime import datetime, timedelta | |
| 3 | 1 | import json |
| 4 | 2 | import os |
| 3 | from concurrent.futures import ThreadPoolExecutor | |
| 4 | from datetime import datetime, timedelta | |
| 5 | from urllib.parse import quote | |
| 5 | 6 | |
| 6 | 7 | from lib.api.tmdbv3api.as_obj import AsObj |
| 7 | 8 | from lib.api.tmdbv3api.objs.anime import TmdbAnime |
| 9 | from lib.api.tmdbv3api.objs.search import Search | |
| 10 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 8 | 11 | from lib.clients.tmdb.anime import TmdbAnimeClient |
| 9 | 12 | from lib.clients.tmdb.base import BaseTmdbClient |
| 10 | 13 | from lib.clients.tmdb.people_client import PeopleClient |
| 11 | from lib.api.tmdbv3api.objs.search import Search | |
| 12 | from lib.clients.tmdb.utils.utils import add_kodi_dir_item, tmdb_get | |
| 14 | from lib.clients.tmdb.utils.utils import ( | |
| 15 | add_kodi_dir_item, | |
| 16 | filter_excluded_languages, | |
| 17 | tmdb_get, | |
| 18 | ) | |
| 13 | 19 | from lib.db.cached import cache |
| 20 | from lib.db.pickle_db import PickleDatabase | |
| 14 | 21 | from lib.utils.general.utils import ( |
| 22 | Anime, | |
| 15 | 23 | add_next_button, |
| 16 | 24 | execute_thread_pool, |
| 17 | 25 | set_content_type, |
| … | … | @@ -19,8 +27,6 @@ from lib.utils.general.utils import ( |
| 19 | 27 | set_pluging_category, |
| 20 | 28 | translate_weekday, |
| 21 | 29 | ) |
| 22 | ||
| 23 | from lib.db.pickle_db import PickleDatabase | |
| 24 | 30 | from lib.utils.kodi.utils import ( |
| 25 | 31 | ADDON_PATH, |
| 26 | 32 | add_directory_items_batch, |
| … | … | @@ -30,17 +36,17 @@ from lib.utils.kodi.utils import ( |
| 30 | 36 | end_of_directory, |
| 31 | 37 | kodilog, |
| 32 | 38 | make_list_item, |
| 39 | notification, | |
| 33 | 40 | show_busy_dialog, |
| 34 | 41 | show_keyboard, |
| 35 | notification, | |
| 36 | 42 | translation, |
| 37 | 43 | ) |
| 38 | ||
| 39 | 44 | from lib.utils.views.shows import show_episode_info, show_season_info |
| 40 | from lib.utils.views.weekly_calendar import is_this_week, parse_date_str | |
| 41 | from lib.utils.views.weekly_calendar import get_episodes_for_show | |
| 42 | ||
| 43 | from lib.utils.general.utils import Anime | |
| 45 | from lib.utils.views.weekly_calendar import ( | |
| 46 | get_episodes_for_show, | |
| 47 | is_this_week, | |
| 48 | parse_date_str, | |
| 49 | ) | |
| 44 | 50 | |
| 45 | 51 | |
| 46 | 52 | def _apply_tmdb_view(mode, content_type=""): |
| … | … | @@ -54,8 +60,9 @@ def _apply_tmdb_view(mode, content_type=""): |
| 54 | 60 | return apply_section_view("view.episodes", content_type or "episodes") |
| 55 | 61 | return apply_section_view("view.main", content_type=content_type) |
| 56 | 62 | |
| 57 | import xbmcgui | |
| 63 | ||
| 58 | 64 | import xbmc |
| 65 | import xbmcgui | |
| 59 | 66 | |
| 60 | 67 | |
| 61 | 68 | class TmdbClient(BaseTmdbClient): |
| … | … | @@ -108,7 +115,8 @@ class TmdbClient(BaseTmdbClient): |
| 108 | 115 | |
| 109 | 116 | media_type = TmdbClient._get_result_value(item, "media_type", "") or mode |
| 110 | 117 | 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}" | |
| 112 | 120 | |
| 113 | 121 | cached_metadata = cache.get(cache_key) |
| 114 | 122 | if cached_metadata: |
| … | … | @@ -117,6 +125,26 @@ class TmdbClient(BaseTmdbClient): |
| 117 | 125 | return merged |
| 118 | 126 | |
| 119 | 127 | 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 | ||
| 120 | 148 | path = "movie_images" if normalized_mode == "movies" else "tv_images" |
| 121 | 149 | images = tmdb_get(path, {"id": tmdb_id}) |
| 122 | 150 | if images: |
| … | … | @@ -228,9 +256,7 @@ class TmdbClient(BaseTmdbClient): |
| 228 | 256 | ) |
| 229 | 257 | |
| 230 | 258 | 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) | |
| 234 | 260 | |
| 235 | 261 | handlers = { |
| 236 | 262 | Anime.SEARCH: handle_search, |
| … | … | @@ -246,9 +272,7 @@ class TmdbClient(BaseTmdbClient): |
| 246 | 272 | if handler: |
| 247 | 273 | data = handler() |
| 248 | 274 | if data: |
| 249 | TmdbAnimeClient().process_anime_results( | |
| 250 | data, submode, page, mode, category | |
| 251 | ) | |
| 275 | TmdbAnimeClient().process_anime_results(data, submode, page, mode, category) | |
| 252 | 276 | |
| 253 | 277 | @staticmethod |
| 254 | 278 | def handle_tmdb_search(params): |
| … | … | @@ -260,9 +284,7 @@ class TmdbClient(BaseTmdbClient): |
| 260 | 284 | query = params.get("query") |
| 261 | 285 | if not query: |
| 262 | 286 | 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") | |
| 266 | 288 | ) |
| 267 | 289 | if not query: |
| 268 | 290 | end_of_directory(cache=False) |
| … | … | @@ -273,6 +295,7 @@ class TmdbClient(BaseTmdbClient): |
| 273 | 295 | if page == 1: |
| 274 | 296 | PickleDatabase().set_key("search_query", query) |
| 275 | 297 | from datetime import timedelta |
| 298 | ||
| 276 | 299 | from lib.db.cached import cache |
| 277 | 300 | from lib.utils.kodi.settings import get_cache_expiration |
| 278 | 301 | |
| … | … | @@ -290,21 +313,29 @@ class TmdbClient(BaseTmdbClient): |
| 290 | 313 | return |
| 291 | 314 | |
| 292 | 315 | results = getattr(data, "results", []) |
| 316 | results = filter_excluded_languages(results) | |
| 293 | 317 | if results: |
| 294 | 318 | directory_items = [] |
| 295 | 319 | for item in results: |
| 296 | 320 | tmdb_id = item.get("id", "") |
| 297 | title = item.get("title", "") or item.get("name", "") | |
| 298 | 321 | media_type = item.get("media_type", "") |
| 299 | 322 | |
| 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 | ||
| 300 | 334 | if media_type == "movie": |
| 301 | 335 | label_title = f"[B]{translation(90008)} -[/B] {title}" |
| 302 | 336 | elif media_type == "tv": |
| 303 | 337 | label_title = f"[B]{translation(90007)} -[/B] {title}" |
| 304 | else: | |
| 305 | continue | |
| 306 | 338 | |
| 307 | metadata = TmdbClient._get_cached_tmdb_item_metadata(item, media_type) | |
| 308 | 339 | list_item = make_list_item(label=label_title) |
| 309 | 340 | # Use item directly as tmdb_obj for basic info |
| 310 | 341 | set_media_infoTag(list_item, data=metadata, mode=media_type) |
| … | … | @@ -321,7 +352,7 @@ class TmdbClient(BaseTmdbClient): |
| 321 | 352 | add_next_button("handle_tmdb_search", page=page + 1, mode=mode) |
| 322 | 353 | end_of_directory() |
| 323 | 354 | _apply_tmdb_view("main") |
| 324 | ||
| 355 | ||
| 325 | 356 | close_busy_dialog() |
| 326 | 357 | |
| 327 | 358 | @staticmethod |
| … | … | @@ -358,6 +389,8 @@ class TmdbClient(BaseTmdbClient): |
| 358 | 389 | return |
| 359 | 390 | |
| 360 | 391 | results = getattr(data, "results", []) |
| 392 | if mode != "anime": | |
| 393 | results = filter_excluded_languages(results) | |
| 361 | 394 | if results: |
| 362 | 395 | display_mode = submode if mode == "anime" and submode else mode |
| 363 | 396 | directory_items = [] |
| … | … | @@ -419,6 +452,8 @@ class TmdbClient(BaseTmdbClient): |
| 419 | 452 | return |
| 420 | 453 | |
| 421 | 454 | results = getattr(data, "results", []) |
| 455 | if mode != "anime": | |
| 456 | results = filter_excluded_languages(results) | |
| 422 | 457 | if results: |
| 423 | 458 | display_mode = submode if mode == "anime" and submode else mode |
| 424 | 459 | directory_items = [] |
| … | … | @@ -440,9 +475,7 @@ class TmdbClient(BaseTmdbClient): |
| 440 | 475 | ) |
| 441 | 476 | directory_items.append(item_tuple) |
| 442 | 477 | 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) | |
| 446 | 479 | end_of_directory() |
| 447 | 480 | _apply_tmdb_view(display_mode) |
| 448 | 481 | |
| … | … | @@ -478,13 +511,19 @@ class TmdbClient(BaseTmdbClient): |
| 478 | 511 | return |
| 479 | 512 | |
| 480 | 513 | results = getattr(data, "results", []) |
| 514 | results = filter_excluded_languages(results) | |
| 481 | 515 | |
| 482 | 516 | directory_items = [] |
| 483 | 517 | for res in results: |
| 484 | 518 | tmdb_id = getattr(res, "id", "") |
| 485 | title = getattr(res, "title", "") or getattr(res, "name", "") | |
| 486 | 519 | result_media_type = getattr(res, "media_type", "") or "" |
| 487 | 520 | 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 | ) | |
| 488 | 527 | list_item = make_list_item(label=title) |
| 489 | 528 | set_media_infoTag(list_item, data=metadata, mode=mode) |
| 490 | 529 | item_tuple = BaseTmdbClient.add_media_directory_item( |
| … | … | @@ -511,13 +550,19 @@ class TmdbClient(BaseTmdbClient): |
| 511 | 550 | return |
| 512 | 551 | |
| 513 | 552 | results = getattr(data, "results", []) |
| 553 | results = filter_excluded_languages(results) | |
| 514 | 554 | |
| 515 | 555 | directory_items = [] |
| 516 | 556 | for res in results: |
| 517 | 557 | tmdb_id = getattr(res, "id", "") |
| 518 | title = getattr(res, "title", "") or getattr(res, "name", "") | |
| 519 | 558 | result_media_type = getattr(res, "media_type", "") or "" |
| 520 | 559 | 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 | ) | |
| 521 | 566 | list_item = make_list_item(label=title) |
| 522 | 567 | set_media_infoTag(list_item, data=metadata, mode=mode) |
| 523 | 568 | item_tuple = BaseTmdbClient.add_media_directory_item( |
| … | … | @@ -530,9 +575,7 @@ class TmdbClient(BaseTmdbClient): |
| 530 | 575 | ) |
| 531 | 576 | directory_items.append(item_tuple) |
| 532 | 577 | 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) | |
| 536 | 579 | end_of_directory() |
| 537 | 580 | _apply_tmdb_view("movies") |
| 538 | 581 | |
| … | … | @@ -613,12 +656,18 @@ class TmdbClient(BaseTmdbClient): |
| 613 | 656 | return |
| 614 | 657 | |
| 615 | 658 | results = getattr(data, "results", []) |
| 659 | results = filter_excluded_languages(results) | |
| 616 | 660 | directory_items = [] |
| 617 | 661 | for res in results: |
| 618 | 662 | tmdb_id = getattr(res, "id", "") |
| 619 | title = getattr(res, "title", "") or getattr(res, "name", "") | |
| 620 | 663 | result_media_type = getattr(res, "media_type", "") or "" |
| 621 | 664 | 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 | ) | |
| 622 | 671 | list_item = make_list_item(label=title) |
| 623 | 672 | set_media_infoTag(list_item, data=metadata, mode=mode) |
| 624 | 673 | item_tuple = BaseTmdbClient.add_media_directory_item( |
| … | … | @@ -655,6 +704,7 @@ class TmdbClient(BaseTmdbClient): |
| 655 | 704 | return |
| 656 | 705 | |
| 657 | 706 | results = getattr(data, "results", []) |
| 707 | results = filter_excluded_languages(results) | |
| 658 | 708 | directory_items = [] |
| 659 | 709 | for res in results: |
| 660 | 710 | tmdb_id = getattr(res, "id", "") |
| … | … | @@ -673,9 +723,7 @@ class TmdbClient(BaseTmdbClient): |
| 673 | 723 | ) |
| 674 | 724 | directory_items.append(item_tuple) |
| 675 | 725 | 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) | |
| 679 | 727 | end_of_directory() |
| 680 | 728 | _apply_tmdb_view(mode) |
| 681 | 729 | |
| … | … | @@ -734,12 +782,18 @@ class TmdbClient(BaseTmdbClient): |
| 734 | 782 | return |
| 735 | 783 | |
| 736 | 784 | results = getattr(data, "results", []) |
| 785 | results = filter_excluded_languages(results, force_allow_lang=lang) | |
| 737 | 786 | directory_items = [] |
| 738 | 787 | for res in results: |
| 739 | 788 | tmdb_id = getattr(res, "id", "") |
| 740 | title = getattr(res, "title", "") or getattr(res, "name", "") | |
| 741 | 789 | result_media_type = getattr(res, "media_type", "") or "" |
| 742 | 790 | 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 | ) | |
| 743 | 797 | list_item = make_list_item(label=title) |
| 744 | 798 | set_media_infoTag(list_item, data=metadata, mode=mode) |
| 745 | 799 | item_tuple = BaseTmdbClient.add_media_directory_item( |
| … | … | @@ -798,9 +852,7 @@ class TmdbClient(BaseTmdbClient): |
| 798 | 852 | from lib.clients.tmdb.utils.utils import NETWORKS |
| 799 | 853 | |
| 800 | 854 | 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), "") | |
| 804 | 856 | set_pluging_category(network_name) |
| 805 | 857 | |
| 806 | 858 | mode = params.get("mode") |
| … | … | @@ -830,12 +882,18 @@ class TmdbClient(BaseTmdbClient): |
| 830 | 882 | return |
| 831 | 883 | |
| 832 | 884 | results = getattr(data, "results", []) |
| 885 | results = filter_excluded_languages(results) | |
| 833 | 886 | directory_items = [] |
| 834 | 887 | for res in results: |
| 835 | 888 | tmdb_id = getattr(res, "id", "") |
| 836 | title = getattr(res, "title", "") or getattr(res, "name", "") | |
| 837 | 889 | result_media_type = getattr(res, "media_type", "") or "" |
| 838 | 890 | 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 | ) | |
| 839 | 897 | list_item = make_list_item(label=title) |
| 840 | 898 | set_media_infoTag(list_item, data=metadata, mode=mode) |
| 841 | 899 | item_tuple = BaseTmdbClient.add_media_directory_item( |
| … | … | @@ -862,7 +920,7 @@ class TmdbClient(BaseTmdbClient): |
| 862 | 920 | def show_calendar_items(query, page, mode): |
| 863 | 921 | set_pluging_category(translation(90021)) |
| 864 | 922 | 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: | |
| 866 | 924 | notification(translation(90394)) |
| 867 | 925 | end_of_directory() |
| 868 | 926 | return |
| … | … | @@ -880,9 +938,9 @@ class TmdbClient(BaseTmdbClient): |
| 880 | 938 | if air_date and is_this_week(air_date): |
| 881 | 939 | results.append((getattr(show, "name", ""), show, ep, details)) |
| 882 | 940 | |
| 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) | |
| 886 | 944 | |
| 887 | 945 | directory_items = [] |
| 888 | 946 | |
| … | … | @@ -891,9 +949,7 @@ class TmdbClient(BaseTmdbClient): |
| 891 | 949 | date_item = make_list_item( |
| 892 | 950 | label=f"[UPPERCASE][COLOR=orange]Today: {current_date}[/COLOR][/UPPERCASE]" |
| 893 | 951 | ) |
| 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")}) | |
| 897 | 953 | item = add_kodi_dir_item(date_item, "", is_folder=False, batch=True) |
| 898 | 954 | directory_items.append(item) |
| 899 | 955 | |
| … | … | @@ -902,12 +958,12 @@ class TmdbClient(BaseTmdbClient): |
| 902 | 958 | results_today = [r for r in results if r[2].get("air_date") == today_str] |
| 903 | 959 | results_other = [r for r in results if r[2].get("air_date") != today_str] |
| 904 | 960 | |
| 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 | ) | |
| 908 | 964 | |
| 909 | 965 | 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"]} | |
| 911 | 967 | |
| 912 | 968 | air_date_obj = parse_date_str(ep["air_date"]) |
| 913 | 969 | weekday_name = air_date_obj.strftime("%A") |
| … | … | @@ -915,20 +971,16 @@ class TmdbClient(BaseTmdbClient): |
| 915 | 971 | |
| 916 | 972 | # Mark if episode is released today |
| 917 | 973 | 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 "" | |
| 923 | 975 | |
| 924 | 976 | ep_title = f"{mark}{weekday_name_translated} - ({ep['air_date']}) - {title} - S{ep['season']:02}E{ep['number']:02}" |
| 925 | 977 | |
| 926 | tmdb_id = getattr(show, "id") | |
| 978 | tmdb_id = show.id | |
| 927 | 979 | if details is None: |
| 928 | 980 | kodilog(f"Show details not found for TMDB ID: {tmdb_id}") |
| 929 | 981 | continue |
| 930 | 982 | |
| 931 | external_ids = getattr(details, "external_ids") | |
| 983 | external_ids = details.external_ids | |
| 932 | 984 | imdb_id = external_ids.get("imdb_id", "") |
| 933 | 985 | tvdb_id = external_ids.get("tvdb_id", "") |
| 934 | 986 | |
| … | … | @@ -1082,9 +1134,7 @@ class TmdbClient(BaseTmdbClient): |
| 1082 | 1134 | ) # Using movies content type ensures better poster rendering in many skins |
| 1083 | 1135 | |
| 1084 | 1136 | 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}) | |
| 1088 | 1138 | |
| 1089 | 1139 | def fetch_tv(): |
| 1090 | 1140 | return tmdb_get("discover_tv", {"with_keywords": keyword_id, "page": page}) |
| … | … | @@ -1102,7 +1152,7 @@ class TmdbClient(BaseTmdbClient): |
| 1102 | 1152 | if isinstance(item, dict): |
| 1103 | 1153 | item["media_type"] = "movie" |
| 1104 | 1154 | else: |
| 1105 | setattr(item, "media_type", "movie") | |
| 1155 | item.media_type = "movie" | |
| 1106 | 1156 | results.extend(movie_data.results) |
| 1107 | 1157 | total_pages = max(total_pages, getattr(movie_data, "total_pages", 0)) |
| 1108 | 1158 | |
| … | … | @@ -1112,7 +1162,7 @@ class TmdbClient(BaseTmdbClient): |
| 1112 | 1162 | if isinstance(item, dict): |
| 1113 | 1163 | item["media_type"] = "tv" |
| 1114 | 1164 | else: |
| 1115 | setattr(item, "media_type", "tv") | |
| 1165 | item.media_type = "tv" | |
| 1116 | 1166 | results.extend(tv_data.results) |
| 1117 | 1167 | total_pages = max(total_pages, getattr(tv_data, "total_pages", 0)) |
| 1118 | 1168 | |
| … | … | @@ -1131,13 +1181,10 @@ class TmdbClient(BaseTmdbClient): |
| 1131 | 1181 | reverse=True, |
| 1132 | 1182 | ) |
| 1133 | 1183 | |
| 1184 | results = filter_excluded_languages(results) | |
| 1134 | 1185 | directory_items = [] |
| 1135 | 1186 | 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", "") | |
| 1141 | 1188 | title = ( |
| 1142 | 1189 | getattr(res, "title", "") or getattr(res, "name", "") |
| 1143 | 1190 | if not isinstance(res, dict) |
| … | … | @@ -1209,13 +1256,19 @@ class TmdbClient(BaseTmdbClient): |
| 1209 | 1256 | return |
| 1210 | 1257 | |
| 1211 | 1258 | results = getattr(data, "results", []) |
| 1259 | results = filter_excluded_languages(results) | |
| 1212 | 1260 | |
| 1213 | 1261 | directory_items = [] |
| 1214 | 1262 | for res in results: |
| 1215 | 1263 | tmdb_id = getattr(res, "id", "") |
| 1216 | title = getattr(res, "title", "") or getattr(res, "name", "") | |
| 1217 | 1264 | result_media_type = getattr(res, "media_type", "") or "" |
| 1218 | 1265 | 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 | ) | |
| 1219 | 1272 | list_item = make_list_item(label=title) |
| 1220 | 1273 | set_media_infoTag(list_item, data=metadata, mode=mode) |
| 1221 | 1274 | item_tuple = BaseTmdbClient.add_media_directory_item( |
| … | … | @@ -1248,6 +1301,10 @@ class TmdbClient(BaseTmdbClient): |
| 1248 | 1301 | tmdb_id = ids.get("tmdb_id") |
| 1249 | 1302 | page = int(params.get("page", 1)) |
| 1250 | 1303 | |
| 1304 | if not tmdb_id: | |
| 1305 | notification(translation(90400)) | |
| 1306 | return | |
| 1307 | ||
| 1251 | 1308 | if not tmdb_id: |
| 1252 | 1309 | notification(translation(90400)) |
| 1253 | 1310 | return |
| … | … | @@ -1276,13 +1333,19 @@ class TmdbClient(BaseTmdbClient): |
| 1276 | 1333 | return |
| 1277 | 1334 | |
| 1278 | 1335 | results = getattr(data, "results", []) |
| 1336 | results = filter_excluded_languages(results) | |
| 1279 | 1337 | |
| 1280 | 1338 | directory_items = [] |
| 1281 | 1339 | for res in results: |
| 1282 | 1340 | tmdb_id = getattr(res, "id", "") |
| 1283 | title = getattr(res, "title", "") or getattr(res, "name", "") | |
| 1284 | 1341 | result_media_type = getattr(res, "media_type", "") or "" |
| 1285 | 1342 | 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 | ) | |
| 1286 | 1349 | list_item = make_list_item(label=title) |
| 1287 | 1350 | set_media_infoTag(list_item, data=metadata, mode=mode) |
| 1288 | 1351 | item_tuple = BaseTmdbClient.add_media_directory_item( |
| … | … | @@ -1459,12 +1522,21 @@ class TmdbClient(BaseTmdbClient): |
| 1459 | 1522 | |
| 1460 | 1523 | selected = xbmcgui.Dialog().select( |
| 1461 | 1524 | translation(90733), |
| 1462 | [translation(90740), translation(90741)], | |
| 1525 | [ | |
| 1526 | translation(90740), | |
| 1527 | translation(90741), | |
| 1528 | translation(90773), | |
| 1529 | ], | |
| 1463 | 1530 | ) |
| 1464 | 1531 | if selected < 0: |
| 1465 | 1532 | return |
| 1466 | 1533 | |
| 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": | |
| 1468 | 1540 | edited_query = show_keyboard(id=90736, default=query) |
| 1469 | 1541 | if not edited_query: |
| 1470 | 1542 | return |
| … | … | @@ -1482,6 +1554,37 @@ class TmdbClient(BaseTmdbClient): |
| 1482 | 1554 | if not query: |
| 1483 | 1555 | return |
| 1484 | 1556 | |
| 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 | ||
| 1485 | 1588 | season_default = str(tv_data.get("season", "")) |
| 1486 | 1589 | season_input = xbmcgui.Dialog().numeric(0, translation(90512), season_default) |
| 1487 | 1590 | if not season_input: |
| … | … | @@ -1492,9 +1595,7 @@ class TmdbClient(BaseTmdbClient): |
| 1492 | 1595 | return |
| 1493 | 1596 | |
| 1494 | 1597 | 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) | |
| 1498 | 1599 | if not episode_input: |
| 1499 | 1600 | return |
| 1500 | 1601 | episode_input = str(episode_input).strip() |
| … | … | @@ -5,7 +5,6 @@ from lib.api.tmdbv3api.objs.movie import Movie |
| 5 | 5 | from lib.api.tmdbv3api.objs.tv import TV |
| 6 | 6 | from lib.utils.kodi.utils import kodilog |
| 7 | 7 | |
| 8 | ||
| 9 | 8 | YOUTUBE_WATCH_URL = "https://www.youtube.com/watch?v={}" |
| 10 | 9 | |
| 11 | 10 |
| … | … | @@ -0,0 +1,40 @@ |
| 1 | EXCLUDABLE_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 | ] |
| … | … | @@ -1,13 +1,18 @@ |
| 1 | 1 | import json |
| 2 | from datetime import datetime, timedelta | |
| 3 | 2 | import os |
| 4 | 3 | import threading |
| 4 | from datetime import datetime, timedelta | |
| 5 | 5 | from typing import List, Optional |
| 6 | from urllib import parse | |
| 7 | ||
| 8 | import xbmc | |
| 9 | from xbmcplugin import addDirectoryItem | |
| 6 | 10 | |
| 7 | 11 | from lib.api.mdblist.mdblist import MDblistAPI |
| 8 | 12 | from lib.api.tmdbv3api.as_obj import AsObj |
| 9 | 13 | from lib.api.tmdbv3api.objs.anime import TmdbAnime |
| 10 | 14 | from lib.api.tmdbv3api.objs.collection import Collection |
| 15 | from lib.api.tmdbv3api.objs.discover import Discover | |
| 11 | 16 | from lib.api.tmdbv3api.objs.episode import Episode |
| 12 | 17 | from lib.api.tmdbv3api.objs.find import Find |
| 13 | 18 | from lib.api.tmdbv3api.objs.genre import Genre |
| … | … | @@ -15,27 +20,22 @@ from lib.api.tmdbv3api.objs.movie import Movie |
| 15 | 20 | from lib.api.tmdbv3api.objs.person import Person |
| 16 | 21 | from lib.api.tmdbv3api.objs.search import Search |
| 17 | 22 | from lib.api.tmdbv3api.objs.season import Season |
| 18 | from lib.api.tmdbv3api.objs.discover import Discover | |
| 19 | 23 | from lib.api.tmdbv3api.objs.trending import Trending |
| 20 | 24 | from lib.api.tmdbv3api.objs.tv import TV |
| 21 | ||
| 25 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 26 | from lib.db.cached import cache | |
| 27 | from lib.utils.general.utils import execute_thread_pool | |
| 28 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled | |
| 22 | 29 | from lib.utils.kodi.utils import ( |
| 23 | 30 | ADDON_HANDLE, |
| 24 | 31 | ADDON_PATH, |
| 25 | 32 | action_url_run, |
| 26 | 33 | container_update, |
| 27 | execute_builtin, | |
| 28 | kodilog, | |
| 34 | get_setting, | |
| 29 | 35 | kodi_play_media, |
| 36 | kodilog, | |
| 30 | 37 | translation, |
| 31 | 38 | ) |
| 32 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled | |
| 33 | from lib.utils.general.utils import execute_thread_pool | |
| 34 | from urllib import parse | |
| 35 | ||
| 36 | from lib.db.cached import cache | |
| 37 | from xbmcplugin import addDirectoryItem | |
| 38 | ||
| 39 | 39 | |
| 40 | 40 | LANGUAGES = [ |
| 41 | 41 | "ar-AE", |
| … | … | @@ -262,7 +262,7 @@ def add_icon_tmdb(item, icon_path="tmdb.png"): |
| 262 | 262 | |
| 263 | 263 | |
| 264 | 264 | def tmdb_get(path, params=None) -> Optional[AsObj]: |
| 265 | identifier = f"{path}|{params}" | |
| 265 | identifier = f"{path}|{params}|{TMDb().language}" | |
| 266 | 266 | data = cache.get(key=identifier) |
| 267 | 267 | if data: |
| 268 | 268 | return data |
| … | … | @@ -277,14 +277,10 @@ def tmdb_get(path, params=None) -> Optional[AsObj]: |
| 277 | 277 | "search_multi": lambda p: Search().multi(p["query"], page=p["page"]), |
| 278 | 278 | "search_collections": lambda p: Search().collections(p["query"], p["page"]), |
| 279 | 279 | "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), | |
| 283 | 281 | "tv_details": lambda p: TV().details(p["id"] if isinstance(p, dict) else p), |
| 284 | 282 | "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"]), | |
| 288 | 284 | "movie_genres": lambda _: Genre().movie_list(), |
| 289 | 285 | "show_genres": lambda _: Genre().tv_list(), |
| 290 | 286 | "discover_movie": lambda p: Discover().discover_movies(p), |
| … | … | @@ -299,42 +295,24 @@ def tmdb_get(path, params=None) -> Optional[AsObj]: |
| 299 | 295 | "popular_people": lambda p: Person().popular(page=p), |
| 300 | 296 | "trending_people": lambda p: Trending().person_week(page=p), |
| 301 | 297 | "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), | |
| 305 | 299 | "person_credits": lambda p: Person().combined_credits( |
| 306 | 300 | p["id"] if isinstance(p, dict) else p |
| 307 | 301 | ), |
| 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), | |
| 311 | 303 | "person_movie_credits": lambda p: Person().movie_credits( |
| 312 | 304 | p["id"] if isinstance(p, dict) else p |
| 313 | 305 | ), |
| 314 | 306 | "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), | |
| 327 | 311 | "anime_year": lambda p: TmdbAnime().anime_year(p), |
| 328 | 312 | "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"]), | |
| 338 | 316 | "movie_recommendations": lambda p: Movie().recommendations( |
| 339 | 317 | movie_id=p["id"], page=p["page"] |
| 340 | 318 | ), |
| … | … | @@ -344,6 +322,7 @@ def tmdb_get(path, params=None) -> Optional[AsObj]: |
| 344 | 322 | "tv_images": lambda p: TV().images(tv_id=p["id"]), |
| 345 | 323 | "movie_reviews": lambda p: Movie().reviews(movie_id=p["id"], page=p["page"]), |
| 346 | 324 | "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), | |
| 347 | 326 | } |
| 348 | 327 | |
| 349 | 328 | try: |
| … | … | @@ -356,9 +335,7 @@ def tmdb_get(path, params=None) -> Optional[AsObj]: |
| 356 | 335 | cache.set( |
| 357 | 336 | key=identifier, |
| 358 | 337 | 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), | |
| 362 | 339 | ) |
| 363 | 340 | |
| 364 | 341 | return data |
| … | … | @@ -393,9 +370,7 @@ def mdblist_get(path, params=None) -> Optional[List]: |
| 393 | 370 | cache.set( |
| 394 | 371 | key=identifier, |
| 395 | 372 | 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), | |
| 399 | 374 | ) |
| 400 | 375 | |
| 401 | 376 | return data |
| … | … | @@ -416,6 +391,24 @@ def get_tmdb_show_details(id: str) -> Optional[AsObj]: |
| 416 | 391 | return tmdb_get("tv_details", id) |
| 417 | 392 | |
| 418 | 393 | |
| 394 | def 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 | ||
| 419 | 412 | def filter_anime_by_keyword(results, mode): |
| 420 | 413 | filtered_anime = [] |
| 421 | 414 | anime_api = TmdbAnime() |
| … | … | @@ -423,9 +416,7 @@ def filter_anime_by_keyword(results, mode): |
| 423 | 416 | |
| 424 | 417 | def check_and_append(item, filtered_anime, list_lock): |
| 425 | 418 | 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"] | |
| 429 | 420 | for keyword in keywords: |
| 430 | 421 | if keyword["id"] == 210024: |
| 431 | 422 | with list_lock: |
| … | … | @@ -447,9 +438,7 @@ def _build_trailer_ids(ids, title=None, title_key="title"): |
| 447 | 438 | |
| 448 | 439 | def build_play_trailer_context_menu_item(ids, media_type, title=None, title_key="title"): |
| 449 | 440 | 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")): | |
| 453 | 442 | return None |
| 454 | 443 | |
| 455 | 444 | params = { |
| … | … | @@ -469,7 +458,9 @@ def build_play_trailer_context_menu_item(ids, media_type, title=None, title_key= |
| 469 | 458 | ) |
| 470 | 459 | |
| 471 | 460 | |
| 472 | def add_tmdb_movie_context_menu(mode, media_type, title=None, ids={}): | |
| 461 | def add_tmdb_movie_context_menu(mode, media_type, title=None, ids=None): | |
| 462 | if ids is None: | |
| 463 | ids = {} | |
| 473 | 464 | context_menu = [ |
| 474 | 465 | ( |
| 475 | 466 | translation(90049), |
| … | … | @@ -493,6 +484,10 @@ def add_tmdb_movie_context_menu(mode, media_type, title=None, ids={}): |
| 493 | 484 | force_select=True, |
| 494 | 485 | ), |
| 495 | 486 | ), |
| 487 | ( | |
| 488 | translation(90755), | |
| 489 | "RunPlugin(plugin://plugin.video.jacktook/?action=source_manager_toggle)", | |
| 490 | ), | |
| 496 | 491 | ( |
| 497 | 492 | "Extras", |
| 498 | 493 | 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={}): |
| 557 | 552 | ), |
| 558 | 553 | ), |
| 559 | 554 | ), |
| 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 | ), | |
| 560 | 569 | ( |
| 561 | 570 | translation(90116), |
| 562 | 571 | container_update(name="settings"), |
| … | … | @@ -565,7 +574,9 @@ def add_tmdb_movie_context_menu(mode, media_type, title=None, ids={}): |
| 565 | 574 | return context_menu |
| 566 | 575 | |
| 567 | 576 | |
| 568 | def add_tmdb_show_context_menu(mode, ids={}, title=None): | |
| 577 | def add_tmdb_show_context_menu(mode, ids=None, title=None): | |
| 578 | if ids is None: | |
| 579 | ids = {} | |
| 569 | 580 | context_menu = [ |
| 570 | 581 | ( |
| 571 | 582 | "Extras", |
| … | … | @@ -619,6 +630,10 @@ def add_tmdb_show_context_menu(mode, ids={}, title=None): |
| 619 | 630 | ), |
| 620 | 631 | ), |
| 621 | 632 | ), |
| 633 | ( | |
| 634 | translation(90755), | |
| 635 | "RunPlugin(plugin://plugin.video.jacktook/?action=source_manager_toggle)", | |
| 636 | ), | |
| 622 | 637 | ( |
| 623 | 638 | translation(90116), |
| 624 | 639 | container_update(name="settings"), |
| … | … | @@ -627,7 +642,9 @@ def add_tmdb_show_context_menu(mode, ids={}, title=None): |
| 627 | 642 | return context_menu |
| 628 | 643 | |
| 629 | 644 | |
| 630 | def add_tmdb_episode_context_menu(mode, tv_name=None, tv_data=None, ids={}): | |
| 645 | def add_tmdb_episode_context_menu(mode, tv_name=None, tv_data=None, ids=None): | |
| 646 | if ids is None: | |
| 647 | ids = {} | |
| 631 | 648 | return [ |
| 632 | 649 | ( |
| 633 | 650 | translation(90049), |
| … | … | @@ -651,6 +668,10 @@ def add_tmdb_episode_context_menu(mode, tv_name=None, tv_data=None, ids={}): |
| 651 | 668 | force_select=True, |
| 652 | 669 | ), |
| 653 | 670 | ), |
| 671 | ( | |
| 672 | translation(90755), | |
| 673 | "RunPlugin(plugin://plugin.video.jacktook/?action=source_manager_toggle)", | |
| 674 | ), | |
| 654 | 675 | ( |
| 655 | 676 | translation(90733), |
| 656 | 677 | action_url_run( |
| … | … | @@ -691,3 +712,43 @@ def add_tmdb_episode_context_menu(mode, tv_name=None, tv_data=None, ids={}): |
| 691 | 712 | container_update(name="settings"), |
| 692 | 713 | ), |
| 693 | 714 | ] |
| 715 | ||
| 716 | ||
| 717 | def 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 | ||
| 725 | def 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 |
| … | … | @@ -1,10 +1,10 @@ |
| 1 | 1 | import re |
| 2 | from typing import List, Dict, Tuple, Optional, Any, Callable | |
| 2 | from typing import Any, Callable, Dict, List, Optional, Tuple | |
| 3 | 3 | |
| 4 | 4 | from lib.clients.base import BaseClient, TorrentStream |
| 5 | from lib.utils.localization.countries import find_language_by_unicode | |
| 6 | from lib.utils.kodi.utils import convert_size_to_bytes, kodilog, translation | |
| 7 | 5 | from lib.utils.general.utils import USER_AGENT_HEADER, unicode_flag_to_country_code |
| 6 | from lib.utils.kodi.utils import convert_size_to_bytes, translation | |
| 7 | from lib.utils.localization.countries import find_language_by_unicode | |
| 8 | 8 | |
| 9 | 9 | |
| 10 | 10 | class Torrentio(BaseClient): |
| … | … | @@ -27,13 +27,13 @@ class Torrentio(BaseClient): |
| 27 | 27 | else: |
| 28 | 28 | self.handle_exception(translation(30228)) |
| 29 | 29 | return None |
| 30 | ||
| 30 | ||
| 31 | 31 | res = self.session.get(url, headers=USER_AGENT_HEADER, timeout=10) |
| 32 | 32 | if res.status_code != 200: |
| 33 | 33 | return |
| 34 | 34 | return self.parse_response(res) |
| 35 | 35 | except Exception as e: |
| 36 | self.handle_exception(f"{translation(30228)}: {str(e)}") | |
| 36 | self.handle_exception(f"{translation(30228)}: {e!s}") | |
| 37 | 37 | |
| 38 | 38 | def parse_response(self, res: Any) -> List[TorrentStream]: |
| 39 | 39 | res = res.json() |
| … | … | @@ -90,7 +90,7 @@ class Torrentio(BaseClient): |
| 90 | 90 | for flag in flag_emojis: |
| 91 | 91 | languages.append(unicode_flag_to_country_code(flag).upper()) |
| 92 | 92 | 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): | |
| 94 | 94 | full_languages.append(full_lang) |
| 95 | 95 | return languages, full_languages |
| 96 | 96 |
| … | … | @@ -1,4 +1,5 @@ |
| 1 | 1 | import json |
| 2 | ||
| 2 | 3 | from lib.api.trakt.base_cache import connect_database |
| 3 | 4 | |
| 4 | 5 | |
| … | … | @@ -14,34 +15,40 @@ class Paginator: |
| 14 | 15 | def _store_page(self, page_number, total_pages, data): |
| 15 | 16 | conn = self._connect() |
| 16 | 17 | cursor = conn.cursor() |
| 17 | cursor.execute(''' | |
| 18 | cursor.execute( | |
| 19 | """ | |
| 18 | 20 | INSERT OR REPLACE INTO paginated_data (id, page_number, data, total_pages) |
| 19 | 21 | 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 | ) | |
| 21 | 25 | conn.commit() |
| 22 | 26 | conn.close() |
| 23 | 27 | |
| 24 | 28 | def _retrieve_page(self, page_number): |
| 25 | 29 | conn = self._connect() |
| 26 | 30 | cursor = conn.cursor() |
| 27 | cursor.execute(''' | |
| 31 | cursor.execute( | |
| 32 | """ | |
| 28 | 33 | SELECT data FROM paginated_data WHERE page_number = ? |
| 29 | ''', (page_number,)) | |
| 34 | """, | |
| 35 | (page_number,), | |
| 36 | ) | |
| 30 | 37 | result = cursor.fetchone() |
| 31 | 38 | conn.close() |
| 32 | 39 | if result and result[0] is not None: |
| 33 | 40 | return json.loads(result[0]) |
| 34 | 41 | return None |
| 35 | ||
| 42 | ||
| 36 | 43 | def _clear_table(self): |
| 37 | 44 | conn = self._connect() |
| 38 | 45 | cursor = conn.cursor() |
| 39 | cursor.execute('DELETE FROM paginated_data') | |
| 46 | cursor.execute("DELETE FROM paginated_data") | |
| 40 | 47 | conn.commit() |
| 41 | 48 | conn.close() |
| 42 | ||
| 49 | ||
| 43 | 50 | def initialize(self, data): |
| 44 | self._clear_table() | |
| 51 | self._clear_table() | |
| 45 | 52 | self.total_pages = (len(data) + self.page_size - 1) // self.page_size |
| 46 | 53 | for i in range(self.total_pages): |
| 47 | 54 | start = i * self.page_size |
| … | … | @@ -53,7 +60,7 @@ class Paginator: |
| 53 | 60 | self.current_page = page_number - 1 |
| 54 | 61 | data = self._retrieve_page(page_number) |
| 55 | 62 | 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}") | |
| 57 | 64 | return data |
| 58 | 65 | |
| 59 | 66 | def next_page(self): |
| … | … | @@ -73,5 +80,4 @@ class Paginator: |
| 73 | 80 | return self.get_page(self.current_page + 1) |
| 74 | 81 | |
| 75 | 82 | |
| 76 | ||
| 77 | 83 | paginator_db = Paginator() |
| … | … | @@ -1,16 +1,18 @@ |
| 1 | import contextlib | |
| 1 | 2 | import json |
| 2 | 3 | from datetime import datetime, timedelta |
| 3 | from lib.clients.tmdb.utils.utils import build_play_trailer_context_menu_item, tmdb_get | |
| 4 | ||
| 4 | 5 | from lib.api.trakt.trakt import TraktAPI, TraktLists, TraktMovies, TraktTV |
| 5 | 6 | from lib.api.trakt.trakt_utils import ( |
| 7 | add_trakt_collection_context_menu, | |
| 6 | 8 | add_trakt_custom_list_context_menu, |
| 7 | 9 | add_trakt_favorites_context_menu, |
| 8 | 10 | add_trakt_watched_context_menu, |
| 9 | 11 | add_trakt_watchlist_context_menu, |
| 10 | 12 | is_trakt_auth, |
| 11 | 13 | ) |
| 14 | from lib.clients.tmdb.utils.utils import build_play_trailer_context_menu_item, tmdb_get | |
| 12 | 15 | from lib.clients.trakt.utils import add_kodi_dir_item, extract_ids |
| 13 | from lib.api.trakt.trakt_utils import add_trakt_collection_context_menu | |
| 14 | 16 | from lib.utils.general.utils import ( |
| 15 | 17 | Anime, |
| 16 | 18 | Enum, |
| … | … | @@ -22,7 +24,6 @@ from lib.utils.general.utils import ( |
| 22 | 24 | ) |
| 23 | 25 | from lib.utils.kodi.utils import ( |
| 24 | 26 | action_url_run, |
| 25 | add_directory_items_batch, | |
| 26 | 27 | build_url, |
| 27 | 28 | container_update, |
| 28 | 29 | dialog_select, |
| … | … | @@ -32,14 +33,15 @@ from lib.utils.kodi.utils import ( |
| 32 | 33 | execute_builtin, |
| 33 | 34 | get_datetime, |
| 34 | 35 | get_setting, |
| 36 | kodi_play_media, | |
| 35 | 37 | kodilog, |
| 36 | 38 | make_list_item, |
| 37 | 39 | notification, |
| 38 | kodi_play_media, | |
| 39 | 40 | refresh, |
| 40 | 41 | show_keyboard, |
| 41 | 42 | translation, |
| 42 | 43 | ) |
| 44 | ||
| 43 | 45 | from .paginator import paginator_db |
| 44 | 46 | |
| 45 | 47 | |
| … | … | @@ -137,9 +139,7 @@ class BaseTraktClient: |
| 137 | 139 | ) |
| 138 | 140 | |
| 139 | 141 | @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): | |
| 143 | 143 | trailer_item = build_play_trailer_context_menu_item( |
| 144 | 144 | ids=ids, |
| 145 | 145 | media_type="movie" if mode == "movies" else "tv", |
| … | … | @@ -162,12 +162,10 @@ class BaseTraktClient: |
| 162 | 162 | if trailer_item: |
| 163 | 163 | context_menu.append(trailer_item) |
| 164 | 164 | 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 | ) | |
| 171 | 169 | list_item.addContextMenuItems(context_menu) |
| 172 | 170 | add_kodi_dir_item( |
| 173 | 171 | list_item=list_item, |
| … | … | @@ -243,9 +241,8 @@ class TraktClient: |
| 243 | 241 | def _normalize_trakt_media_type(media_type, ids): |
| 244 | 242 | if media_type in ("tv", "show", "shows", "tvshow", "tvshows", "anime"): |
| 245 | 243 | 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" | |
| 249 | 246 | if ids.get("tvdb") or ids.get("tvdb_id"): |
| 250 | 247 | return "shows" |
| 251 | 248 | |
| … | … | @@ -318,10 +315,8 @@ class TraktClient: |
| 318 | 315 | if first_air_date is None and isinstance(show_details, dict): |
| 319 | 316 | first_air_date = show_details.get("first_air_date") |
| 320 | 317 | if first_air_date: |
| 321 | try: | |
| 318 | with contextlib.suppress(TypeError, ValueError): | |
| 322 | 319 | show_item["year"] = int(str(first_air_date)[:4]) |
| 323 | except (TypeError, ValueError): | |
| 324 | pass | |
| 325 | 320 | |
| 326 | 321 | return {"shows": [show_item]} |
| 327 | 322 | |
| … | … | @@ -340,9 +335,7 @@ class TraktClient: |
| 340 | 335 | seasons_payload = [] |
| 341 | 336 | |
| 342 | 337 | 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}) | |
| 346 | 339 | episodes = getattr(season_details, "episodes", None) |
| 347 | 340 | if episodes is None and isinstance(season_details, dict): |
| 348 | 341 | episodes = season_details.get("episodes", []) |
| … | … | @@ -369,9 +362,7 @@ class TraktClient: |
| 369 | 362 | episode_entries.append({"number": int(episode_number)}) |
| 370 | 363 | |
| 371 | 364 | 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}) | |
| 375 | 366 | |
| 376 | 367 | if not seasons_payload: |
| 377 | 368 | return None |
| … | … | @@ -414,7 +405,11 @@ class TraktClient: |
| 414 | 405 | "shows": [ |
| 415 | 406 | { |
| 416 | 407 | "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 | ], | |
| 418 | 413 | } |
| 419 | 414 | ] |
| 420 | 415 | } |
| … | … | @@ -444,15 +439,9 @@ class TraktClient: |
| 444 | 439 | set_content_type(mode) |
| 445 | 440 | params = params or {} |
| 446 | 441 | 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), | |
| 456 | 445 | } |
| 457 | 446 | handler = handlers.get(mode) |
| 458 | 447 | if handler: |
| … | … | @@ -465,9 +454,7 @@ class TraktClient: |
| 465 | 454 | Trakt.TRENDING: lambda: TraktMovies().trakt_movies_trending(page), |
| 466 | 455 | Trakt.TOP10: lambda: TraktMovies().trakt_movies_top10_boxoffice(), |
| 467 | 456 | 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), | |
| 471 | 458 | Trakt.FAVORITED: lambda: TraktMovies().trakt_movies_most_favorited(page), |
| 472 | 459 | Trakt.RECOMENDATIONS: lambda: TraktMovies().trakt_recommendations("movies"), |
| 473 | 460 | Trakt.FAVORITES: lambda: TraktLists().trakt_favorites(mode), |
| … | … | @@ -500,9 +487,7 @@ class TraktClient: |
| 500 | 487 | query_handlers = { |
| 501 | 488 | Trakt.TRENDING: lambda: TraktTV().trakt_tv_trending(page), |
| 502 | 489 | 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), | |
| 506 | 491 | Trakt.FAVORITED: lambda: TraktTV().trakt_tv_most_favorited(page), |
| 507 | 492 | Trakt.RECOMENDATIONS: lambda: TraktTV().trakt_recommendations("shows"), |
| 508 | 493 | Trakt.FAVORITES: lambda: TraktLists().trakt_favorites(mode), |
| … | … | @@ -563,9 +548,7 @@ class TraktClient: |
| 563 | 548 | @staticmethod |
| 564 | 549 | def trakt_add_to_watchlist(params): |
| 565 | 550 | 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) | |
| 569 | 552 | try: |
| 570 | 553 | TraktAPI().lists.add_to_watchlist(media_type, ids) |
| 571 | 554 | notification(translation(90434), time=3000) |
| … | … | @@ -577,9 +560,7 @@ class TraktClient: |
| 577 | 560 | @staticmethod |
| 578 | 561 | def trakt_remove_from_watchlist(params): |
| 579 | 562 | 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) | |
| 583 | 564 | try: |
| 584 | 565 | TraktAPI().lists.remove_from_watchlist(media_type, ids) |
| 585 | 566 | notification(translation(90436), time=3000) |
| … | … | @@ -616,9 +597,7 @@ class TraktClient: |
| 616 | 597 | @staticmethod |
| 617 | 598 | def trakt_add_to_collection(params): |
| 618 | 599 | 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) | |
| 622 | 601 | try: |
| 623 | 602 | payload = None |
| 624 | 603 | if media_type not in ("movie", "movies"): |
| … | … | @@ -627,11 +606,17 @@ class TraktClient: |
| 627 | 606 | notification(translation(90465), time=3500) |
| 628 | 607 | return |
| 629 | 608 | 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"): | |
| 631 | 613 | payload = TraktClient._build_show_collection_season_payload(ids) |
| 632 | 614 | if payload is not None: |
| 633 | 615 | 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"): | |
| 635 | 620 | payload = TraktClient._build_show_collection_episode_payload(ids) |
| 636 | 621 | if payload is not None: |
| 637 | 622 | result = TraktAPI().lists.add_to_collection(media_type, ids, payload=payload) |
| … | … | @@ -652,9 +637,7 @@ class TraktClient: |
| 652 | 637 | @staticmethod |
| 653 | 638 | def trakt_remove_from_collection(params): |
| 654 | 639 | 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) | |
| 658 | 641 | try: |
| 659 | 642 | payload = None |
| 660 | 643 | if media_type not in ("movie", "movies"): |
| … | … | @@ -665,14 +648,24 @@ class TraktClient: |
| 665 | 648 | notification(translation(90465), time=3500) |
| 666 | 649 | return |
| 667 | 650 | 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"): | |
| 669 | 655 | payload = TraktClient._build_show_collection_season_payload(ids) |
| 670 | 656 | 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"): | |
| 673 | 664 | payload = TraktClient._build_show_collection_episode_payload(ids) |
| 674 | 665 | 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 | ) | |
| 676 | 669 | removed_count = TraktClient._trakt_sync_result_count(result, "deleted", media_type) |
| 677 | 670 | not_found_count = TraktClient._trakt_sync_result_count(result, "not_found", media_type) |
| 678 | 671 | if removed_count > 0: |
| … | … | @@ -689,9 +682,7 @@ class TraktClient: |
| 689 | 682 | @staticmethod |
| 690 | 683 | def trakt_add_to_favorites(params): |
| 691 | 684 | 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) | |
| 695 | 686 | try: |
| 696 | 687 | TraktAPI().lists.add_to_favorites(media_type, ids) |
| 697 | 688 | notification(translation(90445), time=3000) |
| … | … | @@ -703,9 +694,7 @@ class TraktClient: |
| 703 | 694 | @staticmethod |
| 704 | 695 | def trakt_remove_from_favorites(params): |
| 705 | 696 | 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) | |
| 709 | 698 | try: |
| 710 | 699 | TraktAPI().lists.remove_from_favorites(media_type, ids) |
| 711 | 700 | notification(translation(90446), time=3000) |
| … | … | @@ -793,9 +782,7 @@ class TraktClient: |
| 793 | 782 | if not selected_list: |
| 794 | 783 | return |
| 795 | 784 | 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) | |
| 799 | 786 | notification(translation(90457), time=3000) |
| 800 | 787 | except Exception as e: |
| 801 | 788 | kodilog(f"Error adding item to Trakt list: {e}") |
| … | … | @@ -809,9 +796,7 @@ class TraktClient: |
| 809 | 796 | if not selected_list: |
| 810 | 797 | return |
| 811 | 798 | 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) | |
| 815 | 800 | notification(translation(90536), time=3000) |
| 816 | 801 | except Exception as e: |
| 817 | 802 | kodilog(f"Error removing item from Trakt list: {e}") |
| … | … | @@ -839,16 +824,12 @@ class TraktClient: |
| 839 | 824 | kodilog(f"Error fetching Trakt user profile: {e}") |
| 840 | 825 | |
| 841 | 826 | 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) | |
| 845 | 828 | |
| 846 | 829 | return TraktAPI().calendar.trakt_my_calendar(start_date=start_date, days=days) |
| 847 | 830 | |
| 848 | 831 | @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=""): | |
| 852 | 833 | if query == Trakt.ACCOUNT_INFO: |
| 853 | 834 | TraktPresentation.show_account_info(results) |
| 854 | 835 | end_of_directory(cache=False) |
| … | … | @@ -1011,9 +992,7 @@ class TraktClient: |
| 1011 | 992 | |
| 1012 | 993 | @staticmethod |
| 1013 | 994 | 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) | |
| 1017 | 996 | if not data: |
| 1018 | 997 | notification("No results found", time=3000) |
| 1019 | 998 | end_of_directory(cache=not bool(with_auth)) |
| … | … | @@ -1088,15 +1067,11 @@ class TraktPresentation: |
| 1088 | 1067 | lines = [str(translation(30916)), ""] |
| 1089 | 1068 | lines.append(f"Username: {user.get('username', '')}") |
| 1090 | 1069 | 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', ''))}") | |
| 1094 | 1071 | lines.append( |
| 1095 | 1072 | f"Private: {TraktPresentation._format_bool_label(account.get('private', False))}" |
| 1096 | 1073 | ) |
| 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))}") | |
| 1100 | 1075 | lines.append(f"Timezone: {account.get('timezone', '')}") |
| 1101 | 1076 | lines.append(f"Locale: {account.get('locale', '')}") |
| 1102 | 1077 | lines.append("") |
| … | … | @@ -1112,9 +1087,7 @@ class TraktPresentation: |
| 1112 | 1087 | lines.append("Connections") |
| 1113 | 1088 | for key in ("facebook", "google", "twitter", "mastodon"): |
| 1114 | 1089 | 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))}") | |
| 1118 | 1091 | return "\n".join(lines) |
| 1119 | 1092 | |
| 1120 | 1093 | @staticmethod |
| … | … | @@ -1257,14 +1230,8 @@ class TraktPresentation: |
| 1257 | 1230 | description = res["list"]["description"] |
| 1258 | 1231 | user_data = res["list"].get("user", {}) |
| 1259 | 1232 | 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")) | |
| 1263 | 1234 | |
| 1264 | info_labels = { | |
| 1265 | "title": list_title, | |
| 1266 | "plot": description, | |
| 1267 | } | |
| 1268 | 1235 | |
| 1269 | 1236 | url = build_url( |
| 1270 | 1237 | "trakt_list_content", |
| … | … | @@ -1299,7 +1266,16 @@ class TraktPresentation: |
| 1299 | 1266 | ) |
| 1300 | 1267 | |
| 1301 | 1268 | @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 | ): | |
| 1303 | 1279 | list_title = res.get("name", "Untitled List") |
| 1304 | 1280 | description = res.get("description", "") |
| 1305 | 1281 | item_count = res.get("item_count") |
| … | … | @@ -1505,12 +1481,10 @@ class TraktPresentation: |
| 1505 | 1481 | |
| 1506 | 1482 | first_aired = res.get("first_aired") or ep_data.get("first_aired") |
| 1507 | 1483 | |
| 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", {})) | |
| 1511 | 1485 | 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"] | |
| 1514 | 1488 | |
| 1515 | 1489 | if not tmdb_id: |
| 1516 | 1490 | return |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | from lib.utils.kodi.utils import ADDON_HANDLE | |
| 2 | ||
| 3 | 1 | from xbmcplugin import addDirectoryItem |
| 4 | 2 | |
| 3 | from lib.utils.kodi.utils import ADDON_HANDLE | |
| 4 | ||
| 5 | 5 | |
| 6 | 6 | def add_kodi_dir_item( |
| 7 | 7 | list_item, |
| … | … | @@ -1,4 +1,12 @@ |
| 1 | 1 | import requests |
| 2 | from xbmcgui import Dialog | |
| 3 | from xbmcplugin import ( | |
| 4 | SORT_METHOD_FILE, | |
| 5 | SORT_METHOD_LABEL_IGNORE_THE, | |
| 6 | addDirectoryItem, | |
| 7 | addSortMethod, | |
| 8 | ) | |
| 9 | ||
| 2 | 10 | from lib.api.webdav.webdav import WebDAVClient |
| 3 | 11 | from lib.utils.kodi.utils import ( |
| 4 | 12 | ADDON_HANDLE, |
| … | … | @@ -12,8 +20,6 @@ from lib.utils.kodi.utils import ( |
| 12 | 20 | show_picture, |
| 13 | 21 | translation, |
| 14 | 22 | ) |
| 15 | from xbmcgui import Dialog | |
| 16 | from xbmcplugin import addDirectoryItem, addSortMethod, SORT_METHOD_LABEL_IGNORE_THE, SORT_METHOD_FILE | |
| 17 | 23 | |
| 18 | 24 | |
| 19 | 25 | def list_webdav(params): |
| … | … | @@ -36,9 +42,7 @@ def list_webdav(params): |
| 36 | 42 | |
| 37 | 43 | # --- FOLDERS --- |
| 38 | 44 | 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"] | |
| 42 | 46 | url = build_url("list_webdav", path=new_relative_path) |
| 43 | 47 | is_folder = True |
| 44 | 48 | |
| … | … | @@ -80,9 +84,7 @@ def list_webdav(params): |
| 80 | 84 | url = item["url"] |
| 81 | 85 | is_folder = False |
| 82 | 86 | |
| 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) | |
| 86 | 88 | |
| 87 | 89 | addSortMethod(ADDON_HANDLE, SORT_METHOD_LABEL_IGNORE_THE) |
| 88 | 90 | addSortMethod(ADDON_HANDLE, SORT_METHOD_FILE) |
| … | … | @@ -8,7 +8,6 @@ import requests |
| 8 | 8 | from lib.clients.tmdb.trailers import resolve_tmdb_trailer |
| 9 | 9 | from lib.utils.kodi.utils import kodilog |
| 10 | 10 | |
| 11 | ||
| 12 | 11 | WATCH_URL = "https://www.youtube.com/watch" |
| 13 | 12 | PLAYER_URL = "https://www.youtube.com/youtubei/v1/player" |
| 14 | 13 | CACHE_TTL_SECONDS = 300 |
| … | … | @@ -188,9 +187,8 @@ def _choose_better_video_format(best_fmt: Dict[str, object], candidate_fmt: Dict |
| 188 | 187 | |
| 189 | 188 | best_container_rank = _container_rank(best_fmt.get("mimeType")) |
| 190 | 189 | 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 | |
| 194 | 192 | ): |
| 195 | 193 | return candidate_fmt if candidate_container_rank < best_container_rank else best_fmt |
| 196 | 194 | |
| … | … | @@ -203,7 +201,9 @@ def _choose_better_video_format(best_fmt: Dict[str, object], candidate_fmt: Dict |
| 203 | 201 | return candidate_fmt if _video_rank(candidate_fmt) > _video_rank(best_fmt) else best_fmt |
| 204 | 202 | |
| 205 | 203 | |
| 206 | def _select_best_video_format(formats: List[Dict[str, object]]) -> Optional[Dict[str, object]]: | |
| 204 | def _select_best_video_format( | |
| 205 | formats: List[Dict[str, object]], | |
| 206 | ) -> Optional[Dict[str, object]]: | |
| 207 | 207 | if not formats: |
| 208 | 208 | return None |
| 209 | 209 | |
| … | … | @@ -300,9 +300,7 @@ def resolve_trailer_playback( |
| 300 | 300 | |
| 301 | 301 | watch_config = parse_watch_config(watch_response.text) |
| 302 | 302 | 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}") | |
| 306 | 304 | return None |
| 307 | 305 | |
| 308 | 306 | for client in YOUTUBE_CLIENTS: |
| … | … | @@ -1,11 +1,13 @@ |
| 1 | 1 | import json |
| 2 | 2 | import logging |
| 3 | 3 | from types import SimpleNamespace |
| 4 | from typing import Any, Callable, Dict, List, Optional | |
| 5 | ||
| 4 | 6 | from requests import ConnectTimeout, ReadTimeout |
| 5 | 7 | from requests.exceptions import RequestException |
| 8 | ||
| 6 | 9 | from lib.clients.base import BaseClient, TorrentStream |
| 7 | 10 | from lib.utils.general.utils import USER_AGENT_HEADER, info_hash_to_magnet |
| 8 | from typing import List, Optional, Dict, Any, Callable | |
| 9 | 11 | from lib.utils.kodi.utils import notification |
| 10 | 12 | |
| 11 | 13 | |
| … | … | @@ -78,9 +80,7 @@ class Zilean(BaseClient): |
| 78 | 80 | ) |
| 79 | 81 | if res.status_code != 200: |
| 80 | 82 | 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)) | |
| 84 | 84 | torrents = [] |
| 85 | 85 | for result in response: |
| 86 | 86 | torrents.append( |
| … | … | @@ -1,11 +1,12 @@ |
| 1 | 1 | import os |
| 2 | 2 | import threading |
| 3 | 3 | from sqlite3 import dbapi2 as db |
| 4 | from lib.utils.kodi.utils import notification, translation | |
| 5 | 4 | |
| 5 | import xbmc | |
| 6 | 6 | import xbmcaddon |
| 7 | 7 | from xbmcvfs import translatePath |
| 8 | 8 | |
| 9 | from lib.utils.kodi.utils import notification, translation | |
| 9 | 10 | |
| 10 | 11 | try: |
| 11 | 12 | if xbmc.getCondVisibility("System.HasAddon(script.otaku.mappings)"): |
| … | … | @@ -18,7 +19,7 @@ try: |
| 18 | 19 | mappingDB = os.path.join(mappingPath, "resources", "data", "anime_mappings.db") |
| 19 | 20 | else: |
| 20 | 21 | mappingDB = None |
| 21 | except: | |
| 22 | except Exception: | |
| 22 | 23 | OTAKU_ADDON = None |
| 23 | 24 | mappingDB = None |
| 24 | 25 | |
| … | … | @@ -34,7 +35,7 @@ def get_all_ids(anilist_id): |
| 34 | 35 | conn.row_factory = _dict_factory |
| 35 | 36 | conn.execute("PRAGMA FOREIGN_KEYS = 1") |
| 36 | 37 | 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})") | |
| 38 | 39 | mapping = cursor.fetchone() |
| 39 | 40 | cursor.close() |
| 40 | 41 | try_release_lock(mappingDB_lock) |
| … | … | @@ -1,30 +1,27 @@ |
| 1 | 1 | import os |
| 2 | 2 | import pickle |
| 3 | import re | |
| 3 | 4 | import sqlite3 |
| 4 | 5 | import sys |
| 5 | 6 | import threading |
| 6 | 7 | import traceback |
| 7 | import re | |
| 8 | ||
| 9 | from base64 import b64encode, b64decode | |
| 8 | from base64 import b64decode, b64encode | |
| 10 | 9 | from datetime import datetime, timedelta |
| 11 | 10 | from hashlib import sha256 |
| 12 | 11 | |
| 13 | from lib.jacktook.utils import kodilog | |
| 14 | ||
| 15 | import xbmcaddon | |
| 16 | 12 | import xbmc |
| 13 | import xbmcaddon | |
| 17 | 14 | import xbmcgui |
| 18 | 15 | |
| 16 | from lib.jacktook.utils import kodilog | |
| 17 | ||
| 19 | 18 | PY3 = sys.version_info.major >= 3 |
| 20 | 19 | if PY3: |
| 21 | 20 | from xbmcvfs import translatePath |
| 22 | 21 | else: |
| 23 | 22 | from xbmc import translatePath |
| 24 | 23 | |
| 25 | ADDON_DATA = translatePath( | |
| 26 | xbmcaddon.Addon("plugin.video.jacktook").getAddonInfo("profile") | |
| 27 | ) | |
| 24 | ADDON_DATA = translatePath(xbmcaddon.Addon("plugin.video.jacktook").getAddonInfo("profile")) | |
| 28 | 25 | ADDON_ID = xbmcaddon.Addon().getAddonInfo("id") |
| 29 | 26 | |
| 30 | 27 | if not PY3: |
| … | … | @@ -49,7 +46,7 @@ def pickle_hash(obj): |
| 49 | 46 | return h.hexdigest() |
| 50 | 47 | |
| 51 | 48 | |
| 52 | class _BaseCache(object): | |
| 49 | class _BaseCache: | |
| 53 | 50 | __instance = None |
| 54 | 51 | |
| 55 | 52 | _load_func = staticmethod(pickle.loads) |
| … | … | @@ -148,7 +145,7 @@ class MemoryCache(_BaseCache): |
| 148 | 145 | self._window.setProperty(self._database + key, b64encode(blob).decode()) |
| 149 | 146 | except Exception as e: |
| 150 | 147 | kodilog( |
| 151 | f"[MemoryCache] Error storing key {key!r}: {str(e)}", | |
| 148 | f"[MemoryCache] Error storing key {key!r}: {e!s}", | |
| 152 | 149 | level=xbmc.LOGERROR, |
| 153 | 150 | ) |
| 154 | 151 | kodilog(traceback.format_exc(), level=xbmc.LOGERROR) |
| … | … | @@ -185,17 +182,15 @@ class MemoryCache(_BaseCache): |
| 185 | 182 | data = self._window.getProperty(self._database + "_KEY_INDEX_") |
| 186 | 183 | if data: |
| 187 | 184 | return self._load_func(b64decode(data)) |
| 188 | except: | |
| 185 | except Exception: | |
| 189 | 186 | pass |
| 190 | 187 | return set() |
| 191 | 188 | |
| 192 | 189 | def _save_key_index(self, index): |
| 193 | 190 | try: |
| 194 | 191 | 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: | |
| 199 | 194 | pass |
| 200 | 195 | |
| 201 | 196 | def _add_key_to_index(self, key): |
| … | … | @@ -291,7 +286,7 @@ class SQLiteCache(_BaseCache): |
| 291 | 286 | ")" |
| 292 | 287 | ) |
| 293 | 288 | for k, v in SQLITE_SETTINGS.items(): |
| 294 | self._conn.execute("PRAGMA {}={}".format(k, v)) | |
| 289 | self._conn.execute(f"PRAGMA {k}={v}") | |
| 295 | 290 | self._cleanup_interval = cleanup_interval |
| 296 | 291 | self._last_cleanup = datetime.utcnow() |
| 297 | 292 | self.clean_up() |
| … | … | @@ -368,12 +363,12 @@ class SQLiteCache(_BaseCache): |
| 368 | 363 | ), |
| 369 | 364 | ) |
| 370 | 365 | kodilog( |
| 371 | "Set cache for key '{}' with expiry {}".format(key, expires), | |
| 366 | f"Set cache for key '{key}' with expiry {expires}", | |
| 372 | 367 | level=xbmc.LOGDEBUG, |
| 373 | 368 | ) |
| 374 | 369 | except Exception as e: |
| 375 | 370 | kodilog( |
| 376 | "Failed to set cache for key '{}': {}".format(key, str(e)), | |
| 371 | f"Failed to set cache for key '{key}': {e!s}", | |
| 377 | 372 | level=xbmc.LOGERROR, |
| 378 | 373 | ) |
| 379 | 374 | # fallback to raw in‑memory store |
| … | … | @@ -396,7 +391,7 @@ class SQLiteCache(_BaseCache): |
| 396 | 391 | self._conn.execute("DELETE FROM `cached` WHERE key LIKE ?", (pattern,)) |
| 397 | 392 | |
| 398 | 393 | def _set_version(self, version): |
| 399 | self._conn.execute("PRAGMA user_version={}".format(version)) | |
| 394 | self._conn.execute(f"PRAGMA user_version={version}") | |
| 400 | 395 | |
| 401 | 396 | @property |
| 402 | 397 | def version(self): |
| … | … | @@ -1,5 +1,6 @@ |
| 1 | 1 | import os |
| 2 | 2 | import pickle |
| 3 | ||
| 3 | 4 | import xbmcvfs |
| 4 | 5 | |
| 5 | 6 | from lib.jacktook.utils import kodilog |
| … | … | @@ -15,9 +16,7 @@ class PickleDatabase: |
| 15 | 16 | "jt:lib": {}, |
| 16 | 17 | } |
| 17 | 18 | |
| 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)) | |
| 21 | 20 | self._database_path = os.path.join(data_dir, "database.pickle") |
| 22 | 21 | xbmcvfs.mkdirs(data_dir) |
| 23 | 22 |
| … | … | @@ -12,6 +12,7 @@ class TorrentStream: |
| 12 | 12 | subindexer: str = "" |
| 13 | 13 | addonKey: str = "" |
| 14 | 14 | addonName: str = "" |
| 15 | addonSourceName: str = "" | |
| 15 | 16 | addonInstanceLabel: str = "" |
| 16 | 17 | guid: str = "" |
| 17 | 18 | infoHash: str = "" |
| … | … | @@ -32,7 +33,7 @@ class TorrentStream: |
| 32 | 33 | # Quality and URLs |
| 33 | 34 | quality: str = "N/A" |
| 34 | 35 | url: str = "" |
| 35 | # Stremio behaviorHints.proxyHeaders.request — headers the source requires | |
| 36 | # Stremio behaviorHints.proxyHeaders.request -- headers the source requires | |
| 36 | 37 | # on playback (Referer/User-Agent for hotlink-protected CDNs). |
| 37 | 38 | proxyHeaders: Dict[str, str] = field(default_factory=dict) |
| 38 | 39 | |
| … | … | @@ -40,3 +41,7 @@ class TorrentStream: |
| 40 | 41 | isPack: bool = False |
| 41 | 42 | isCached: bool = False |
| 42 | 43 | addedToDebrid: bool = False |
| 44 | ||
| 45 | # Codec & HDR | |
| 46 | codec: str = "" | |
| 47 | hdr: str = "" |
| … | … | @@ -0,0 +1,108 @@ |
| 1 | import threading | |
| 2 | from dataclasses import dataclass | |
| 3 | from typing import Dict, List, Optional | |
| 4 | ||
| 5 | ||
| 6 | @dataclass | |
| 7 | class 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 | ||
| 22 | class 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() |
| … | … | @@ -1,312 +1,774 @@ |
| 1 | import contextlib | |
| 1 | 2 | import json |
| 2 | 3 | import os |
| 4 | import re | |
| 3 | 5 | import ssl |
| 4 | 6 | import threading |
| 5 | import re | |
| 6 | from urllib.request import Request, urlopen | |
| 7 | import time | |
| 8 | from typing import Optional | |
| 7 | 9 | from urllib.parse import parse_qsl, unquote, urlparse |
| 10 | from urllib.request import Request, urlopen | |
| 11 | ||
| 12 | import xbmc | |
| 13 | import xbmcgui | |
| 14 | import xbmcvfs | |
| 15 | from xbmcplugin import ( | |
| 16 | addDirectoryItems, | |
| 17 | endOfDirectory, | |
| 18 | setContent, | |
| 19 | ) | |
| 20 | ||
| 21 | from lib.db.cached import MemoryCache | |
| 22 | from lib.download_manager import DownloadManager | |
| 23 | from lib.gui.custom_progress import CustomProgressDialog | |
| 24 | from lib.nav.debrid import resolve_cloud_download_url | |
| 8 | 25 | from lib.utils.general.utils import set_pluging_category, supported_video_extensions |
| 9 | 26 | from lib.utils.kodi.utils import ( |
| 10 | 27 | ADDON_HANDLE, |
| 11 | apply_section_view, | |
| 28 | ADDON_PATH, | |
| 12 | 29 | action_url_run, |
| 30 | apply_section_view, | |
| 31 | build_url, | |
| 13 | 32 | bytes_to_human_readable, |
| 14 | 33 | get_setting, |
| 15 | kodilog, | |
| 16 | translatePath, | |
| 17 | ADDON_PATH, | |
| 18 | notification, | |
| 19 | open_file, | |
| 20 | translation, | |
| 21 | ) | |
| 22 | from lib.db.cached import MemoryCache | |
| 23 | from lib.gui.custom_progress import CustomProgressDialog | |
| 24 | ||
| 25 | from xbmcplugin import ( | |
| 26 | addDirectoryItems, | |
| 27 | endOfDirectory, | |
| 28 | setContent, | |
| 34 | kodilog, | |
| 35 | notification, | |
| 36 | open_file, | |
| 37 | translatePath, | |
| 38 | translation, | |
| 29 | 39 | ) |
| 30 | import xbmcgui | |
| 31 | import xbmcvfs | |
| 32 | import xbmc | |
| 33 | ||
| 34 | ||
| 35 | cancel_flag_cache = MemoryCache() | |
| 36 | ||
| 37 | ||
| 40 | ||
| 41 | cancel_flag_cache = MemoryCache() | |
| 42 | ||
| 43 | ||
| 38 | 44 | def handle_download_file(params): |
| 39 | 45 | 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 | ||
| 83 | def 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: | |
| 41 | 108 | notification("Invalid download destination.") |
| 42 | 109 | return |
| 43 | 110 | |
| 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 | ||
| 57 | def 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 | ||
| 66 | class 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 | ||
| 77 | class 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 | ||
| 92 | class 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 | ||
| 215 | def 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 | ||
| 224 | def 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 | ||
| 238 | def 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 | ||
| 131 | def 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 | ||
| 158 | def 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 | ||
| 166 | class 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 | ||
| 185 | class 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 | ||
| 208 | class 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 | ||
| 496 | def 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 | ||
| 505 | def 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 | ||
| 527 | def 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 | ||
| 560 | def 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 | ||
| 575 | def 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 | ||
| 597 | def _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 | ||
| 608 | def _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 | ||
| 629 | def 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) | |
| 241 | 636 | item_list = [] |
| 242 | 637 | |
| 243 | 638 | try: |
| 244 | 639 | setContent(ADDON_HANDLE, "files") |
| 245 | 640 | 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 | ||
| 297 | 754 | addDirectoryItems(ADDON_HANDLE, item_list) |
| 298 | 755 | endOfDirectory(ADDON_HANDLE) |
| 299 | 756 | apply_section_view("view.downloads", content_type="files") |
| 300 | 757 | except Exception as e: |
| 301 | notification(f"Error: {str(e)}", translation(90662)) | |
| 758 | notification(f"Error: {e!s}", translation(90662)) | |
| 302 | 759 | endOfDirectory(ADDON_HANDLE, succeeded=False) |
| 303 | ||
| 304 | ||
| 305 | def is_active_download(path): | |
| 306 | cancel_flag = cancel_flag_cache.get(path) | |
| 307 | return cancel_flag is not True | |
| 308 | ||
| 309 | ||
| 760 | ||
| 761 | ||
| 762 | def 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 | ||
| 310 | 772 | def normalize_file_name(file_name, url=""): |
| 311 | 773 | file_name = (file_name or "").strip() |
| 312 | 774 | valid_extensions = {ext.lower() for ext in supported_video_extensions()} |
| … | … | @@ -1,13 +1,13 @@ |
| 1 | 1 | import threading |
| 2 | ||
| 3 | import xbmcgui | |
| 4 | ||
| 5 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 2 | 6 | from lib.gui.base_window import BaseWindow |
| 3 | 7 | from lib.utils.kodi.utils import ( |
| 4 | ADDON_PATH, | |
| 5 | kodilog, | |
| 6 | notification, | |
| 7 | 8 | execute_builtin, |
| 9 | kodilog, | |
| 8 | 10 | ) |
| 9 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 10 | import xbmcgui | |
| 11 | 11 | |
| 12 | 12 | |
| 13 | 13 | class ActorInfoWindow(BaseWindow): |
| … | … | @@ -1,273 +1,263 @@ |
| 1 | import abc | |
| 2 | from copy import deepcopy | |
| 3 | import hashlib | |
| 4 | import json | |
| 5 | from typing import Any, Dict, Tuple | |
| 6 | from lib.domain.torrent import TorrentStream | |
| 7 | from lib.utils.debrid.debrid_utils import get_magnet_from_uri, _is_torrent_ready_in_debrid | |
| 8 | from lib.utils.player.utils import resolve_playback_url | |
| 9 | from lib.utils.general.utils import Indexer, IndexerType, truncate_text | |
| 10 | from lib.utils.kodi.logging import summarize_locator_for_log | |
| 11 | from lib.utils.kodi.utils import ADDON, kodilog, translation | |
| 12 | import xbmcgui | |
| 13 | ||
| 14 | ||
| 15 | ACTION_PREVIOUS_MENU = 10 | |
| 16 | ACTION_PLAYER_STOP = 13 | |
| 17 | ACTION_NAV_BACK = 92 | |
| 18 | ||
| 19 | ||
| 20 | class 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 | |
| 1 | import abc | |
| 2 | import hashlib | |
| 3 | import json | |
| 4 | from copy import deepcopy | |
| 5 | from typing import Any, Dict, Tuple | |
| 6 | ||
| 7 | import xbmcgui | |
| 8 | ||
| 9 | from lib.domain.torrent import TorrentStream | |
| 10 | from lib.utils.debrid.debrid_utils import ( | |
| 11 | _is_torrent_ready_in_debrid, | |
| 12 | get_magnet_from_uri, | |
| 13 | ) | |
| 14 | from lib.utils.general.utils import Indexer, IndexerType, truncate_text | |
| 15 | from lib.utils.kodi.logging import summarize_locator_for_log | |
| 16 | from lib.utils.kodi.utils import ADDON, kodilog, translation | |
| 17 | from lib.utils.player.utils import resolve_playback_url | |
| 18 | ||
| 19 | ACTION_PREVIOUS_MENU = 10 | |
| 20 | ACTION_PLAYER_STOP = 13 | |
| 21 | ACTION_NAV_BACK = 92 | |
| 22 | ||
| 23 | ||
| 24 | class 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 |
| … | … | @@ -1,20 +1,26 @@ |
| 1 | import contextlib | |
| 1 | 2 | import json |
| 3 | from concurrent.futures import ThreadPoolExecutor | |
| 2 | 4 | from typing import Dict, List |
| 3 | 5 | |
| 6 | import xbmcgui | |
| 7 | from xbmcgui import WindowXML, WindowXMLDialog | |
| 8 | ||
| 4 | 9 | from lib.domain.torrent import TorrentStream |
| 5 | 10 | from lib.gui.custom_progress import CustomProgressDialog |
| 6 | 11 | from lib.gui.play_next_window import PlayNext |
| 7 | 12 | from lib.gui.resolver_window import ResolverWindow |
| 8 | 13 | from lib.gui.resume_window import ResumeDialog |
| 9 | from lib.utils.kodi.utils import ADDON_PATH, PLAYLIST, kodilog, translation | |
| 14 | from lib.gui.search_status_window import SearchStatusWindow, SearchTaskManager | |
| 10 | 15 | from lib.gui.source_select import SourceSelect |
| 11 | from lib.gui.search_status_window import SearchTaskManager, SearchStatusWindow | |
| 12 | from lib.domain.torrent import TorrentStream | |
| 13 | from concurrent.futures import ThreadPoolExecutor | |
| 14 | ||
| 15 | from xbmcgui import WindowXMLDialog, WindowXML | |
| 16 | import xbmcgui | |
| 17 | import xbmc | |
| 16 | from lib.utils.kodi.utils import ( | |
| 17 | ADDON_PATH, | |
| 18 | PLAYLIST, | |
| 19 | clear_property, | |
| 20 | kodilog, | |
| 21 | set_property, | |
| 22 | translation, | |
| 23 | ) | |
| 18 | 24 | |
| 19 | 25 | |
| 20 | 26 | class CustomWindow(WindowXML): |
| … | … | @@ -57,6 +63,7 @@ class CustomDialog(WindowXMLDialog): |
| 57 | 63 | _url = 32502 |
| 58 | 64 | _close_button_id = 32503 |
| 59 | 65 | _qrcode = 32504 |
| 66 | _call_to_action = 32505 | |
| 60 | 67 | |
| 61 | 68 | def __init__(self, *args, **kwargs): |
| 62 | 69 | super().__init__(*args, **kwargs) |
| … | … | @@ -64,19 +71,19 @@ class CustomDialog(WindowXMLDialog): |
| 64 | 71 | self.text = kwargs.get("text") |
| 65 | 72 | self.url = kwargs.get("url") |
| 66 | 73 | self.qrcode_path = kwargs.get("qrcode") |
| 74 | self.call_to_action = kwargs.get("call_to_action") | |
| 67 | 75 | |
| 68 | 76 | def onInit(self): |
| 69 | 77 | self.getControl(self._heading).setLabel(self.heading) |
| 70 | 78 | self.getControl(self._text).setLabel(self.text) |
| 71 | 79 | self.getControl(self._url).setLabel(self.url) |
| 80 | self.getControl(self._call_to_action).setLabel(self.call_to_action or "") | |
| 72 | 81 | if self.qrcode_path: |
| 73 | 82 | self.getControl(self._qrcode).setImage(self.qrcode_path) |
| 74 | 83 | self.getControl(self._qrcode).setVisible(True) |
| 75 | 84 | else: |
| 76 | try: | |
| 85 | with contextlib.suppress(BaseException): | |
| 77 | 86 | self.getControl(self._qrcode).setVisible(False) |
| 78 | except: | |
| 79 | pass | |
| 80 | 87 | |
| 81 | 88 | def onClick(self, controlId): |
| 82 | 89 | if controlId == self._close_button_id: |
| … | … | @@ -111,9 +118,7 @@ _mock_information = { |
| 111 | 118 | } |
| 112 | 119 | |
| 113 | 120 | |
| 114 | def source_select( | |
| 115 | item_info: Dict[str, str], xml_file: str, sources: List[TorrentStream] | |
| 116 | ) -> bool: | |
| 121 | def source_select(item_info: Dict[str, str], xml_file: str, sources: List[TorrentStream]) -> bool: | |
| 117 | 122 | window = SourceSelect( |
| 118 | 123 | xml_file, |
| 119 | 124 | ADDON_PATH, |
| … | … | @@ -134,81 +139,35 @@ def run_next_dialog(params): |
| 134 | 139 | kodilog(f"Error accessing playlist in run_next_dialog: {e}") |
| 135 | 140 | return |
| 136 | 141 | |
| 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") | |
| 206 | 155 | |
| 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 | |
| 209 | 166 | |
| 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") | |
| 212 | 171 | |
| 213 | 172 | |
| 214 | 173 | def run_resume_dialog(params): |
| … | … | @@ -327,8 +286,6 @@ def search_status_mock(): |
| 327 | 286 | manager.submit_task("Stremio", "stremio", dummy_task, 8, False, 20) |
| 328 | 287 | manager.submit_task("Prowlarr", "prowlarr", dummy_task, 4, True, 0) |
| 329 | 288 | |
| 330 | window = SearchStatusWindow( | |
| 331 | "search_status.xml", ADDON_PATH, task_manager=manager | |
| 332 | ) | |
| 289 | window = SearchStatusWindow("search_status.xml", ADDON_PATH, task_manager=manager) | |
| 333 | 290 | window.doModal() |
| 334 | 291 | del window |
| … | … | @@ -1,6 +1,7 @@ |
| 1 | from lib.utils.kodi.utils import kodilog, translation | |
| 2 | 1 | import xbmcgui |
| 3 | 2 | |
| 3 | from lib.utils.kodi.utils import kodilog, translation | |
| 4 | ||
| 4 | 5 | |
| 5 | 6 | class CustomProgressDialog(xbmcgui.WindowXMLDialog): |
| 6 | 7 | def __init__(self, xml_file: str, location: str): |
| … | … | @@ -21,13 +22,30 @@ class CustomProgressDialog(xbmcgui.WindowXMLDialog): |
| 21 | 22 | self.getControl(12001).setLabel(self.title) |
| 22 | 23 | self.getControl(12002).setLabel(self.message) |
| 23 | 24 | self.getControl(12004).setPercent(self.progress) |
| 25 | self.getControl(12006).setLabel(f"{self.progress}%") | |
| 24 | 26 | self.setFocusId(12003) |
| 25 | 27 | |
| 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 | ): | |
| 27 | 37 | 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}") | |
| 31 | 49 | |
| 32 | 50 | def onClick(self, controlId): |
| 33 | 51 | if controlId == 12003: # Cancel button |
| … | … | @@ -0,0 +1,406 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | import threading | |
| 4 | import time | |
| 5 | ||
| 6 | import xbmc | |
| 7 | import xbmcgui | |
| 8 | import xbmcvfs | |
| 9 | ||
| 10 | from lib.download_manager import DownloadManager | |
| 11 | from lib.downloader import ( | |
| 12 | Downloader, | |
| 13 | cancel_flag_cache, | |
| 14 | get_download_metadata, | |
| 15 | handle_pause_download, | |
| 16 | ) | |
| 17 | from lib.gui.base_window import BaseWindow | |
| 18 | from lib.utils.kodi.settings import get_setting as _get_setting | |
| 19 | from lib.utils.kodi.utils import ( | |
| 20 | bytes_to_human_readable, | |
| 21 | execute_builtin, | |
| 22 | kodilog, | |
| 23 | translation, | |
| 24 | ) | |
| 25 | from lib.utils.kodi.utils import open_file as _open_file | |
| 26 | from lib.utils.kodi.utils import translatePath as _translatePath | |
| 27 | ||
| 28 | ||
| 29 | class 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 |
| … | … | @@ -0,0 +1,31 @@ |
| 1 | import json | |
| 2 | ||
| 3 | import xbmcgui | |
| 4 | ||
| 5 | from lib.clients.tmdb.utils.excluded_languages_data import EXCLUDABLE_LANGUAGES | |
| 6 | from lib.utils.kodi.utils import get_setting, set_setting, translation | |
| 7 | ||
| 8 | SETTING_KEY = "excluded_languages" | |
| 9 | ||
| 10 | ||
| 11 | def 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)) |
| … | … | @@ -0,0 +1,144 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | import sys | |
| 4 | ||
| 5 | import xbmc | |
| 6 | import xbmcgui | |
| 7 | ||
| 8 | from lib.utils.kodi.settings import get_setting | |
| 9 | from lib.utils.kodi.utils import kodilog, set_setting | |
| 10 | ||
| 11 | ||
| 12 | def 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 | ||
| 67 | def 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 | ||
| 93 | def _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 | ||
| 119 | def _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 |
| … | … | @@ -1,22 +1,23 @@ |
| 1 | import contextlib | |
| 1 | 2 | import json |
| 2 | import os | |
| 3 | 3 | import threading |
| 4 | ||
| 5 | import xbmcgui | |
| 6 | ||
| 7 | from lib.api.fanart.fanart import get_fanart | |
| 8 | from lib.api.imdb.imdb_scraper import get_imdb_extras | |
| 9 | from lib.clients.tmdb.utils.utils import LANGUAGES, tmdb_get | |
| 4 | 10 | from lib.gui.base_window import BaseWindow |
| 11 | from lib.utils.general.utils import truncate_text | |
| 5 | 12 | from lib.utils.kodi.utils import ( |
| 6 | 13 | ADDON_PATH, |
| 7 | kodilog, | |
| 14 | build_url, | |
| 8 | 15 | execute_builtin, |
| 9 | notification, | |
| 10 | 16 | get_setting, |
| 11 | build_url, | |
| 17 | kodilog, | |
| 18 | notification, | |
| 12 | 19 | translation, |
| 13 | 20 | ) |
| 14 | from lib.clients.tmdb.utils.utils import tmdb_get, LANGUAGES | |
| 15 | from lib.api.fanart.fanart import get_fanart | |
| 16 | from lib.api.imdb.imdb_scraper import get_imdb_trivia, get_imdb_goofs | |
| 17 | from lib.utils.general.utils import truncate_text | |
| 18 | import xbmcgui | |
| 19 | import xbmc | |
| 20 | 21 | |
| 21 | 22 | |
| 22 | 23 | class ExtrasWindow(BaseWindow): |
| … | … | @@ -152,10 +153,8 @@ class ExtrasWindow(BaseWindow): |
| 152 | 153 | # ── Fanart.tv for clearlogo and better backgrounds ── |
| 153 | 154 | language_index = get_setting("language", 18) |
| 154 | 155 | lang = "en" |
| 155 | try: | |
| 156 | with contextlib.suppress(Exception): | |
| 156 | 157 | lang = LANGUAGES[int(language_index)].split("-")[0] |
| 157 | except Exception: | |
| 158 | pass | |
| 159 | 158 | |
| 160 | 159 | media_type_fa = "movies" if self.media_type == "movie" else "tv" |
| 161 | 160 | fa_id = str(self.tmdb_id) |
| … | … | @@ -193,11 +192,7 @@ class ExtrasWindow(BaseWindow): |
| 193 | 192 | if genres: |
| 194 | 193 | genre_names = [] |
| 195 | 194 | 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", "") | |
| 201 | 196 | if name: |
| 202 | 197 | genre_names.append(name) |
| 203 | 198 | if genre_names: |
| … | … | @@ -213,10 +208,8 @@ class ExtrasWindow(BaseWindow): |
| 213 | 208 | vote_average = getattr(data, "vote_average", None) |
| 214 | 209 | if vote_average and float(vote_average) > 0: |
| 215 | 210 | self.setProperty("tmdb_rating", "true") |
| 216 | try: | |
| 211 | with contextlib.suppress(Exception): | |
| 217 | 212 | self.getControl(4005).setLabel(f"{float(vote_average):.1f}") |
| 218 | except Exception: | |
| 219 | pass | |
| 220 | 213 | |
| 221 | 214 | # Year and runtime — populate Line 2 (control 2001) |
| 222 | 215 | line2_parts = [] |
| … | … | @@ -246,18 +239,14 @@ class ExtrasWindow(BaseWindow): |
| 246 | 239 | line2_parts.append(str(status)) |
| 247 | 240 | |
| 248 | 241 | if line2_parts: |
| 249 | try: | |
| 242 | with contextlib.suppress(Exception): | |
| 250 | 243 | self.getControl(2001).setLabel(" • ".join(line2_parts)) |
| 251 | except Exception: | |
| 252 | pass | |
| 253 | 244 | |
| 254 | 245 | # Vote count / Tagline — populate Line 3 (control 3001) |
| 255 | 246 | tagline = getattr(data, "tagline", "") |
| 256 | 247 | if tagline: |
| 257 | try: | |
| 248 | with contextlib.suppress(Exception): | |
| 258 | 249 | self.getControl(3001).setLabel(f"[I]{tagline}[/I]") |
| 259 | except Exception: | |
| 260 | pass | |
| 261 | 250 | |
| 262 | 251 | except Exception as e: |
| 263 | 252 | kodilog(f"Error populating metadata: {e}") |
| … | … | @@ -306,15 +295,9 @@ class ExtrasWindow(BaseWindow): |
| 306 | 295 | if not self.tmdb_id: |
| 307 | 296 | return |
| 308 | 297 | 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" | |
| 314 | 299 | 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") | |
| 318 | 301 | except Exception as e: |
| 319 | 302 | kodilog(f"Error fetching recommended: {e}") |
| 320 | 303 | |
| … | … | @@ -326,9 +309,7 @@ class ExtrasWindow(BaseWindow): |
| 326 | 309 | try: |
| 327 | 310 | path = "movie_similar" if self.media_type == "movie" else "tv_similar" |
| 328 | 311 | 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") | |
| 332 | 313 | except Exception as e: |
| 333 | 314 | kodilog(f"Error fetching similar: {e}") |
| 334 | 315 | |
| … | … | @@ -337,11 +318,7 @@ class ExtrasWindow(BaseWindow): |
| 337 | 318 | def fetch_ai_similar(self): |
| 338 | 319 | if not self.tmdb_id: |
| 339 | 320 | 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" | |
| 345 | 322 | data = tmdb_get(path, {"id": self.tmdb_id, "page": 2}) |
| 346 | 323 | self.populate_media_list(self.ai_similar_list_id, data, "ai_similar.number") |
| 347 | 324 | |
| … | … | @@ -682,37 +659,58 @@ class ExtrasWindow(BaseWindow): |
| 682 | 659 | self._fetch_trakt_comments() |
| 683 | 660 | return |
| 684 | 661 | |
| 685 | # 1. IMDb Trivia | |
| 662 | # Fetch all IMDb extras in a single GraphQL call | |
| 686 | 663 | 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) | |
| 691 | 665 | |
| 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}") | |
| 700 | 675 | |
| 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}") | |
| 704 | 682 | |
| 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}") | |
| 711 | 689 | |
| 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}") | |
| 714 | 709 | |
| 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) | |
| 716 | 714 | self._fetch_trakt_comments() |
| 717 | 715 | |
| 718 | 716 | def _fetch_tmdb_reviews(self): |
| … | … | @@ -730,14 +728,10 @@ class ExtrasWindow(BaseWindow): |
| 730 | 728 | if results: |
| 731 | 729 | for rev in results: |
| 732 | 730 | 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") | |
| 736 | 732 | if content: |
| 737 | 733 | 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") | |
| 741 | 735 | except Exception as e: |
| 742 | 736 | kodilog(f"Error fetching TMDB reviews: {e}") |
| 743 | 737 | |
| … | … | @@ -758,14 +752,10 @@ class ExtrasWindow(BaseWindow): |
| 758 | 752 | user = comment.get("user", {}).get("username", "Unknown") |
| 759 | 753 | if text: |
| 760 | 754 | 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") | |
| 764 | 756 | except Exception as e: |
| 765 | 757 | 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") | |
| 769 | 759 | |
| 770 | 760 | def _populate_text_panel(self, list_id, string_list, count_property): |
| 771 | 761 | try: |
| … | … | @@ -807,9 +797,7 @@ class ExtrasWindow(BaseWindow): |
| 807 | 797 | try: |
| 808 | 798 | from lib.api.trakt.trakt import TraktAPI |
| 809 | 799 | |
| 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}) | |
| 813 | 801 | notification(translation(90409)) |
| 814 | 802 | except Exception as e: |
| 815 | 803 | kodilog(f"Error modifying trakt watchlist: {e}") |
| … | … | @@ -820,13 +808,9 @@ class ExtrasWindow(BaseWindow): |
| 820 | 808 | from lib.api.trakt.trakt import TraktAPI |
| 821 | 809 | |
| 822 | 810 | 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}) | |
| 826 | 812 | 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}) | |
| 830 | 814 | notification(translation(90411)) |
| 831 | 815 | except Exception as e: |
| 832 | 816 | kodilog(f"Error marking watched on trakt: {e}") |
| … | … | @@ -1,6 +1,8 @@ |
| 1 | 1 | import xbmcgui |
| 2 | ||
| 2 | 3 | from lib.gui.base_window import BaseWindow |
| 3 | 4 | |
| 5 | ||
| 4 | 6 | class FilterWindow(BaseWindow): |
| 5 | 7 | def __init__(self, xml_file, location, filter): |
| 6 | 8 | super().__init__(xml_file, location, None) |
| … | … | @@ -10,14 +12,13 @@ class FilterWindow(BaseWindow): |
| 10 | 12 | def onInit(self): |
| 11 | 13 | self.list_control = self.getControl(2000) |
| 12 | 14 | self.list_control.reset() |
| 13 | ||
| 15 | ||
| 14 | 16 | for q in self.filter: |
| 15 | 17 | item = xbmcgui.ListItem(label=q) |
| 16 | 18 | self.list_control.addItem(item) |
| 17 | 19 | |
| 18 | 20 | self.set_default_focus(self.list_control, 2000, control_list_reset=True) |
| 19 | 21 | |
| 20 | ||
| 21 | 22 | def handle_action(self, action_id, control_id=None): |
| 22 | 23 | if action_id == 7: # Select |
| 23 | 24 | pos = self.list_control.getSelectedPosition() |
| … | … | @@ -25,4 +26,4 @@ class FilterWindow(BaseWindow): |
| 25 | 26 | self.selected_filter = self.filter[pos] |
| 26 | 27 | self.close() |
| 27 | 28 | elif action_id in (2, 9, 10, 13, 92): # Back/Escape |
| 28 | self.close() | |
| 29 | self.close() |
| … | … | @@ -1,4 +1,5 @@ |
| 1 | 1 | import xbmcgui |
| 2 | ||
| 2 | 3 | from lib.gui.base_window import BaseWindow |
| 3 | 4 | from lib.utils.kodi.utils import translation |
| 4 | 5 |
| … | … | @@ -1,13 +1,157 @@ |
| 1 | import xbmc | |
| 2 | ||
| 1 | 3 | from lib.gui.play_window import PlayWindow |
| 4 | from lib.utils.kodi.utils import get_setting, kodilog | |
| 5 | ||
| 6 | ||
| 7 | def _safe_text(value): | |
| 8 | if value is None: | |
| 9 | return "" | |
| 10 | return str(value) | |
| 11 | ||
| 12 | ||
| 13 | def _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 | ||
| 22 | def _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 | ||
| 32 | def 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}" | |
| 2 | 63 | |
| 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 | ) | |
| 3 | 75 | |
| 4 | from 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 | } | |
| 5 | 87 | |
| 6 | 88 | |
| 7 | 89 | class PlayNext(PlayWindow): |
| 8 | 90 | def __init__(self, xml_file, xml_location, item_information=None): |
| 9 | 91 | super().__init__(xml_file, xml_location, item_information=item_information) |
| 10 | 92 | 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() | |
| 11 | 155 | |
| 12 | 156 | def smart_play_action(self): |
| 13 | 157 | if get_setting("auto_play"): |
| … | … | @@ -1,7 +1,9 @@ |
| 1 | 1 | import abc |
| 2 | ||
| 3 | import xbmc | |
| 4 | ||
| 2 | 5 | from lib.gui.base_window import BaseWindow |
| 3 | 6 | from lib.utils.kodi.utils import kodilog |
| 4 | import xbmc | |
| 5 | 7 | |
| 6 | 8 | |
| 7 | 9 | class PlayWindow(BaseWindow): |
| … | … | @@ -44,15 +46,15 @@ class PlayWindow(BaseWindow): |
| 44 | 46 | super().onInit() |
| 45 | 47 | |
| 46 | 48 | 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 | |
| 50 | 50 | |
| 51 | 51 | def background_tasks(self): |
| 52 | 52 | try: |
| 53 | 53 | 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): | |
| 56 | 58 | progress_bar = None |
| 57 | 59 | |
| 58 | 60 | while ( |
| … | … | @@ -82,10 +84,12 @@ class PlayWindow(BaseWindow): |
| 82 | 84 | def smart_play_action(self): |
| 83 | 85 | """ |
| 84 | 86 | Perform the default smartplay action at window timeout |
| 87 | ||
| 85 | 88 | :return: |
| 86 | 89 | """ |
| 87 | 90 | |
| 88 | 91 | def close(self): |
| 92 | ||
| 89 | 93 | self.closed = True |
| 90 | 94 | super().close() |
| 91 | 95 |
| … | … | @@ -1,4 +1,5 @@ |
| 1 | 1 | import xbmcgui |
| 2 | ||
| 2 | 3 | from lib.utils.kodi.utils import translation |
| 3 | 4 | |
| 4 | 5 | |
| … | … | @@ -28,13 +29,13 @@ class QRProgressDialog(xbmcgui.WindowXMLDialog): |
| 28 | 29 | ): |
| 29 | 30 | self.title = title |
| 30 | 31 | self.qr_image_path = qr_code |
| 31 | ||
| 32 | ||
| 32 | 33 | # If custom message provided, use it directly (bypass translation formatting) |
| 33 | 34 | if custom_message: |
| 34 | 35 | self.message = custom_message |
| 35 | 36 | elif is_debrid: |
| 36 | 37 | if debrid_type == "RealDebrid": |
| 37 | self.message = "%s\n\n%s" % ( | |
| 38 | self.message = "{}\n\n{}".format( | |
| 38 | 39 | translation(90541) % "https://real-debrid.com/device", |
| 39 | 40 | translation(90542) % user_code, |
| 40 | 41 | ) |
| … | … | @@ -1,15 +1,20 @@ |
| 1 | from typing import Optional, Dict, Any, cast | |
| 2 | 1 | from datetime import timedelta |
| 2 | from typing import Any, Dict, Optional, cast | |
| 3 | 3 | |
| 4 | 4 | from lib.clients.subtitle.submanager import SubtitleManager |
| 5 | 5 | from lib.db.cached import cache |
| 6 | from lib.domain.torrent import TorrentStream | |
| 6 | 7 | from lib.gui.base_window import BaseWindow |
| 7 | 8 | from lib.gui.source_pack_select import SourcePackSelect |
| 8 | 9 | from lib.player import JacktookPLayer |
| 9 | 10 | from lib.utils.debrid.debrid_utils import get_pack_info |
| 10 | from lib.utils.kodi.utils import get_setting, kodilog | |
| 11 | from lib.utils.kodi.utils import ADDON_PATH, notification, set_property | |
| 12 | from lib.domain.torrent import TorrentStream | |
| 11 | from lib.utils.kodi.utils import ( | |
| 12 | ADDON_PATH, | |
| 13 | get_setting, | |
| 14 | kodilog, | |
| 15 | notification, | |
| 16 | set_property, | |
| 17 | ) | |
| 13 | 18 | |
| 14 | 19 | |
| 15 | 20 | class ResolverWindow(BaseWindow): |
| … | … | @@ -147,17 +152,12 @@ class ResolverWindow(BaseWindow): |
| 147 | 152 | key = "" |
| 148 | 153 | if "ids" in playback_info: |
| 149 | 154 | 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 "") | |
| 156 | 156 | |
| 157 | 157 | if key: |
| 158 | 158 | tv_data = cast(Dict[str, Any], playback_info.get("tv_data", {}) or {}) |
| 159 | 159 | 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']}" | |
| 161 | 161 | |
| 162 | 162 | if key: |
| 163 | 163 | cache.set(key, playback_info, timedelta(days=30)) |
| … | … | @@ -186,9 +186,7 @@ class ResolverWindow(BaseWindow): |
| 186 | 186 | |
| 187 | 187 | |
| 188 | 188 | class 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): | |
| 192 | 190 | self.message = message |
| 193 | 191 | self.status_code = status_code |
| 194 | 192 | self.error_content = error_content |
| … | … | @@ -1,6 +1,7 @@ |
| 1 | 1 | from lib.gui.base_window import BaseWindow |
| 2 | 2 | from lib.utils.kodi.utils import translation |
| 3 | 3 | |
| 4 | ||
| 4 | 5 | class ResumeDialog(BaseWindow): |
| 5 | 6 | def __init__(self, xml_file, xml_location, **kwargs): |
| 6 | 7 | super().__init__(xml_file, xml_location) |
| … | … | @@ -1,15 +1,13 @@ |
| 1 | from dataclasses import dataclass, field | |
| 2 | from typing import List, Optional, Callable, Dict, Any | |
| 3 | from concurrent.futures import Future, ThreadPoolExecutor, as_completed | |
| 4 | 1 | import threading |
| 2 | from concurrent.futures import Future, ThreadPoolExecutor | |
| 3 | from dataclasses import dataclass | |
| 4 | from typing import Callable, List, Optional | |
| 5 | 5 | |
| 6 | 6 | import xbmc |
| 7 | import xbmcgui | |
| 8 | 7 | |
| 9 | from lib.utils.kodi.utils import kodilog, translation | |
| 10 | from lib.gui.base_window import BaseWindow | |
| 11 | 8 | from lib.domain.torrent import TorrentStream |
| 12 | ||
| 9 | from lib.gui.base_window import BaseWindow | |
| 10 | from lib.utils.kodi.utils import kodilog, translation | |
| 13 | 11 | |
| 14 | 12 | STATUS_MAP = { |
| 15 | 13 | "Pending": 90244, |
| … | … | @@ -24,9 +22,7 @@ STATUS_MAP = { |
| 24 | 22 | class SearchTask: |
| 25 | 23 | name: str |
| 26 | 24 | 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" | |
| 30 | 26 | result_count: int = 0 |
| 31 | 27 | error: str = "" |
| 32 | 28 | future: Optional[Future] = None |
| … | … | @@ -37,19 +33,19 @@ class SearchTaskManager: |
| 37 | 33 | self.executor = executor |
| 38 | 34 | self.tasks: List[SearchTask] = [] |
| 39 | 35 | self._cancel_event = threading.Event() |
| 36 | self._lock = threading.RLock() | |
| 40 | 37 | |
| 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: | |
| 44 | 39 | task = SearchTask(name=name, indexer_key=indexer_key) |
| 45 | self.tasks.append(task) | |
| 40 | with self._lock: | |
| 41 | self.tasks.append(task) | |
| 46 | 42 | |
| 47 | 43 | def wrapped_fn(*a, **kw): |
| 48 | 44 | if self._cancel_event.is_set(): |
| 49 | task.status = "Cancelled" | |
| 45 | self._update_task(task, status="Cancelled") | |
| 50 | 46 | return [] |
| 51 | 47 | |
| 52 | task.status = "In Progress" | |
| 48 | self._update_task(task, status="In Progress") | |
| 53 | 49 | try: |
| 54 | 50 | # ARTIFICIAL DELAY FOR TESTING GUI UPDATES |
| 55 | 51 | # xbmc.sleep(8000) |
| … | … | @@ -57,15 +53,17 @@ class SearchTaskManager: |
| 57 | 53 | results = fn(*a, **kw) |
| 58 | 54 | |
| 59 | 55 | if self._cancel_event.is_set(): |
| 60 | task.status = "Cancelled" | |
| 56 | self._update_task(task, status="Cancelled") | |
| 61 | 57 | return results if results else [] |
| 62 | 58 | |
| 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 | ) | |
| 65 | 64 | return results |
| 66 | 65 | except Exception as e: |
| 67 | task.status = "Failed" | |
| 68 | task.error = str(e) | |
| 66 | self._update_task(task, status="Failed", error=str(e)) | |
| 69 | 67 | kodilog(f"Task {name} failed: {e}") |
| 70 | 68 | return [] |
| 71 | 69 | |
| … | … | @@ -75,10 +73,11 @@ class SearchTaskManager: |
| 75 | 73 | |
| 76 | 74 | def cancel_pending(self): |
| 77 | 75 | 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" | |
| 82 | 81 | |
| 83 | 82 | @property |
| 84 | 83 | def is_cancelled(self) -> bool: |
| … | … | @@ -86,11 +85,42 @@ class SearchTaskManager: |
| 86 | 85 | |
| 87 | 86 | @property |
| 88 | 87 | 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 | ] | |
| 90 | 118 | |
| 91 | 119 | def collect_results(self) -> List[TorrentStream]: |
| 92 | 120 | total_results = [] |
| 93 | for task in self.tasks: | |
| 121 | with self._lock: | |
| 122 | tasks = list(self.tasks) | |
| 123 | for task in tasks: | |
| 94 | 124 | if task.future and task.future.done(): |
| 95 | 125 | try: |
| 96 | 126 | res = task.future.result() |
| … | … | @@ -120,20 +150,7 @@ class SearchStatusWindow(BaseWindow): |
| 120 | 150 | self.setProperty("instant_close", "false") |
| 121 | 151 | |
| 122 | 152 | # 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() | |
| 137 | 154 | |
| 138 | 155 | def _get_status_color(self, status: str) -> str: |
| 139 | 156 | if status == "Pending": |
| … | … | @@ -148,9 +165,9 @@ class SearchStatusWindow(BaseWindow): |
| 148 | 165 | return "FFFFA500" # Orange |
| 149 | 166 | return "FFFFFFFF" # White |
| 150 | 167 | |
| 151 | def _update_loop(self): | |
| 168 | def run_until_complete(self, poll_interval_ms=250, final_delay_ms=500): | |
| 152 | 169 | while not self._monitor.abortRequested() and not self.task_manager.is_complete: |
| 153 | xbmc.sleep(250) | |
| 170 | xbmc.sleep(poll_interval_ms) | |
| 154 | 171 | |
| 155 | 172 | if self.task_manager.is_cancelled: |
| 156 | 173 | break |
| … | … | @@ -162,16 +179,17 @@ class SearchStatusWindow(BaseWindow): |
| 162 | 179 | |
| 163 | 180 | # Give user a brief moment to see final state if not cancelled |
| 164 | 181 | if not self.task_manager.is_cancelled: |
| 165 | xbmc.sleep(500) | |
| 182 | xbmc.sleep(final_delay_ms) | |
| 166 | 183 | |
| 167 | 184 | self.close() |
| 168 | 185 | |
| 169 | 186 | 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) | |
| 171 | 189 | completed_tasks = 0 |
| 172 | 190 | total_results = 0 |
| 173 | 191 | |
| 174 | for i, task in enumerate(self.task_manager.tasks): | |
| 192 | for i, task in enumerate(tasks): | |
| 175 | 193 | if i >= self.MAX_TASK_SLOTS: |
| 176 | 194 | break |
| 177 | 195 | |
| … | … | @@ -185,16 +203,21 @@ class SearchStatusWindow(BaseWindow): |
| 185 | 203 | if task.status == "Completed": |
| 186 | 204 | completed_tasks += 1 |
| 187 | 205 | self.setProperty(f"task_{i}_results", str(task.result_count)) |
| 206 | self.setProperty(f"task_{i}_show_spinner", "") | |
| 188 | 207 | elif task.status in ["Failed", "Cancelled"]: |
| 189 | 208 | completed_tasks += 1 |
| 190 | 209 | self.setProperty(f"task_{i}_results", "") |
| 210 | self.setProperty(f"task_{i}_show_spinner", "") | |
| 191 | 211 | elif task.status == "In Progress": |
| 192 | 212 | self.setProperty( |
| 193 | 213 | f"task_{i}_results", |
| 194 | 214 | str(task.result_count) if task.result_count > 0 else "", |
| 195 | 215 | ) |
| 216 | show_spinner = "true" if task.result_count == 0 else "" | |
| 217 | self.setProperty(f"task_{i}_show_spinner", show_spinner) | |
| 196 | 218 | else: |
| 197 | 219 | self.setProperty(f"task_{i}_results", "") |
| 220 | self.setProperty(f"task_{i}_show_spinner", "") | |
| 198 | 221 | |
| 199 | 222 | # Update progress |
| 200 | 223 | if total_tasks > 0: |
| … | … | @@ -3,8 +3,7 @@ from threading import Thread |
| 3 | 3 | import xbmc |
| 4 | 4 | import xbmcgui |
| 5 | 5 | |
| 6 | from lib.utils.kodi.utils import ADDON_PATH, kodilog | |
| 7 | ||
| 6 | from lib.utils.kodi.utils import kodilog | |
| 8 | 7 | |
| 9 | 8 | ACTION_PREVIOUS_MENU = 10 |
| 10 | 9 | ACTION_PLAYER_STOP = 13 |
| … | … | @@ -14,10 +13,11 @@ ACTION_NAV_BACK = 92 |
| 14 | 13 | class SkipIntroWindow(xbmcgui.WindowXMLDialog): |
| 15 | 14 | """ |
| 16 | 15 | Overlay dialog that shows a 'Skip Intro' or 'Skip Recap' button |
| 16 | ||
| 17 | 17 | during playback when IntroDB segment data is available. |
| 18 | 18 | """ |
| 19 | ||
| 20 | 19 | def __init__(self, xml_file, xml_location, segment_data=None, label="Skip Intro"): |
| 20 | ||
| 21 | 21 | super().__init__(xml_file, xml_location) |
| 22 | 22 | self.segment_data = segment_data or {} |
| 23 | 23 | self.label = label |
| … | … | @@ -41,6 +41,7 @@ class SkipIntroWindow(xbmcgui.WindowXMLDialog): |
| 41 | 41 | def _background_monitor(self): |
| 42 | 42 | """Monitor playback and auto-close when segment ends or playback stops.""" |
| 43 | 43 | try: |
| 44 | ||
| 44 | 45 | end_sec = self.segment_data.get("end_sec", 0) |
| 45 | 46 | |
| 46 | 47 | while not self.closed and self.player.isPlaying(): |
| … | … | @@ -80,6 +81,7 @@ class SkipIntroWindow(xbmcgui.WindowXMLDialog): |
| 80 | 81 | def _do_skip(self): |
| 81 | 82 | """Seek to the end of the current segment.""" |
| 82 | 83 | try: |
| 84 | ||
| 83 | 85 | end_sec = self.segment_data.get("end_sec", 0) |
| 84 | 86 | if end_sec > 0 and self.player.isPlaying(): |
| 85 | 87 | self.player.seekTime(end_sec) |
| … | … | @@ -0,0 +1,163 @@ |
| 1 | import json | |
| 2 | import os | |
| 3 | from datetime import timedelta | |
| 4 | ||
| 5 | import xbmcgui | |
| 6 | ||
| 7 | from lib.db.cached import cache | |
| 8 | from lib.utils.kodi.settings import get_setting | |
| 9 | from lib.utils.kodi.utils import ADDON_PATH, kodilog, translation | |
| 10 | ||
| 11 | BUILTIN_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 | ||
| 21 | CACHE_KEY = "source_manager_selection" | |
| 22 | KNOWN_CACHE_KEY = "source_manager_known_keys" | |
| 23 | ||
| 24 | ||
| 25 | def _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 | ||
| 39 | def _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 | ||
| 50 | def _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 | ||
| 61 | def _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 | ||
| 79 | def _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 | ||
| 107 | def _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 | ||
| 137 | def _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 | ||
| 143 | def 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) |
| … | … | @@ -1,7 +1,8 @@ |
| 1 | from typing import Optional, Dict, Any | |
| 1 | from typing import Any, Dict, Optional | |
| 2 | ||
| 3 | from lib.domain.torrent import TorrentStream | |
| 2 | 4 | from lib.gui.source_pack_window import SourcePackWindow |
| 3 | 5 | from lib.utils.general.utils import DebridType |
| 4 | from lib.domain.torrent import TorrentStream | |
| 5 | 6 | from lib.utils.player.utils import resolve_playback_url |
| 6 | 7 | |
| 7 | 8 | |
| … | … | @@ -14,9 +15,7 @@ class SourcePackSelect(SourcePackWindow): |
| 14 | 15 | pack_info: Optional[Dict[str, Any]] = None, |
| 15 | 16 | item_information: Optional[Dict[str, Any]] = None, |
| 16 | 17 | ) -> 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) | |
| 20 | 19 | self.source: Optional[TorrentStream] = source |
| 21 | 20 | self.pack_info: Optional[Dict[str, Any]] = pack_info |
| 22 | 21 | self.position: int = -1 |
| … | … | @@ -30,9 +29,8 @@ class SourcePackSelect(SourcePackWindow): |
| 30 | 29 | |
| 31 | 30 | def handle_action(self, action_id: int, control_id: Optional[int] = None) -> None: |
| 32 | 31 | 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() | |
| 36 | 34 | |
| 37 | 35 | def _resolve_item(self) -> None: |
| 38 | 36 | self.setProperty("resolving", "true") |
| … | … | @@ -1,12 +1,14 @@ |
| 1 | 1 | import abc |
| 2 | ||
| 2 | 3 | import xbmcgui |
| 4 | ||
| 3 | 5 | from lib.gui.base_window import BaseWindow |
| 4 | 6 | |
| 5 | 7 | |
| 6 | 8 | class SourcePackWindow(BaseWindow): |
| 7 | 9 | def __init__(self, xml_file, location, source=None, pack_info=None, item_information=None): |
| 8 | 10 | 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": []} | |
| 10 | 12 | self.source = source or type("EmptySource", (), {"type": "", "quality": ""})() |
| 11 | 13 | self.display_list = None |
| 12 | 14 |
| … | … | @@ -1,332 +1,338 @@ |
| 1 | from typing import List, Optional, Dict | |
| 2 | from lib.domain.torrent import TorrentStream | |
| 3 | from lib.gui.filter_type_window import FilterTypeWindow | |
| 4 | from lib.gui.filter_items_window import FilterWindow | |
| 5 | from lib.gui.base_window import BaseWindow | |
| 6 | from lib.gui.resolver_window import ResolverWindow | |
| 7 | from lib.gui.resume_window import ResumeDialog | |
| 8 | from lib.utils.debrid.debrid_utils import ( | |
| 9 | add_source_to_debrid, | |
| 10 | get_torrent_data_from_uri, | |
| 11 | get_source_status, | |
| 12 | ) | |
| 13 | from 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 | ) | |
| 1 | import contextlib | |
| 2 | import threading | |
| 3 | from typing import Dict, List, Optional | |
| 4 | ||
| 5 | import xbmc | |
| 6 | import xbmcgui | |
| 7 | ||
| 8 | from lib.clients.stremio.constants import STREMIO_ADDON_ALIASES_KEY | |
| 9 | from lib.db.cached import cache | |
| 10 | from lib.domain.torrent import TorrentStream | |
| 11 | from lib.gui.base_window import BaseWindow | |
| 12 | from lib.gui.filter_items_window import FilterWindow | |
| 13 | from lib.gui.filter_type_window import FilterTypeWindow | |
| 14 | from lib.gui.qr_progress_dialog import QRProgressDialog | |
| 15 | from lib.gui.resolver_window import ResolverWindow | |
| 16 | from lib.gui.resume_window import ResumeDialog | |
| 17 | ||
| 18 | # For web subtitle upload | |
| 19 | from lib.services.subtitle_server import SubtitleUploadServer, get_local_ip | |
| 20 | from lib.utils.debrid.debrid_utils import ( | |
| 21 | add_source_to_debrid, | |
| 22 | get_source_status, | |
| 23 | get_torrent_data_from_uri, | |
| 24 | ) | |
| 25 | from lib.utils.debrid.qrcode_utils import make_qrcode | |
| 23 | 26 | from lib.utils.general.utils import ( |
| 24 | 27 | IndexerType, |
| 25 | 28 | extract_publish_date, |
| 26 | get_info_hash_from_magnet, | |
| 29 | format_season_episode, | |
| 27 | 30 | get_colored_languages, |
| 31 | get_info_hash_from_magnet, | |
| 28 | 32 | get_provider_color, |
| 29 | 33 | get_random_color, |
| 30 | format_season_episode, | |
| 31 | safe_json_loads, | |
| 32 | 34 | normalize_tv_data, |
| 35 | safe_json_loads, | |
| 33 | 36 | ) |
| 34 | from lib.utils.parsers.title_parser import parse_title_info | |
| 35 | from lib.utils.torrent.torrserver_utils import add_source_to_torrserver | |
| 36 | ||
| 37 | import xbmcgui | |
| 38 | import xbmc | |
| 39 | import threading | |
| 40 | ||
| 41 | # For web subtitle upload | |
| 42 | from lib.services.subtitle_server import SubtitleUploadServer, get_local_ip | |
| 43 | from lib.utils.debrid.qrcode_utils import make_qrcode | |
| 44 | from lib.gui.qr_progress_dialog import QRProgressDialog | |
| 45 | ||
| 46 | THEMES = { | |
| 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 | ||
| 55 | class 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 | |
| 37 | from 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 | ) | |
| 46 | from lib.utils.parsers.title_parser import parse_title_info | |
| 47 | from lib.utils.torrent.torrserver_utils import add_source_to_torrserver | |
| 48 | ||
| 49 | THEMES = { | |
| 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 | ||
| 58 | def _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 | ||
| 67 | class 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 | |
| 330 | 336 | elif source.type == IndexerType.DEBRID: |
| 331 | 337 | provider_name = source.debridType or source.type |
| 332 | 338 | elif source.type == IndexerType.STREMIO_DEBRID: |
| … | … | @@ -340,108 +346,113 @@ class SourceSelect(BaseWindow): |
| 340 | 346 | provider_name = source.indexer or source.type |
| 341 | 347 | else: |
| 342 | 348 | provider_name = source.debridType or source.type |
| 343 | indexer_label = source.addonInstanceLabel or source.indexer | |
| 349 | indexer_label = _get_source_indexer_label(source, aliases) | |
| 344 | 350 | 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 | ||
| 434 | 444 | def _download_file(self, selected_source) -> None: |
| 435 | 445 | self.playback_info = self._ensure_playback_info(selected_source) |
| 436 | 446 | if self.playback_info: |
| 437 | 447 | self.playback_info.update(self.item_information) |
| 438 | 448 | else: |
| 439 | notification(translation(90407)) | |
| 440 | return | |
| 449 | notification(translation(90407)) | |
| 450 | return | |
| 441 | 451 | |
| 442 | download_dir = get_setting("download_dir") | |
| 443 | 452 | 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() | |
| 445 | 456 | year = self.item_information.get("year", "") |
| 446 | 457 | tv_data = self.playback_info.get("tv_data") |
| 447 | 458 | if isinstance(tv_data, str): |
| … | … | @@ -456,185 +467,190 @@ class SourceSelect(BaseWindow): |
| 456 | 467 | else: |
| 457 | 468 | file_name = title |
| 458 | 469 | |
| 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 | ||
| 459 | 485 | xbmc.executebuiltin( |
| 460 | 486 | action_url_run( |
| 461 | 487 | "handle_download_file", |
| 462 | 488 | file_name=file_name, |
| 463 | 489 | url=self.playback_info["url"], |
| 464 | destination=translatePath(download_dir), | |
| 490 | destination=destination, | |
| 465 | 491 | ) |
| 466 | 492 | ) |
| 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) |
| … | … | @@ -1,12 +1,13 @@ |
| 1 | from lib.clients.base import BaseClient, TorrentStream | |
| 1 | from typing import Any, Callable, Dict, List, Optional | |
| 2 | ||
| 3 | from ..clients.base import BaseClient, TorrentStream | |
| 4 | from ..utils.kodi.utils import convert_size_to_bytes, get_setting | |
| 2 | 5 | from .providers import ( |
| 3 | 6 | burst_search, |
| 4 | 7 | burst_search_episode, |
| 5 | 8 | burst_search_movie, |
| 6 | 9 | burst_search_season, |
| 7 | 10 | ) |
| 8 | from lib.utils.kodi.utils import convert_size_to_bytes, get_setting | |
| 9 | from typing import List, Optional, Dict, Any, Callable | |
| 10 | 11 | |
| 11 | 12 | |
| 12 | 13 | class Burst(BaseClient): |
| … | … | @@ -22,15 +23,14 @@ class Burst(BaseClient): |
| 22 | 23 | season: Optional[int], |
| 23 | 24 | episode: Optional[int], |
| 24 | 25 | silent: bool = False, |
| 26 | **kwargs: Any, | |
| 25 | 27 | ) -> Optional[List[TorrentStream]]: |
| 26 | 28 | try: |
| 27 | 29 | if mode == "tv" or media_type == "tv": |
| 28 | 30 | if get_setting("include_season_packs"): |
| 29 | 31 | results = burst_search_season(tmdb_id, query, season, silent=silent) |
| 30 | 32 | 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) | |
| 34 | 34 | elif mode == "movies" or media_type == "movies": |
| 35 | 35 | results = burst_search_movie(tmdb_id, query, silent=silent) |
| 36 | 36 | else: |
| … | … | @@ -39,25 +39,39 @@ class Burst(BaseClient): |
| 39 | 39 | results = self.parse_response(results) |
| 40 | 40 | return results |
| 41 | 41 | except Exception as e: |
| 42 | self.handle_exception(f"Burst error: {str(e)}") | |
| 42 | self.handle_exception(f"Burst error: {e!s}") | |
| 43 | 43 | |
| 44 | 44 | def parse_response(self, res: List[Dict[str, Any]]) -> List[TorrentStream]: |
| 45 | 45 | results = [] |
| 46 | 46 | 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}" | |
| 61 | 75 | ) |
| 62 | ) | |
| 76 | continue | |
| 63 | 77 | return results |
| … | … | @@ -1,12 +1,12 @@ |
| 1 | from .utils import NoProvidersError | |
| 2 | from .provider_base import ProviderListener | |
| 3 | from lib.utils.kodi.utils import ADDON_NAME, translation | |
| 4 | 1 | from xbmcgui import DialogProgressBG |
| 5 | 2 | |
| 3 | from ..utils.kodi.utils import ADDON_NAME, translation | |
| 4 | from .provider_base import ProviderListener | |
| 5 | ||
| 6 | 6 | |
| 7 | 7 | class ProviderListenerDialog(ProviderListener): |
| 8 | 8 | 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) | |
| 10 | 10 | self._total = len(providers) |
| 11 | 11 | self._count = 0 |
| 12 | 12 | self._silent = silent |
| … | … | @@ -18,16 +18,14 @@ class ProviderListenerDialog(ProviderListener): |
| 18 | 18 | self._dialog.update(int(100 * self._count / self._total)) |
| 19 | 19 | |
| 20 | 20 | def __enter__(self): |
| 21 | ret = super(ProviderListenerDialog, self).__enter__() | |
| 21 | ret = super().__enter__() | |
| 22 | 22 | if self._dialog: |
| 23 | 23 | self._dialog.create(ADDON_NAME, translation(90660)) |
| 24 | 24 | return ret |
| 25 | 25 | |
| 26 | 26 | def __exit__(self, exc_type, exc_val, exc_tb): |
| 27 | 27 | 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) | |
| 31 | 29 | finally: |
| 32 | 30 | if self._dialog: |
| 33 | 31 | self._dialog.close() |
| … | … | @@ -4,6 +4,8 @@ import sys |
| 4 | 4 | import threading |
| 5 | 5 | import time |
| 6 | 6 | |
| 7 | import xbmc | |
| 8 | ||
| 7 | 9 | from .utils import ( |
| 8 | 10 | ADDON_ID, |
| 9 | 11 | bytes_to_str, |
| … | … | @@ -13,10 +15,8 @@ from .utils import ( |
| 13 | 15 | run_script, |
| 14 | 16 | str_to_bytes, |
| 15 | 17 | ) |
| 16 | import xbmc | |
| 17 | 18 | |
| 18 | ||
| 19 | __all__ = ["ProviderResult", "Provider"] | |
| 19 | __all__ = ["Provider", "ProviderResult"] | |
| 20 | 20 | |
| 21 | 21 | |
| 22 | 22 | def get_providers(): |
| … | … | @@ -60,7 +60,7 @@ class ProviderResult(dict): |
| 60 | 60 | set_size, size = _setter_and_getter("size") |
| 61 | 61 | |
| 62 | 62 | |
| 63 | class Provider(object): | |
| 63 | class Provider: | |
| 64 | 64 | def __init__(self): |
| 65 | 65 | self._methods = {} |
| 66 | 66 | for name in dir(self): |
| … | … | @@ -81,18 +81,12 @@ class Provider(object): |
| 81 | 81 | def search_season(self, tmdb_id, show_title, season_number, titles): |
| 82 | 82 | raise NotImplementedError("'search_season' method must be implemented") |
| 83 | 83 | |
| 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): | |
| 87 | 85 | raise NotImplementedError("'search_episode' method must be implemented") |
| 88 | 86 | |
| 89 | 87 | def resolve(self, provider_data): |
| 90 | 88 | raise NotImplementedError("'resolve' method must be implemented") |
| 91 | 89 | |
| 92 | def ping(self): | |
| 93 | kodilog("Ping method called") | |
| 94 | return ADDON_ID | |
| 95 | ||
| 96 | 90 | def register(self): |
| 97 | 91 | if len(sys.argv) != 4: |
| 98 | 92 | kodilog("Expecting 4 arguments") |
| … | … | @@ -103,9 +97,7 @@ class Provider(object): |
| 103 | 97 | if method in self._methods: |
| 104 | 98 | try: |
| 105 | 99 | 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", {})) | |
| 109 | 101 | except Exception as e: |
| 110 | 102 | kodilog(f"Failed running method '{method}': {e}") |
| 111 | 103 | value = None |
| … | … | @@ -120,9 +112,9 @@ class Provider(object): |
| 120 | 112 | |
| 121 | 113 | class ProviderListener(xbmc.Monitor): |
| 122 | 114 | def __init__(self, providers, method, timeout=10): |
| 123 | super(ProviderListener, self).__init__() | |
| 115 | super().__init__() | |
| 124 | 116 | self._waiting = {i: True for i in providers} |
| 125 | self._method = "Other.{}.{}".format(ADDON_ID, method) | |
| 117 | self._method = f"Other.{ADDON_ID}.{method}" | |
| 126 | 118 | self._timeout = timeout |
| 127 | 119 | self._data = {} |
| 128 | 120 | self._lock = threading.Lock() |
| … | … | @@ -1,11 +1,11 @@ |
| 1 | from lib.jacktook.provider_base import ( | |
| 1 | from ..utils.kodi.utils import kodilog | |
| 2 | from .listener import ProviderListenerDialog | |
| 3 | from .provider_base import ( | |
| 2 | 4 | ProviderResult, |
| 3 | 5 | get_providers, |
| 4 | 6 | send_to_providers, |
| 5 | 7 | ) |
| 6 | from lib.jacktook.listener import ProviderListenerDialog | |
| 7 | from lib.jacktook.utils import NoProvidersError | |
| 8 | from lib.utils.kodi.utils import kodilog | |
| 8 | from .utils import NoProvidersError | |
| 9 | 9 | |
| 10 | 10 | |
| 11 | 11 | def burst_search(query, silent=False): |
| … | … | @@ -21,9 +21,7 @@ def burst_search_show(show_id, query, silent=False): |
| 21 | 21 | |
| 22 | 22 | |
| 23 | 23 | def 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) | |
| 27 | 25 | |
| 28 | 26 | |
| 29 | 27 | def burst_search_episode(show_id, query, season_number, episode_number, silent=False): |
| … | … | @@ -43,9 +41,7 @@ def search(method, *args, **kwargs): |
| 43 | 41 | if not providers: |
| 44 | 42 | raise NoProvidersError("No providers available") |
| 45 | 43 | |
| 46 | with ProviderListenerDialog( | |
| 47 | providers, method, timeout=30, silent=silent | |
| 48 | ) as listener: | |
| 44 | with ProviderListenerDialog(providers, method, timeout=30, silent=silent) as listener: | |
| 49 | 45 | send_to_providers(providers, method, *args, **kwargs) |
| 50 | 46 | |
| 51 | 47 | results = [] |
| … | … | @@ -1,8 +1,11 @@ |
| 1 | 1 | import json |
| 2 | ||
| 2 | 3 | import xbmc |
| 3 | 4 | import xbmcaddon |
| 4 | 5 | |
| 5 | from lib.utils.kodi.logging import kodilog | |
| 6 | ||
| 7 | def kodilog(message, level=xbmc.LOGINFO): | |
| 8 | xbmc.log("[###JACKTOOKLOG###] " + str(message), level) | |
| 6 | 9 | |
| 7 | 10 | |
| 8 | 11 | ADDON = xbmcaddon.Addon() |
| … | … | @@ -13,9 +16,7 @@ ADDON_NAME = ADDON.getAddonInfo("name") |
| 13 | 16 | |
| 14 | 17 | |
| 15 | 18 | def 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) | |
| 19 | 20 | addons = data["result"].get("addons") |
| 20 | 21 | return [(a["addonid"], a["type"]) for a in addons] if addons else [] |
| 21 | 22 | |
| … | … | @@ -28,20 +29,17 @@ def notify_all(sender, message, data=None): |
| 28 | 29 | |
| 29 | 30 | |
| 30 | 31 | def run_script(script_id, *args): |
| 31 | xbmc.executebuiltin("RunScript({})".format(",".join((script_id,) + args))) | |
| 32 | xbmc.executebuiltin("RunScript({})".format(",".join((script_id, *args)))) | |
| 32 | 33 | |
| 33 | 34 | |
| 34 | 35 | def execute_json_rpc(method, rpc_version="2.0", rpc_id=1, **params): |
| 35 | 36 | return json.loads( |
| 36 | 37 | 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)) | |
| 40 | 39 | ) |
| 41 | 40 | ) |
| 42 | 41 | |
| 43 | 42 | |
| 44 | ||
| 45 | 43 | class ResolveTimeoutError(Exception): |
| 46 | 44 | pass |
| 47 | 45 |
| … | … | @@ -1,32 +1,36 @@ |
| 1 | from datetime import timedelta | |
| 1 | 2 | from threading import Thread |
| 2 | 3 | from typing import Any, Dict, List |
| 3 | from datetime import timedelta | |
| 4 | 4 | |
| 5 | 5 | from xbmcplugin import addDirectoryItem |
| 6 | 6 | |
| 7 | 7 | from lib.api.debrid.alldebrid import AllDebrid |
| 8 | 8 | from lib.api.debrid.debrider import Debrider |
| 9 | from lib.api.debrid.offcloud import Offcloud | |
| 9 | 10 | from lib.api.debrid.premiumize import Premiumize |
| 10 | 11 | from lib.api.debrid.realdebrid import RealDebrid |
| 11 | 12 | from lib.api.debrid.torbox import Torbox |
| 12 | 13 | from lib.clients.debrid.alldebrid import AllDebridHelper |
| 13 | 14 | from lib.clients.debrid.debrider import DebriderHelper |
| 15 | from lib.clients.debrid.offcloud import OffcloudHelper | |
| 14 | 16 | from lib.clients.debrid.realdebrid import RealDebridHelper |
| 15 | 17 | from lib.clients.debrid.torbox import TorboxHelper |
| 18 | from lib.db.cached import cache | |
| 16 | 19 | from lib.services.debrid.auth import ( |
| 17 | 20 | run_alldebrid_auth, |
| 18 | 21 | run_debrider_auth, |
| 22 | run_offcloud_auth, | |
| 19 | 23 | run_premiumize_auth, |
| 20 | 24 | run_realdebrid_auth, |
| 21 | 25 | run_torbox_auth, |
| 22 | 26 | ) |
| 23 | 27 | from lib.services.debrid.download import run_realdebrid_download |
| 24 | from lib.db.cached import cache | |
| 25 | 28 | from lib.utils.general.utils import ( |
| 26 | 29 | DebridType, |
| 27 | 30 | IndexerType, |
| 28 | 31 | build_list_item, |
| 29 | 32 | check_debrid_enabled, |
| 33 | get_public_ip, | |
| 30 | 34 | get_random_color, |
| 31 | 35 | set_pluging_category, |
| 32 | 36 | ) |
| … | … | @@ -36,9 +40,9 @@ from lib.utils.kodi.utils import ( |
| 36 | 40 | apply_section_view, |
| 37 | 41 | build_url, |
| 38 | 42 | end_of_directory, |
| 39 | execute_builtin, | |
| 40 | 43 | finish_action, |
| 41 | 44 | get_setting, |
| 45 | kodilog, | |
| 42 | 46 | notification, |
| 43 | 47 | translation, |
| 44 | 48 | ) |
| … | … | @@ -56,6 +60,12 @@ def _start_torbox_download(magnet): |
| 56 | 60 | thread.start() |
| 57 | 61 | |
| 58 | 62 | |
| 63 | def _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 | ||
| 59 | 69 | def _start_premiumize_download(magnet): |
| 60 | 70 | pm_client = Premiumize(token=str(get_setting("premiumize_token"))) |
| 61 | 71 | thread = Thread(target=pm_client.download, args=(magnet,), kwargs={"pack": False}) |
| … | … | @@ -67,11 +77,13 @@ DEBRID_CLOUD_ACTIONS = { |
| 67 | 77 | DebridType.DB: {"info": "debrider_info"}, |
| 68 | 78 | DebridType.AD: {"info": "alldebrid_info"}, |
| 69 | 79 | DebridType.TB: {"downloads": "get_tb_downloads", "info": "torbox_info"}, |
| 80 | DebridType.OC: {"downloads": "get_oc_downloads", "info": "offcloud_info"}, | |
| 70 | 81 | } |
| 71 | 82 | |
| 72 | 83 | DEBRID_DOWNLOAD_HANDLERS = { |
| 73 | 84 | "RD": _start_realdebrid_download, |
| 74 | 85 | "TB": _start_torbox_download, |
| 86 | "OC": _start_offcloud_download, | |
| 75 | 87 | "PM": _start_premiumize_download, |
| 76 | 88 | } |
| 77 | 89 | |
| … | … | @@ -80,6 +92,7 @@ DEBRID_INFO_HANDLERS = { |
| 80 | 92 | DebridType.AD: lambda: AllDebridHelper().get_info(), |
| 81 | 93 | DebridType.DB: lambda: DebriderHelper().get_info(), |
| 82 | 94 | DebridType.TB: lambda: TorboxHelper().get_info(), |
| 95 | DebridType.OC: lambda: OffcloudHelper().get_info(), | |
| 83 | 96 | } |
| 84 | 97 | |
| 85 | 98 | |
| … | … | @@ -119,17 +132,13 @@ def cloud_details(params): |
| 119 | 132 | |
| 120 | 133 | def cloud(params): |
| 121 | 134 | 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)] | |
| 125 | 136 | for debrid in activated_debrids: |
| 126 | 137 | torrent_li = build_list_item(debrid, "download.png") |
| 127 | 138 | actions = DEBRID_CLOUD_ACTIONS.get(debrid, {}) |
| 128 | 139 | info_action = actions.get("info") |
| 129 | 140 | 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))]) | |
| 133 | 142 | addDirectoryItem( |
| 134 | 143 | ADDON_HANDLE, |
| 135 | 144 | build_url("cloud_details", debrid_name=debrid), |
| … | … | @@ -191,24 +200,35 @@ def get_rd_downloads(params): |
| 191 | 200 | rd_client = RealDebrid(token=str(get_setting("real_debrid_token", ""))) |
| 192 | 201 | raw_downloads = rd_client.get_user_downloads_list(page=page) |
| 193 | 202 | 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)] | |
| 197 | 204 | |
| 198 | 205 | sorted_downloads = sorted( |
| 199 | 206 | downloads, key=lambda item: str(item.get("filename", "")), reverse=False |
| 200 | 207 | ) |
| 201 | 208 | 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") | |
| 205 | 211 | 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) | |
| 206 | 226 | addDirectoryItem( |
| 207 | 227 | ADDON_HANDLE, |
| 208 | 228 | build_url( |
| 209 | 229 | "play_info_hash", |
| 210 | url=download.get("download"), | |
| 211 | title=download.get("filename"), | |
| 230 | url=direct_url, | |
| 231 | title=filename, | |
| 212 | 232 | debrid_type=debrid_type, |
| 213 | 233 | ), |
| 214 | 234 | torrent_li, |
| … | … | @@ -237,9 +257,7 @@ def get_tb_downloads(params): |
| 237 | 257 | if raw_downloads is None: |
| 238 | 258 | raw_downloads = TorboxHelper().get_cloud_downloads() |
| 239 | 259 | 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)] | |
| 243 | 261 | |
| 244 | 262 | def _torbox_sort_key(item: Dict[str, Any]): |
| 245 | 263 | return ( |
| … | … | @@ -254,20 +272,35 @@ def get_tb_downloads(params): |
| 254 | 272 | ) |
| 255 | 273 | |
| 256 | 274 | 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, | |
| 258 | 278 | ) if params.get("debug") else None |
| 259 | 279 | |
| 260 | 280 | 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") | |
| 264 | 283 | 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) | |
| 265 | 298 | addDirectoryItem( |
| 266 | 299 | ADDON_HANDLE, |
| 267 | 300 | build_url( |
| 268 | 301 | "play_media", |
| 269 | 302 | data={ |
| 270 | "title": download.get("name"), | |
| 303 | "title": name, | |
| 271 | 304 | "url": "", |
| 272 | 305 | "mode": "movie", |
| 273 | 306 | "type": IndexerType.DEBRID, |
| … | … | @@ -285,6 +318,64 @@ def get_tb_downloads(params): |
| 285 | 318 | apply_section_view("view.downloads", content_type="files") |
| 286 | 319 | |
| 287 | 320 | |
| 321 | def 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 | ||
| 288 | 379 | def download(magnet, debrid_type): |
| 289 | 380 | handler = DEBRID_DOWNLOAD_HANDLERS.get(debrid_type) |
| 290 | 381 | if not handler: |
| … | … | @@ -293,6 +384,38 @@ def download(magnet, debrid_type): |
| 293 | 384 | handler(magnet) |
| 294 | 385 | |
| 295 | 386 | |
| 387 | def 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 | ||
| 296 | 419 | def rd_auth(params): |
| 297 | 420 | rd_client = RealDebrid(token=str(get_setting("real_debrid_token", ""))) |
| 298 | 421 | run_realdebrid_auth(rd_client) |
| … | … | @@ -343,6 +466,21 @@ def tb_remove_auth(params): |
| 343 | 466 | torbox_client.remove_auth() |
| 344 | 467 | |
| 345 | 468 | |
| 469 | def oc_auth(params): | |
| 470 | offcloud_client = Offcloud(token=str(get_setting("offcloud_token") or "")) | |
| 471 | run_offcloud_auth(offcloud_client) | |
| 472 | ||
| 473 | ||
| 474 | def oc_remove_auth(params): | |
| 475 | offcloud_client = Offcloud(token=str(get_setting("offcloud_token") or "")) | |
| 476 | offcloud_client.remove_auth() | |
| 477 | ||
| 478 | ||
| 346 | 479 | def torbox_info(params): |
| 347 | 480 | finish_action() |
| 348 | 481 | DEBRID_INFO_HANDLERS[DebridType.TB]() |
| 482 | ||
| 483 | ||
| 484 | def offcloud_info(params): | |
| 485 | finish_action() | |
| 486 | DEBRID_INFO_HANDLERS[DebridType.OC]() |
| … | … | @@ -1,16 +1,20 @@ |
| 1 | from datetime import timedelta | |
| 2 | 1 | import os |
| 2 | from datetime import timedelta | |
| 3 | 3 | |
| 4 | 4 | from xbmcgui import Dialog |
| 5 | 5 | from xbmcplugin import addDirectoryItem, endOfDirectory |
| 6 | 6 | |
| 7 | 7 | from lib.db.cached import cache |
| 8 | 8 | from lib.utils.general.items_menus import history_menu_items, library_menu_items |
| 9 | from lib.utils.general.utils import build_list_item, clear_history_by_type, set_pluging_category | |
| 9 | from lib.utils.general.utils import ( | |
| 10 | build_list_item, | |
| 11 | clear_history_by_type, | |
| 12 | set_pluging_category, | |
| 13 | ) | |
| 10 | 14 | from lib.utils.kodi.settings import get_cache_expiration |
| 11 | 15 | from lib.utils.kodi.utils import ( |
| 12 | ADDON_PATH, | |
| 13 | 16 | ADDON_HANDLE, |
| 17 | ADDON_PATH, | |
| 14 | 18 | add_directory_items_batch, |
| 15 | 19 | apply_section_view, |
| 16 | 20 | build_url, |
| … | … | @@ -18,6 +22,7 @@ from lib.utils.kodi.utils import ( |
| 18 | 22 | end_of_directory, |
| 19 | 23 | finish_action, |
| 20 | 24 | kodi_play_media, |
| 25 | kodilog, | |
| 21 | 26 | make_list_item, |
| 22 | 27 | notification, |
| 23 | 28 | show_keyboard, |
| … | … | @@ -64,9 +69,7 @@ def _render_menu(items, cache_listing=True): |
| 64 | 69 | context_menu.append( |
| 65 | 70 | ( |
| 66 | 71 | label, |
| 67 | container_update( | |
| 68 | context_item["action"], **context_item.get("params", {}) | |
| 69 | ), | |
| 72 | container_update(context_item["action"], **context_item.get("params", {})), | |
| 70 | 73 | ) |
| 71 | 74 | ) |
| 72 | 75 | if context_menu: |
| … | … | @@ -127,8 +130,7 @@ def remove_from_library(params): |
| 127 | 130 | |
| 128 | 131 | |
| 129 | 132 | def 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 | |
| 132 | 134 | |
| 133 | 135 | kodilog(f"clear_library action invoked with params={params}") |
| 134 | 136 | |
| … | … | @@ -142,9 +144,10 @@ def clear_library(params): |
| 142 | 144 | |
| 143 | 145 | |
| 144 | 146 | def add_to_library(params): |
| 145 | from lib.utils.general.utils import add_to_library as add_lib | |
| 146 | 147 | import json |
| 147 | 148 | |
| 149 | from lib.utils.general.utils import add_to_library as add_lib | |
| 150 | ||
| 148 | 151 | data = params.get("data") |
| 149 | 152 | if data: |
| 150 | 153 | add_lib(json.loads(data)) |
| … | … | @@ -188,9 +191,7 @@ def search_direct(params): |
| 188 | 191 | |
| 189 | 192 | for item_mode, text in cache.get_list(key=mode): |
| 190 | 193 | 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")}) | |
| 194 | 195 | list_item.setProperty("IsPlayable", "true") |
| 195 | 196 | list_item.addContextMenuItems( |
| 196 | 197 | [ |
| … | … | @@ -1,16 +1,25 @@ |
| 1 | 1 | import json |
| 2 | 2 | import os |
| 3 | from lib.api.trakt.trakt import ProviderException, TraktAPI | |
| 4 | from lib.clients.trakt.trakt import TraktClient | |
| 5 | 3 | |
| 4 | import xbmcgui | |
| 5 | from xbmcgui import ListItem | |
| 6 | from xbmcplugin import ( | |
| 7 | addDirectoryItem, | |
| 8 | ) | |
| 9 | ||
| 10 | import lib.nav.debrid as debrid_navigation | |
| 11 | import lib.nav.library_history as library_history_navigation | |
| 12 | from lib.api.trakt.trakt import ProviderException, TraktAPI | |
| 6 | 13 | from lib.clients.stremio.catalog_menus import list_stremio_catalogs |
| 7 | 14 | from lib.clients.tmdb.tmdb import ( |
| 8 | 15 | TmdbClient, |
| 9 | 16 | ) |
| 10 | ||
| 17 | from lib.clients.trakt.trakt import TraktClient | |
| 18 | from lib.clients.youtube_resolver import ( | |
| 19 | extract_video_id, | |
| 20 | resolve_item_trailer, | |
| 21 | ) | |
| 11 | 22 | from lib.db.cached import RuntimeCache, cache |
| 12 | ||
| 13 | from lib.downloader import downloads_viewer | |
| 14 | 23 | from lib.gui.custom_dialogs import ( |
| 15 | 24 | CustomDialog, |
| 16 | 25 | download_dialog_mock, |
| … | … | @@ -18,87 +27,87 @@ from lib.gui.custom_dialogs import ( |
| 18 | 27 | run_next_mock, |
| 19 | 28 | source_select_mock, |
| 20 | 29 | ) |
| 21 | ||
| 22 | 30 | from lib.player import JacktookPLayer |
| 31 | from lib.updater import downgrade_addon_menu, updates_check_addon | |
| 32 | from 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 | ) | |
| 43 | from 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 | ) | |
| 52 | from lib.utils.general.utils import ( | |
| 53 | clear_database_cache as utils_clear_database_cache, | |
| 54 | ) | |
| 55 | from lib.utils.general.utils import ( | |
| 56 | clear_debrid_cache as utils_clear_debrid_cache, | |
| 57 | ) | |
| 58 | from lib.utils.general.utils import ( | |
| 59 | clear_mdblist_cache as utils_clear_mdblist_cache, | |
| 60 | ) | |
| 61 | from lib.utils.general.utils import ( | |
| 62 | clear_stremio_cache as utils_clear_stremio_cache, | |
| 63 | ) | |
| 64 | from lib.utils.general.utils import ( | |
| 65 | clear_tmdb_cache as utils_clear_tmdb_cache, | |
| 66 | ) | |
| 67 | from lib.utils.kodi.settings import addon_settings | |
| 68 | from lib.utils.kodi.settings_backup import ( | |
| 69 | export_settings_backup as kodi_export_settings_backup, | |
| 70 | ) | |
| 71 | from lib.utils.kodi.settings_backup import ( | |
| 72 | factory_reset_action as kodi_factory_reset_action, | |
| 73 | ) | |
| 74 | from lib.utils.kodi.settings_backup import ( | |
| 75 | reset_all_settings_action as kodi_reset_all_settings_action, | |
| 76 | ) | |
| 77 | from lib.utils.kodi.settings_backup import ( | |
| 78 | restore_settings_backup as kodi_restore_settings_backup, | |
| 79 | ) | |
| 23 | 80 | from lib.utils.kodi.utils import ( |
| 24 | 81 | ADDON_HANDLE, |
| 25 | 82 | ADDON_PATH, |
| 83 | ADDON_VERSION, | |
| 26 | 84 | CHANGELOG_PATH, |
| 27 | 85 | JACKTORR_ADDON, |
| 86 | action_url_run, | |
| 28 | 87 | add_directory_items_batch, |
| 29 | 88 | apply_section_view, |
| 30 | action_url_run, | |
| 31 | 89 | build_url, |
| 32 | 90 | burst_addon_settings, |
| 33 | 91 | capture_current_view_id, |
| 92 | clear_cached_settings, | |
| 34 | 93 | dialog_text, |
| 35 | 94 | end_of_directory, |
| 36 | 95 | execute_builtin, |
| 37 | 96 | finish_action, |
| 38 | clear_cached_settings, | |
| 39 | 97 | get_setting, |
| 40 | 98 | is_youtube_addon_enabled, |
| 41 | 99 | kodilog, |
| 100 | make_list_item, | |
| 42 | 101 | notification, |
| 43 | 102 | play_info_hash, |
| 44 | 103 | reset_saved_views, |
| 45 | 104 | save_view_id, |
| 105 | set_setting, | |
| 46 | 106 | translation, |
| 47 | make_list_item, | |
| 48 | 107 | ) |
| 49 | 108 | from lib.utils.player.utils import resolve_playback_url |
| 50 | 109 | from lib.utils.torrent.torrserver_init import get_torrserver_api |
| 51 | 110 | from lib.utils.torrentio.utils import open_providers_selection |
| 52 | from lib.utils.kodi.settings import addon_settings | |
| 53 | from 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 | ) | |
| 59 | from 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 | ) | |
| 73 | from lib.clients.youtube_resolver import ( | |
| 74 | extract_video_id, | |
| 75 | resolve_item_trailer, | |
| 76 | ) | |
| 77 | import lib.nav.debrid as debrid_navigation | |
| 78 | import lib.nav.library_history as library_history_navigation | |
| 79 | from 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 | ||
| 90 | from lib.updater import updates_check_addon, downgrade_addon_menu | |
| 91 | ||
| 92 | from xbmcgui import ListItem | |
| 93 | from xbmcplugin import ( | |
| 94 | addDirectoryItem, | |
| 95 | setResolvedUrl, | |
| 96 | ) | |
| 97 | ||
| 98 | ||
| 99 | from lib.utils.general.items_menus import ( | |
| 100 | root_menu_items, | |
| 101 | ) | |
| 102 | 111 | |
| 103 | 112 | |
| 104 | 113 | def _menu_condition_signature(items): |
| … | … | @@ -314,6 +323,12 @@ def settings_menu(params): |
| 314 | 323 | build_list_item(translation(90743), "settings.png"), |
| 315 | 324 | isFolder=True, |
| 316 | 325 | ) |
| 326 | addDirectoryItem( | |
| 327 | ADDON_HANDLE, | |
| 328 | build_url("donate"), | |
| 329 | build_list_item(translation(90815), "donate.png"), | |
| 330 | isFolder=False, | |
| 331 | ) | |
| 317 | 332 | end_of_directory(cache=False) |
| 318 | 333 | apply_section_view("view.main") |
| 319 | 334 | |
| … | … | @@ -368,6 +383,7 @@ def reset_views(params): |
| 368 | 383 | |
| 369 | 384 | |
| 370 | 385 | def root_menu(): |
| 386 | maybe_show_donation_prompt() | |
| 371 | 387 | set_pluging_category(translation(90069)) |
| 372 | 388 | render_menu( |
| 373 | 389 | root_menu_items, |
| … | … | @@ -377,6 +393,18 @@ def root_menu(): |
| 377 | 393 | apply_section_view("view.main") |
| 378 | 394 | |
| 379 | 395 | |
| 396 | def 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 | ||
| 380 | 408 | def animation_menu(params): |
| 381 | 409 | _render_cached_menu_entries( |
| 382 | 410 | _get_cached_menu_entries( |
| … | … | @@ -408,10 +436,26 @@ def telegram_menu(params): |
| 408 | 436 | set_pluging_category(translation(90013)) |
| 409 | 437 | add_directory_items_batch( |
| 410 | 438 | [ |
| 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 | ), | |
| 415 | 459 | ] |
| 416 | 460 | ) |
| 417 | 461 | end_of_directory() |
| … | … | @@ -528,17 +572,29 @@ def search_menu(params): |
| 528 | 572 | directory_items = [] |
| 529 | 573 | |
| 530 | 574 | 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 | ) | |
| 532 | 580 | ) |
| 533 | 581 | |
| 534 | 582 | # -- Direct Search -- |
| 535 | 583 | 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 | ) | |
| 537 | 589 | ) |
| 538 | 590 | |
| 539 | 591 | # -- Keyword Search -- |
| 540 | 592 | 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 | ) | |
| 542 | 598 | ) |
| 543 | 599 | |
| 544 | 600 | # -- Recent TMDb Searches -- |
| … | … | @@ -550,11 +606,15 @@ def search_menu(params): |
| 550 | 606 | |
| 551 | 607 | for _, text in tmdb_history[:5]: |
| 552 | 608 | 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")}) | |
| 556 | 610 | 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 | ) | |
| 558 | 618 | |
| 559 | 619 | # -- Recent Direct Searches -- |
| 560 | 620 | direct_history = cache.get_list(key="direct") |
| … | … | @@ -565,18 +625,20 @@ def search_menu(params): |
| 565 | 625 | |
| 566 | 626 | for mode, text in direct_history[:5]: |
| 567 | 627 | 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")}) | |
| 571 | 629 | 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 | ) | |
| 573 | 637 | |
| 574 | 638 | # -- Clear All Search History -- |
| 575 | 639 | if tmdb_history or direct_history: |
| 576 | 640 | 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")}) | |
| 580 | 642 | directory_items.append((build_url("clear_search_history"), clear_li, False)) |
| 581 | 643 | |
| 582 | 644 | add_directory_items_batch(directory_items) |
| … | … | @@ -714,24 +776,60 @@ def get_tb_downloads(params): |
| 714 | 776 | return debrid_navigation.get_tb_downloads(params) |
| 715 | 777 | |
| 716 | 778 | |
| 779 | def get_oc_downloads(params): | |
| 780 | return debrid_navigation.get_oc_downloads(params) | |
| 781 | ||
| 782 | ||
| 717 | 783 | def torrents(params): |
| 718 | 784 | if not JACKTORR_ADDON: |
| 719 | 785 | notification(translation(30253)) |
| 720 | 786 | end_of_directory(cache=False) |
| 721 | 787 | return |
| 722 | 788 | |
| 789 | set_pluging_category(translation(90012)) | |
| 790 | ||
| 723 | 791 | for torrent in get_torrserver_api().torrents(): |
| 724 | 792 | info_hash = torrent.get("hash") |
| 725 | 793 | |
| 726 | 794 | context_menu_items = [(translation(30700), play_info_hash(info_hash))] |
| 727 | 795 | |
| 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]: | |
| 729 | 829 | context_menu_items.append( |
| 730 | 830 | ( |
| 731 | 831 | 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"), | |
| 735 | 833 | ) |
| 736 | 834 | ) |
| 737 | 835 | |
| … | … | @@ -756,7 +854,7 @@ def torrents(params): |
| 756 | 854 | ] |
| 757 | 855 | ) |
| 758 | 856 | |
| 759 | torrent_li = build_list_item(torrent.get("title", ""), "download.png") | |
| 857 | torrent_li = build_list_item(torrent.get("title", ""), "magnet.png") | |
| 760 | 858 | torrent_li.addContextMenuItems(context_menu_items) |
| 761 | 859 | addDirectoryItem( |
| 762 | 860 | ADDON_HANDLE, |
| … | … | @@ -779,11 +877,69 @@ def play_media(params): |
| 779 | 877 | del player |
| 780 | 878 | |
| 781 | 879 | |
| 880 | def 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 | ||
| 782 | 936 | def play_url(params): |
| 783 | 937 | url = params.get("url") |
| 938 | from xbmc import Player | |
| 784 | 939 | list_item = ListItem(label=params.get("name"), path=url) |
| 785 | 940 | list_item.setPath(url) |
| 786 | setResolvedUrl(ADDON_HANDLE, True, list_item) | |
| 941 | list_item.setProperty("IsPlayable", "true") | |
| 942 | Player().play(url, list_item) | |
| 787 | 943 | |
| 788 | 944 | |
| 789 | 945 | def play_trailer(params): |
| … | … | @@ -839,7 +995,8 @@ def play_trailer(params): |
| 839 | 995 | list_item.setProperty("IsPlayable", "true") |
| 840 | 996 | if playback.get("source_type") == "adaptive" and playback.get("audio_url"): |
| 841 | 997 | 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) | |
| 843 | 1000 | |
| 844 | 1001 | |
| 845 | 1002 | def play_from_pack(params): |
| … | … | @@ -847,8 +1004,10 @@ def play_from_pack(params): |
| 847 | 1004 | data = resolve_playback_url(data) |
| 848 | 1005 | if not data: |
| 849 | 1006 | 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 | |
| 852 | 1011 | |
| 853 | 1012 | |
| 854 | 1013 | def people_menu(mode): |
| … | … | @@ -1015,7 +1174,29 @@ def download(magnet, type): |
| 1015 | 1174 | |
| 1016 | 1175 | |
| 1017 | 1176 | def 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 | ||
| 1194 | def 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 | |
| 1019 | 1200 | |
| 1020 | 1201 | |
| 1021 | 1202 | def addon_update(params): |
| … | … | @@ -1027,7 +1208,7 @@ def downgrade_addon(params): |
| 1027 | 1208 | |
| 1028 | 1209 | |
| 1029 | 1210 | def show_changelog(params): |
| 1030 | dialog_text(translation(90577), file=CHANGELOG_PATH) | |
| 1211 | dialog_text(translation(90577), file=CHANGELOG_PATH) | |
| 1031 | 1212 | |
| 1032 | 1213 | |
| 1033 | 1214 | def donate(params): |
| … | … | @@ -1041,6 +1222,7 @@ def donate(params): |
| 1041 | 1222 | ADDON_PATH, |
| 1042 | 1223 | heading=translation(90023), |
| 1043 | 1224 | text=translation(90022), |
| 1225 | call_to_action=translation(90817), | |
| 1044 | 1226 | url=f"[COLOR snow]{donation_url}[/COLOR]", |
| 1045 | 1227 | qrcode=qr_code, |
| 1046 | 1228 | ) |
| … | … | @@ -1170,10 +1352,22 @@ def tb_remove_auth(params): |
| 1170 | 1352 | return debrid_navigation.tb_remove_auth(params) |
| 1171 | 1353 | |
| 1172 | 1354 | |
| 1355 | def oc_auth(params): | |
| 1356 | return debrid_navigation.oc_auth(params) | |
| 1357 | ||
| 1358 | ||
| 1359 | def oc_remove_auth(params): | |
| 1360 | return debrid_navigation.oc_remove_auth(params) | |
| 1361 | ||
| 1362 | ||
| 1173 | 1363 | def torbox_info(params): |
| 1174 | 1364 | return debrid_navigation.torbox_info(params) |
| 1175 | 1365 | |
| 1176 | 1366 | |
| 1367 | def offcloud_info(params): | |
| 1368 | return debrid_navigation.offcloud_info(params) | |
| 1369 | ||
| 1370 | ||
| 1177 | 1371 | def open_burst_config(params): |
| 1178 | 1372 | burst_addon_settings() |
| 1179 | 1373 |
| … | … | @@ -1,8 +1,23 @@ |
| 1 | from datetime import date | |
| 2 | from json import dumps as json_dumps | |
| 3 | from json import loads | |
| 1 | 4 | from threading import Thread |
| 5 | from typing import Optional | |
| 6 | ||
| 7 | import xbmc | |
| 8 | from xbmc import getCondVisibility as get_visibility | |
| 9 | from xbmcgui import Dialog, ListItem | |
| 10 | from xbmcplugin import setResolvedUrl | |
| 11 | ||
| 12 | from lib.api.trakt.trakt import TraktAPI, TraktLists | |
| 2 | 13 | from lib.api.trakt.trakt_utils import is_trakt_auth |
| 3 | 14 | from lib.clients.subtitle.utils import get_language_code |
| 4 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 5 | from lib.api.trakt.trakt import TraktAPI, TraktLists | |
| 15 | from lib.clients.tmdb.utils.utils import get_movie_keywords, tmdb_get | |
| 16 | from lib.db.cached import cache | |
| 17 | from lib.utils.general.utils import ( | |
| 18 | make_listing, | |
| 19 | set_watched_file, | |
| 20 | ) | |
| 6 | 21 | from lib.utils.kodi.utils import ( |
| 7 | 22 | ADDON_HANDLE, |
| 8 | 23 | PLAYLIST, |
| … | … | @@ -12,6 +27,7 @@ from lib.utils.kodi.utils import ( |
| 12 | 27 | close_all_dialog, |
| 13 | 28 | close_busy_dialog, |
| 14 | 29 | execute_builtin, |
| 30 | get_property, | |
| 15 | 31 | get_setting, |
| 16 | 32 | kodilog, |
| 17 | 33 | notification, |
| … | … | @@ -19,24 +35,20 @@ from lib.utils.kodi.utils import ( |
| 19 | 35 | sleep, |
| 20 | 36 | translation, |
| 21 | 37 | ) |
| 22 | from lib.utils.general.utils import ( | |
| 23 | make_listing, | |
| 24 | set_watched_file, | |
| 38 | from lib.utils.player.utils import ( | |
| 39 | autoscrape_next_episode, | |
| 40 | get_autoscrape_cache_key, | |
| 41 | get_autoscrape_results_cache_key, | |
| 25 | 42 | ) |
| 26 | from lib.utils.player.utils import precache_next_episodes | |
| 27 | ||
| 28 | import xbmc | |
| 29 | from xbmc import getCondVisibility as get_visibility | |
| 30 | from xbmcgui import ListItem, Dialog | |
| 31 | from xbmcplugin import setResolvedUrl | |
| 32 | ||
| 33 | from json import dumps as json_dumps, loads | |
| 34 | 43 | |
| 44 | AUTOPLAY_CONTEXT_NEXT_EPISODE = 1 | |
| 35 | 45 | total_time_errors = ("0.0", "", 0.0, None) |
| 36 | 46 | video_fullscreen_check = "Window.IsActive(fullscreenvideo)" |
| 37 | 47 | |
| 38 | 48 | |
| 39 | 49 | class JacktookPLayer(xbmc.Player): |
| 50 | _nextep_queue: list = [] | |
| 51 | ||
| 40 | 52 | def __init__(self, on_started=None, on_error=None): |
| 41 | 53 | xbmc.Player.__init__(self) |
| 42 | 54 | self.url = None |
| … | … | @@ -57,26 +69,81 @@ class JacktookPLayer(xbmc.Player): |
| 57 | 69 | self._is_trakt_scrobble_active = False |
| 58 | 70 | self._playback_was_paused = False |
| 59 | 71 | |
| 60 | def run(self, data={}): | |
| 72 | def run(self, data=None): | |
| 73 | if data is None: | |
| 74 | data = {} | |
| 61 | 75 | self.set_constants(data) |
| 62 | 76 | self.clear_playback_properties() |
| 63 | 77 | self.add_external_trakt_scrolling() |
| 64 | 78 | self.mark_watched(data) |
| 65 | 79 | |
| 66 | precaching_thread = Thread(target=precache_next_episodes, args=(self.data,)) | |
| 67 | precaching_thread.start() | |
| 68 | ||
| 69 | 80 | 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 | ) | |
| 70 | 85 | |
| 71 | 86 | try: |
| 72 | 87 | self.list_item = make_listing(data) |
| 73 | 88 | self.PLAYLIST.add(self.url, self.list_item) |
| 74 | if self.data["mode"] == "tv": | |
| 75 | self.build_playlist() | |
| 76 | 89 | self.play_video(self.list_item) |
| 77 | 90 | except Exception as e: |
| 78 | 91 | self.run_error(e) |
| 79 | 92 | |
| 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 | ||
| 80 | 147 | def play_video(self, list_item): |
| 81 | 148 | close_busy_dialog() |
| 82 | 149 | |
| … | … | @@ -87,17 +154,31 @@ class JacktookPLayer(xbmc.Player): |
| 87 | 154 | try: |
| 88 | 155 | self._handle_trakt_scrobble(list_item) |
| 89 | 156 | 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). | |
| 90 | 159 | 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) | |
| 91 | 173 | self.monitor() |
| 174 | kodilog("[PLAYER] play_video: monitor() returned") | |
| 92 | 175 | except Exception as e: |
| 93 | 176 | kodilog(f"Error during play_video: {e}") |
| 94 | 177 | self.run_error(e) |
| 95 | 178 | |
| 96 | 179 | def _check_volume(self): |
| 97 | 180 | 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"): | |
| 101 | 182 | return True |
| 102 | 183 | |
| 103 | 184 | threshold = int(get_setting("volume_check_threshold", 50)) |
| … | … | @@ -126,14 +207,8 @@ class JacktookPLayer(xbmc.Player): |
| 126 | 207 | return True |
| 127 | 208 | |
| 128 | 209 | 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) | |
| 137 | 212 | if last_position > 0: |
| 138 | 213 | list_item.setProperty("StartPercent", str(last_position)) |
| 139 | 214 | TraktAPI().scrobble.trakt_start_scrobble(self.data) |
| … | … | @@ -163,15 +238,18 @@ class JacktookPLayer(xbmc.Player): |
| 163 | 238 | |
| 164 | 239 | def monitor(self): |
| 165 | 240 | ensure_dialog_closed = False |
| 241 | kodilog("[PLAYER] monitor() entered") | |
| 166 | 242 | |
| 167 | 243 | try: |
| 168 | 244 | while not self.isPlayingVideo(): |
| 169 | 245 | if self.kodi_monitor.abortRequested(): |
| 246 | kodilog("[PLAYER] monitor: abort requested while waiting for video to start") | |
| 170 | 247 | return |
| 171 | 248 | if get_visibility("Window.IsTopMost(okdialog)"): |
| 172 | 249 | execute_builtin("SendClick(okdialog, 11)") |
| 173 | 250 | return |
| 174 | 251 | sleep(100) |
| 252 | kodilog("[PLAYER] monitor: video started playing, entering main loop") | |
| 175 | 253 | |
| 176 | 254 | # Once playing, initialize streams and subtitles |
| 177 | 255 | self.select_audio_stream() |
| … | … | @@ -184,31 +262,51 @@ class JacktookPLayer(xbmc.Player): |
| 184 | 262 | introdb_thread.daemon = True |
| 185 | 263 | introdb_thread.start() |
| 186 | 264 | |
| 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 | ||
| 187 | 271 | # Monitor loop |
| 188 | 272 | while self.isPlayingVideo(): |
| 189 | 273 | self.update_playback_progress() |
| 190 | 274 | self.handle_trakt_pause_resume() |
| 275 | self.check_autoscrape_threshold() | |
| 191 | 276 | |
| 192 | 277 | if not ensure_dialog_closed: |
| 193 | 278 | ensure_dialog_closed = True |
| 194 | 279 | self.playback_close_dialogs() |
| 195 | 280 | |
| 196 | self.check_skip_intro() | |
| 197 | 281 | 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 | ||
| 198 | 289 | sleep(1000) |
| 199 | 290 | |
| 200 | 291 | self.handle_playback_stop() |
| 292 | kodilog("[PLAYER] monitor: main loop exited, handle_playback_stop completed") | |
| 201 | 293 | |
| 202 | except Exception as e: | |
| 294 | except Exception: | |
| 203 | 295 | self.kill_dialog() |
| 204 | 296 | finally: |
| 205 | self._cleanup_playback_session() | |
| 297 | kodilog("[PLAYER] monitor: finally block - clearing playback properties") | |
| 206 | 298 | self.clear_playback_properties() |
| 299 | kodilog("[PLAYER] monitor: exiting") | |
| 207 | 300 | |
| 208 | 301 | 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 | ||
| 209 | 307 | self.subtitles_found = False |
| 210 | 308 | 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"): | |
| 212 | 310 | list_item.setSubtitles(subtitles_path) |
| 213 | 311 | self.setSubtitleStream(0) |
| 214 | 312 | self.subtitles_found = True |
| … | … | @@ -216,8 +314,9 @@ class JacktookPLayer(xbmc.Player): |
| 216 | 314 | try: |
| 217 | 315 | from lib.clients.subtitle.submanager import SubtitleManager |
| 218 | 316 | |
| 317 | auto_select = get_setting("auto_subtitle_selection", False) | |
| 219 | 318 | 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) | |
| 221 | 320 | if not subs_paths: |
| 222 | 321 | kodilog("No subtitles found, skipping subtitle loading") |
| 223 | 322 | self.subtitles_found = False |
| … | … | @@ -241,23 +340,30 @@ class JacktookPLayer(xbmc.Player): |
| 241 | 340 | """ |
| 242 | 341 | auto_select_enabled = get_setting("auto_subtitle_selection") |
| 243 | 342 | stremio_subtitle_enabled = get_setting("stremio_subtitle_enabled") |
| 244 | search_subtitles = get_setting("search_subtitles") | |
| 245 | ||
| 343 | search_subtitles = get_property("search_subtitles") | |
| 246 | 344 | if self.subtitles_found or stremio_subtitle_enabled or search_subtitles: |
| 247 | 345 | self.showSubtitles(True) |
| 248 | 346 | if self.subtitles_found: |
| 249 | 347 | 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 | |
| 252 | 361 | |
| 253 | 362 | if auto_select_enabled: |
| 254 | 363 | _, _, subtitles = self.get_player_streams() |
| 255 | 364 | kodilog(f"Available subtitles: {subtitles}", level=xbmc.LOGDEBUG) |
| 256 | 365 | 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: | |
| 261 | 367 | self.setSubtitleStream(sub["index"]) |
| 262 | 368 | self.showSubtitles(True) |
| 263 | 369 | break |
| … | … | @@ -272,11 +378,7 @@ class JacktookPLayer(xbmc.Player): |
| 272 | 378 | """ |
| 273 | 379 | auto_audio_enabled = get_setting("auto_audio") |
| 274 | 380 | 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": | |
| 280 | 382 | sleep(500) |
| 281 | 383 | audio_streams = self.getAvailableAudioStreams() |
| 282 | 384 | if audio_streams or len(audio_streams) > 0: |
| … | … | @@ -294,11 +396,12 @@ class JacktookPLayer(xbmc.Player): |
| 294 | 396 | |
| 295 | 397 | def handle_playback_start(self): |
| 296 | 398 | 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 | |
| 299 | 403 | ): |
| 300 | if self.on_started: | |
| 301 | self.on_started() | |
| 404 | self.on_started() | |
| 302 | 405 | except Exception as e: |
| 303 | 406 | kodilog(f"Error in handle_playback_start: {e}") |
| 304 | 407 | |
| … | … | @@ -307,116 +410,478 @@ class JacktookPLayer(xbmc.Player): |
| 307 | 410 | self.total_time = self.getTotalTime() |
| 308 | 411 | self.current_time = self.getTime() |
| 309 | 412 | 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) | |
| 313 | 414 | self.data["progress"] = self.watched_percentage |
| 314 | 415 | self.data["current_time"] = self.current_time |
| 315 | 416 | self.data["total_time"] = self.total_time |
| 316 | 417 | except Exception as e: |
| 317 | 418 | kodilog(f"Error updating playback progress: {e}") |
| 318 | 419 | |
| 319 | def check_next_dialog(self): | |
| 420 | def check_autoscrape_threshold(self): | |
| 320 | 421 | try: |
| 321 | 422 | if not getattr(self, "total_time", None) or self.total_time < 60: |
| 322 | 423 | return |
| 323 | 424 | if not getattr(self, "current_time", None): |
| 324 | 425 | 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 | |
| 325 | 432 | |
| 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 | |
| 327 | 459 | if not getattr(self, "playback_started_properly", False): |
| 328 | 460 | if self.current_time < 10: |
| 329 | 461 | self.playback_started_properly = True |
| 330 | 462 | else: |
| 331 | 463 | return |
| 332 | ||
| 333 | 464 | if self.current_time < (self.total_time * 0.5): |
| 334 | # Don't trigger in the first 50% of playback as a safety measure | |
| 335 | 465 | return |
| 336 | 466 | |
| 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() | |
| 343 | 476 | except Exception as e: |
| 344 | 477 | kodilog(f"Error in check_next_dialog: {e}") |
| 345 | 478 | |
| 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 | |
| 350 | 535 | |
| 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 | |
| 353 | 558 | |
| 354 | close_busy_dialog() | |
| 559 | set_property("jacktook_consecutive_autoplays", str(count)) | |
| 560 | return False | |
| 355 | 561 | |
| 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(): | |
| 358 | 635 | return |
| 359 | 636 | |
| 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") | |
| 362 | 640 | return |
| 363 | 641 | |
| 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 {} | |
| 364 | 684 | tmdb_id = ids.get("tmdb_id") |
| 365 | 685 | if not tmdb_id: |
| 366 | return | |
| 686 | return None | |
| 367 | 687 | |
| 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 {} | |
| 370 | 689 | season = tv_data.get("season") |
| 371 | 690 | episode = tv_data.get("episode") |
| 372 | ||
| 373 | 691 | if season is None or episode is None: |
| 374 | return | |
| 692 | return None | |
| 375 | 693 | |
| 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 | |
| 377 | 701 | |
| 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}) | |
| 378 | 705 | 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 | |
| 380 | 709 | |
| 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. | |
| 384 | 714 | |
| 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 | |
| 387 | 724 | |
| 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 | |
| 393 | 735 | } |
| 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 | |
| 394 | 824 | |
| 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: | |
| 395 | 860 | url = build_url( |
| 396 | 861 | "search", |
| 397 | 862 | mode=self.data["mode"], |
| 398 | query=getattr(details, "name", ""), | |
| 863 | query=query, | |
| 399 | 864 | ids=ids, |
| 400 | 865 | tv_data=next_tv_data, |
| 401 | 866 | rescrape=True, |
| 402 | 867 | preferred_group=self.preferred_group, |
| 868 | autoplay_context=str(AUTOPLAY_CONTEXT_NEXT_EPISODE), | |
| 869 | skip_cancel_on_back=True, | |
| 403 | 870 | ) |
| 404 | 871 | |
| 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 | |
| 411 | 877 | |
| 412 | if is_in_playlist: | |
| 413 | continue | |
| 878 | kodilog(f"[PLAYNEXT] build_playlist: adding to PLAYLIST label={label}, url={url}") | |
| 414 | 879 | |
| 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") | |
| 418 | 883 | |
| 419 | self.PLAYLIST.add(url=url, listitem=list_item) | |
| 884 | self.PLAYLIST.add(url=url, listitem=list_item) | |
| 420 | 885 | |
| 421 | 886 | def kill_dialog(self): |
| 422 | 887 | close_all_dialog() |
| … | … | @@ -426,12 +891,15 @@ class JacktookPLayer(xbmc.Player): |
| 426 | 891 | |
| 427 | 892 | def set_constants(self, data): |
| 428 | 893 | self.PLAYLIST.clear() |
| 894 | if not (data.get("playnext_context") or data.get("autoplay")): | |
| 895 | clear_property("jacktook_consecutive_autoplays") | |
| 429 | 896 | self.data = data |
| 430 | 897 | self.url = self.data["url"] |
| 431 | 898 | self.watched_percentage = self.data.get("progress", 0.0) |
| 432 | 899 | self.total_time = 0 |
| 433 | 900 | self.current_time = 0 |
| 434 | 901 | self.playback_started_properly = False |
| 902 | self.autoscrape_started = False | |
| 435 | 903 | self.next_dialog = get_setting("playnext_dialog_enabled") |
| 436 | 904 | self._is_trakt_scrobble_active = False |
| 437 | 905 | self._playback_was_paused = False |
| … | … | @@ -445,6 +913,11 @@ class JacktookPLayer(xbmc.Player): |
| 445 | 913 | self.skip_intro_segments = None |
| 446 | 914 | self.skip_intro_handled = {"intro": False, "recap": False} |
| 447 | 915 | |
| 916 | # Stinger notification state | |
| 917 | self.stinger_notified = False | |
| 918 | self.stinger_keywords = [] | |
| 919 | self.has_stinger = False | |
| 920 | ||
| 448 | 921 | def fetch_introdb_segments(self): |
| 449 | 922 | """Fetch segment data from IntroDB in a background thread.""" |
| 450 | 923 | try: |
| … | … | @@ -492,9 +965,7 @@ class JacktookPLayer(xbmc.Player): |
| 492 | 965 | if self.skip_intro_auto: |
| 493 | 966 | self.seekTime(end_sec) |
| 494 | 967 | 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" | |
| 498 | 969 | xbmc.executebuiltin( |
| 499 | 970 | action_url_run( |
| 500 | 971 | name="run_skip_intro_dialog", |
| … | … | @@ -507,6 +978,49 @@ class JacktookPLayer(xbmc.Player): |
| 507 | 978 | except Exception as e: |
| 508 | 979 | kodilog(f"Error in check_skip_intro: {e}") |
| 509 | 980 | |
| 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 | ||
| 510 | 1024 | def clear_playback_properties(self): |
| 511 | 1025 | clear_property("script.trakt.ids") |
| 512 | 1026 | |
| … | … | @@ -526,9 +1040,7 @@ class JacktookPLayer(xbmc.Player): |
| 526 | 1040 | set_property("script.trakt.ids", json_dumps(trakt_ids)) |
| 527 | 1041 | |
| 528 | 1042 | 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}' | |
| 532 | 1044 | json_query = xbmc.executeJSONRPC(activePlayers) |
| 533 | 1045 | json_response = loads(json_query) |
| 534 | 1046 | if not json_response.get("result"): |
| … | … | @@ -573,6 +1085,7 @@ class JacktookPLayer(xbmc.Player): |
| 573 | 1085 | set_watched_file(data) |
| 574 | 1086 | |
| 575 | 1087 | def cancel_playback(self): |
| 1088 | kodilog("[PLAYER] cancel_playback called") | |
| 576 | 1089 | self._cleanup_playback_session() |
| 577 | 1090 | try: |
| 578 | 1091 | setResolvedUrl(ADDON_HANDLE, False, ListItem(offscreen=True)) |
| … | … | @@ -580,8 +1093,10 @@ class JacktookPLayer(xbmc.Player): |
| 580 | 1093 | kodilog( |
| 581 | 1094 | f"setResolvedUrl failed in cancel_playback (expected when using direct play): {e}" |
| 582 | 1095 | ) |
| 1096 | self.stop() | |
| 583 | 1097 | |
| 584 | 1098 | def _cleanup_playback_session(self): |
| 1099 | kodilog("[PLAYER] _cleanup_playback_session: clearing PLAYLIST, closing dialogs") | |
| 585 | 1100 | self.PLAYLIST.clear() |
| 586 | 1101 | close_busy_dialog() |
| 587 | 1102 | close_all_dialog() |
| … | … | @@ -1,4 +1,3 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | 1 | """ |
| 3 | 2 | Jacktook addon router — lazy-loading dispatcher. |
| 4 | 3 | |
| … | … | @@ -6,10 +5,10 @@ Uses prefix-based routing with lazy imports to avoid loading all addon |
| 6 | 5 | modules on every invocation. Only the modules needed for the current |
| 7 | 6 | action are imported. |
| 8 | 7 | """ |
| 8 | ||
| 9 | 9 | import sys |
| 10 | 10 | from urllib import parse |
| 11 | 11 | |
| 12 | ||
| 13 | 12 | # --------------------------------------------------------------------------- |
| 14 | 13 | # Action sets for set-based lookups |
| 15 | 14 | # --------------------------------------------------------------------------- |
| … | … | @@ -35,13 +34,17 @@ _DEBRID_ACTIONS = frozenset( |
| 35 | 34 | "debrider_info", |
| 36 | 35 | "get_rd_downloads", |
| 37 | 36 | "get_tb_downloads", |
| 37 | "get_oc_downloads", | |
| 38 | 38 | "cloud", |
| 39 | 39 | "cloud_details", |
| 40 | 40 | "pm_auth", |
| 41 | 41 | "pm_remove_auth", |
| 42 | 42 | "tb_auth", |
| 43 | 43 | "tb_remove_auth", |
| 44 | "oc_auth", | |
| 45 | "oc_remove_auth", | |
| 44 | 46 | "torbox_info", |
| 47 | "offcloud_info", | |
| 45 | 48 | "easynews_info", |
| 46 | 49 | } |
| 47 | 50 | ) |
| … | … | @@ -54,6 +57,8 @@ _TORRSERVER_ACTIONS = frozenset( |
| 54 | 57 | "torrentio_selection", |
| 55 | 58 | "display_picture", |
| 56 | 59 | "display_text", |
| 60 | "download_torrent_subtitles", | |
| 61 | "download_and_play_subtitles", | |
| 57 | 62 | } |
| 58 | 63 | ) |
| 59 | 64 | |
| … | … | @@ -71,8 +76,13 @@ _DOWNLOAD_ACTIONS = frozenset( |
| 71 | 76 | "download_video", |
| 72 | 77 | "handle_download_file", |
| 73 | 78 | "handle_cancel_download", |
| 79 | "handle_pause_download", | |
| 74 | 80 | "handle_delete_file", |
| 75 | 81 | "downloads_menu", |
| 82 | "resume_download", | |
| 83 | "download_cloud_file", | |
| 84 | "open_download_manager", | |
| 85 | "downloads_viewer", | |
| 76 | 86 | } |
| 77 | 87 | ) |
| 78 | 88 | |
| … | … | @@ -106,13 +116,18 @@ def _is_stremio_action(action): |
| 106 | 116 | "search_catalog", |
| 107 | 117 | "add_custom_stremio_addon", |
| 108 | 118 | "remove_custom_stremio_addon", |
| 119 | "rename_stremio_addon", | |
| 120 | "rename_stremio_catalog", | |
| 109 | 121 | "torrentio_toggle_providers", |
| 110 | 122 | "stremio_bypass_addons_select", |
| 111 | 123 | } |
| 112 | 124 | |
| 113 | 125 | |
| 114 | 126 | def _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( | |
| 116 | 131 | ( |
| 117 | 132 | "handle_tmdb", |
| 118 | 133 | "search_tmdb", |
| … | … | @@ -163,6 +178,10 @@ def _is_gui_action(action): |
| 163 | 178 | return action in _GUI_ACTIONS |
| 164 | 179 | |
| 165 | 180 | |
| 181 | def _is_source_manager_action(action): | |
| 182 | return action == "source_manager_toggle" | |
| 183 | ||
| 184 | ||
| 166 | 185 | def _get_route_handler(action): |
| 167 | 186 | for matcher, handler in ROUTE_GROUPS: |
| 168 | 187 | if matcher(action): |
| … | … | @@ -208,16 +227,20 @@ def _route_stremio(action, params): |
| 208 | 227 | "stremio_filtered_selection", |
| 209 | 228 | "add_custom_stremio_addon", |
| 210 | 229 | "remove_custom_stremio_addon", |
| 230 | "rename_stremio_addon", | |
| 231 | "rename_stremio_catalog", | |
| 211 | 232 | "stremio_bypass_addons_select", |
| 212 | 233 | ): |
| 213 | 234 | from lib.clients.stremio.addon_selection import ( |
| 214 | stremio_toggle_addons, | |
| 215 | stremio_toggle_catalogs, | |
| 216 | stremio_toggle_tv_addons, | |
| 217 | stremio_filtered_selection, | |
| 218 | 235 | add_custom_stremio_addon, |
| 219 | 236 | remove_custom_stremio_addon, |
| 237 | rename_stremio_addon, | |
| 238 | rename_stremio_catalog, | |
| 220 | 239 | stremio_bypass_addons_select, |
| 240 | stremio_filtered_selection, | |
| 241 | stremio_toggle_addons, | |
| 242 | stremio_toggle_catalogs, | |
| 243 | stremio_toggle_tv_addons, | |
| 221 | 244 | ) |
| 222 | 245 | |
| 223 | 246 | actions = { |
| … | … | @@ -227,6 +250,8 @@ def _route_stremio(action, params): |
| 227 | 250 | "stremio_filtered_selection": stremio_filtered_selection, |
| 228 | 251 | "add_custom_stremio_addon": add_custom_stremio_addon, |
| 229 | 252 | "remove_custom_stremio_addon": remove_custom_stremio_addon, |
| 253 | "rename_stremio_addon": rename_stremio_addon, | |
| 254 | "rename_stremio_catalog": rename_stremio_catalog, | |
| 230 | 255 | "stremio_bypass_addons_select": stremio_bypass_addons_select, |
| 231 | 256 | } |
| 232 | 257 | actions[action](params) |
| … | … | @@ -377,11 +402,11 @@ def _route_trakt(action, params): |
| 377 | 402 | actions[action](params) |
| 378 | 403 | else: |
| 379 | 404 | from lib.navigation import ( |
| 405 | list_trakt_page, | |
| 380 | 406 | trakt_auth, |
| 381 | 407 | trakt_auth_revoke, |
| 382 | 408 | trakt_group_menu, |
| 383 | 409 | trakt_list_content, |
| 384 | list_trakt_page, | |
| 385 | 410 | ) |
| 386 | 411 | |
| 387 | 412 | actions = { |
| … | … | @@ -398,25 +423,29 @@ def _route_trakt(action, params): |
| 398 | 423 | |
| 399 | 424 | def _route_debrid(action, params): |
| 400 | 425 | from lib.navigation import ( |
| 401 | rd_auth, | |
| 402 | rd_remove_auth, | |
| 403 | 426 | ad_auth, |
| 404 | 427 | ad_remove_auth, |
| 405 | real_debrid_info, | |
| 406 | 428 | alldebrid_info, |
| 429 | cloud, | |
| 430 | cloud_details, | |
| 407 | 431 | debrider_auth, |
| 408 | debrider_remove_auth, | |
| 409 | 432 | debrider_info, |
| 433 | debrider_remove_auth, | |
| 434 | easynews_info, | |
| 435 | get_oc_downloads, | |
| 410 | 436 | get_rd_downloads, |
| 411 | 437 | get_tb_downloads, |
| 412 | cloud, | |
| 413 | cloud_details, | |
| 438 | offcloud_info, | |
| 439 | oc_auth, | |
| 440 | oc_remove_auth, | |
| 414 | 441 | pm_auth, |
| 415 | 442 | pm_remove_auth, |
| 443 | rd_auth, | |
| 444 | rd_remove_auth, | |
| 445 | real_debrid_info, | |
| 416 | 446 | tb_auth, |
| 417 | 447 | tb_remove_auth, |
| 418 | 448 | torbox_info, |
| 419 | easynews_info, | |
| 420 | 449 | ) |
| 421 | 450 | |
| 422 | 451 | actions = { |
| … | … | @@ -431,13 +460,17 @@ def _route_debrid(action, params): |
| 431 | 460 | "debrider_info": debrider_info, |
| 432 | 461 | "get_rd_downloads": get_rd_downloads, |
| 433 | 462 | "get_tb_downloads": get_tb_downloads, |
| 463 | "get_oc_downloads": get_oc_downloads, | |
| 434 | 464 | "cloud": cloud, |
| 435 | 465 | "cloud_details": cloud_details, |
| 436 | 466 | "pm_auth": pm_auth, |
| 437 | 467 | "pm_remove_auth": pm_remove_auth, |
| 438 | 468 | "tb_auth": tb_auth, |
| 439 | 469 | "tb_remove_auth": tb_remove_auth, |
| 470 | "oc_auth": oc_auth, | |
| 471 | "oc_remove_auth": oc_remove_auth, | |
| 440 | 472 | "torbox_info": torbox_info, |
| 473 | "offcloud_info": offcloud_info, | |
| 441 | 474 | "easynews_info": easynews_info, |
| 442 | 475 | } |
| 443 | 476 | actions[action](params) |
| … | … | @@ -450,9 +483,9 @@ def _route_telegram(action, params): |
| 450 | 483 | telegram_menu(params) |
| 451 | 484 | else: |
| 452 | 485 | from lib.clients.jackgram.utils import ( |
| 453 | list_jackgram_raw_files, | |
| 454 | 486 | list_jackgram_latest_movies, |
| 455 | 487 | list_jackgram_latest_series, |
| 488 | list_jackgram_raw_files, | |
| 456 | 489 | list_jackgram_title_sources, |
| 457 | 490 | ) |
| 458 | 491 | |
| … | … | @@ -470,10 +503,10 @@ def _route_telegram(action, params): |
| 470 | 503 | def _route_torrserver(action, params): |
| 471 | 504 | if action in ("torrent_action", "torrent_files", "display_picture", "display_text"): |
| 472 | 505 | from lib.utils.torrent.torrserver_utils import ( |
| 473 | torrent_action, | |
| 474 | torrent_files, | |
| 475 | 506 | display_picture, |
| 476 | 507 | display_text, |
| 508 | torrent_action, | |
| 509 | torrent_files, | |
| 477 | 510 | ) |
| 478 | 511 | |
| 479 | 512 | actions = { |
| … | … | @@ -483,6 +516,17 @@ def _route_torrserver(action, params): |
| 483 | 516 | "display_text": display_text, |
| 484 | 517 | } |
| 485 | 518 | 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) | |
| 486 | 530 | elif action == "torrents": |
| 487 | 531 | from lib.navigation import torrents |
| 488 | 532 | |
| … | … | @@ -495,10 +539,10 @@ def _route_torrserver(action, params): |
| 495 | 539 | |
| 496 | 540 | def _route_webdav(action, params): |
| 497 | 541 | from lib.clients.webdav.client import ( |
| 498 | list_webdav, | |
| 499 | webdav_provider_test, | |
| 500 | 542 | display_text_webdav, |
| 543 | list_webdav, | |
| 501 | 544 | show_picture_webdav, |
| 545 | webdav_provider_test, | |
| 502 | 546 | ) |
| 503 | 547 | |
| 504 | 548 | actions = { |
| … | … | @@ -515,26 +559,42 @@ def _route_downloads(action, params): |
| 515 | 559 | "download_video", |
| 516 | 560 | "handle_download_file", |
| 517 | 561 | "handle_cancel_download", |
| 562 | "handle_pause_download", | |
| 518 | 563 | "handle_delete_file", |
| 564 | "resume_download", | |
| 565 | "download_cloud_file", | |
| 566 | "downloads_viewer", | |
| 519 | 567 | ): |
| 520 | 568 | from lib.downloader import ( |
| 569 | download_cloud_file, | |
| 521 | 570 | download_video, |
| 522 | handle_download_file, | |
| 571 | downloads_viewer, | |
| 523 | 572 | handle_cancel_download, |
| 524 | 573 | handle_delete_file, |
| 574 | handle_download_file, | |
| 575 | handle_pause_download, | |
| 576 | resume_download, | |
| 525 | 577 | ) |
| 526 | 578 | |
| 527 | 579 | actions = { |
| 528 | 580 | "download_video": download_video, |
| 529 | 581 | "handle_download_file": handle_download_file, |
| 530 | 582 | "handle_cancel_download": handle_cancel_download, |
| 583 | "handle_pause_download": handle_pause_download, | |
| 531 | 584 | "handle_delete_file": handle_delete_file, |
| 585 | "resume_download": resume_download, | |
| 586 | "download_cloud_file": download_cloud_file, | |
| 587 | "downloads_viewer": downloads_viewer, | |
| 532 | 588 | } |
| 533 | 589 | actions[action](params) |
| 534 | 590 | elif action == "downloads_menu": |
| 535 | 591 | from lib.navigation import downloads_menu |
| 536 | 592 | |
| 537 | 593 | downloads_menu(params) |
| 594 | elif action == "open_download_manager": | |
| 595 | from lib.navigation import open_download_manager | |
| 596 | ||
| 597 | open_download_manager(params) | |
| 538 | 598 | |
| 539 | 599 | |
| 540 | 600 | def _route_mdblist(action, params): |
| … | … | @@ -557,14 +617,14 @@ def _route_mdblist(action, params): |
| 557 | 617 | def _route_cache(action, params): |
| 558 | 618 | from lib.navigation import ( |
| 559 | 619 | clear_all_cached, |
| 560 | clear_trakt_cache, | |
| 561 | clear_tmdb_cache, | |
| 562 | clear_stremio_cache, | |
| 563 | clear_debrid_cache, | |
| 564 | clear_mdblist_cache, | |
| 565 | 620 | clear_database_cache, |
| 621 | clear_debrid_cache, | |
| 566 | 622 | clear_history, |
| 623 | clear_mdblist_cache, | |
| 567 | 624 | clear_search_history, |
| 625 | clear_stremio_cache, | |
| 626 | clear_tmdb_cache, | |
| 627 | clear_trakt_cache, | |
| 568 | 628 | ) |
| 569 | 629 | |
| 570 | 630 | actions = { |
| … | … | @@ -618,10 +678,10 @@ def _route_gui(action, params): |
| 618 | 678 | del window |
| 619 | 679 | else: |
| 620 | 680 | from lib.navigation import ( |
| 621 | test_source_select, | |
| 622 | test_run_next, | |
| 623 | test_resume_dialog, | |
| 624 | 681 | test_download_dialog, |
| 682 | test_resume_dialog, | |
| 683 | test_run_next, | |
| 684 | test_source_select, | |
| 625 | 685 | ) |
| 626 | 686 | |
| 627 | 687 | actions = { |
| … | … | @@ -635,7 +695,28 @@ def _route_gui(action, params): |
| 635 | 695 | action_func(params) |
| 636 | 696 | |
| 637 | 697 | |
| 698 | def _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 | ||
| 638 | 704 | def _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 | |
| 639 | 720 | if action in ("resolve_for_pack_selection", "resolve_for_subtitles"): |
| 640 | 721 | from lib.actions import resolve_for_pack_selection, resolve_for_subtitles |
| 641 | 722 | |
| … | … | @@ -661,54 +742,55 @@ def _route_core(action, params): |
| 661 | 742 | delete_last_title_entry(params) |
| 662 | 743 | else: |
| 663 | 744 | 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, | |
| 670 | 749 | anime_item, |
| 750 | anime_menu, | |
| 671 | 751 | 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, | |
| 685 | 759 | export_settings_backup, |
| 686 | restore_settings_backup, | |
| 687 | reset_all_settings, | |
| 688 | 760 | factory_reset, |
| 689 | history_menu, | |
| 690 | 761 | files_history, |
| 691 | titles_history, | |
| 692 | titles_calendar, | |
| 762 | history_menu, | |
| 763 | kodi_logs, | |
| 764 | library_calendar, | |
| 693 | 765 | library_menu, |
| 694 | continue_watching_menu, | |
| 695 | library_shows, | |
| 696 | 766 | 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, | |
| 706 | 770 | 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, | |
| 711 | 779 | 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, | |
| 712 | 794 | views_menu, |
| 713 | 795 | ) |
| 714 | 796 | |
| … | … | @@ -729,6 +811,7 @@ def _route_core(action, params): |
| 729 | 811 | "search_item": search_item, |
| 730 | 812 | "next_page_anime": next_page_anime, |
| 731 | 813 | "play_media": play_media, |
| 814 | "play_autoscraped": play_autoscraped, | |
| 732 | 815 | "play_from_pack": play_from_pack, |
| 733 | 816 | "play_trailer": play_trailer, |
| 734 | 817 | "play_url": play_url, |
| … | … | @@ -780,4 +863,5 @@ ROUTE_GROUPS = ( |
| 780 | 863 | (_is_mdblist_action, _route_mdblist), |
| 781 | 864 | (_is_cache_action, _route_cache), |
| 782 | 865 | (_is_gui_action, _route_gui), |
| 866 | (_is_source_manager_action, _route_source_manager), | |
| 783 | 867 | ) |
| … | … | @@ -1,75 +1,156 @@ |
| 1 | from concurrent.futures import ThreadPoolExecutor, as_completed | |
| 1 | import json | |
| 2 | from concurrent.futures import Future, ThreadPoolExecutor, as_completed | |
| 2 | 3 | from typing import List, Optional |
| 3 | 4 | |
| 5 | import xbmc | |
| 6 | from xbmcgui import Dialog | |
| 7 | ||
| 8 | from lib.clients.stremio.addon_client import StremioAddonClient | |
| 9 | from lib.clients.stremio.constants import STREMIO_ADDON_ALIASES_KEY, STREMIO_ADDONS_KEY | |
| 4 | 10 | from lib.clients.stremio.helpers import ( |
| 5 | 11 | get_addon_by_base_url, |
| 12 | get_addon_display_name, | |
| 6 | 13 | get_selected_stream_addons, |
| 7 | 14 | ) |
| 8 | from lib.clients.stremio.constants import STREMIO_ADDONS_KEY | |
| 9 | from lib.clients.stremio.addon_client import StremioAddonClient | |
| 10 | 15 | from lib.db.cached import cache |
| 11 | 16 | from lib.domain.torrent import TorrentStream |
| 12 | 17 | from lib.gui.custom_dialogs import source_select |
| 18 | from lib.gui.search_status_window import SearchStatusWindow, SearchTaskManager | |
| 13 | 19 | from lib.player import JacktookPLayer |
| 14 | 20 | from lib.utils.clients.utils import get_client, update_dialog |
| 21 | from lib.utils.debrid.debrid_utils import check_debrid_cached | |
| 15 | 22 | from lib.utils.general.utils import ( |
| 16 | 23 | DialogListener, |
| 17 | 24 | Indexer, |
| 18 | 25 | SearchVariant, |
| 26 | build_media_metadata, | |
| 19 | 27 | cache_results, |
| 28 | clean_auto_play_undesired, | |
| 20 | 29 | get_cached_results, |
| 21 | pre_process, | |
| 30 | normalize_tv_data, | |
| 22 | 31 | post_process, |
| 23 | build_media_metadata, | |
| 32 | pre_process, | |
| 33 | safe_json_loads, | |
| 24 | 34 | set_content_type, |
| 25 | 35 | set_watched_title, |
| 26 | clean_auto_play_undesired, | |
| 27 | normalize_tv_data, | |
| 28 | safe_json_loads, | |
| 29 | 36 | ) |
| 30 | from lib.utils.debrid.debrid_utils import check_debrid_cached | |
| 31 | 37 | from lib.utils.kodi.settings import auto_play_enabled, get_setting |
| 32 | from lib.utils.player.utils import resolve_playback_url | |
| 33 | 38 | from lib.utils.kodi.utils import ( |
| 34 | notification, | |
| 39 | ADDON_PATH, | |
| 35 | 40 | cancel_playback, |
| 41 | close_busy_dialog, | |
| 36 | 42 | kodilog, |
| 43 | notification, | |
| 37 | 44 | translation, |
| 38 | close_busy_dialog, | |
| 39 | ADDON_PATH, | |
| 40 | 45 | ) |
| 41 | ||
| 42 | from lib.gui.search_status_window import SearchTaskManager, SearchStatusWindow | |
| 43 | ||
| 44 | import xbmc | |
| 45 | from xbmcgui import Dialog | |
| 46 | ||
| 46 | from lib.utils.player.utils import resolve_playback_url | |
| 47 | 47 | |
| 48 | 48 | TITLE_LANGUAGE_LOCALIZED_FIRST = "localized_first" |
| 49 | 49 | TITLE_LANGUAGE_ENGLISH_FIRST = "english_first" |
| 50 | 50 | TITLE_LANGUAGE_ENGLISH_ONLY = "english_only" |
| 51 | 51 | |
| 52 | 52 | |
| 53 | class 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 | ||
| 61 | class 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 | ||
| 53 | 74 | def _build_search_cache_scope(scoped_addon_url: str = "") -> str: |
| 54 | 75 | flags = { |
| 55 | 76 | "torrent": bool(get_setting("torrent_enable")), |
| 77 | "external_scraper": bool(get_setting("external_scraper_enabled")), | |
| 56 | 78 | "stremio": bool(get_setting("stremio_enabled")), |
| 57 | 79 | "rd": bool(get_setting("real_debrid_enabled")), |
| 58 | 80 | "ad": bool(get_setting("alldebrid_enabled")), |
| 59 | 81 | "tb": bool(get_setting("torbox_enabled")), |
| 82 | "oc": bool(get_setting("offcloud_enabled")), | |
| 60 | 83 | "pm": bool(get_setting("premiumize_enabled")), |
| 61 | 84 | "db": bool(get_setting("debrider_enabled")), |
| 62 | 85 | "ed": bool(get_setting("easydebrid_enabled")), |
| 63 | 86 | } |
| 64 | 87 | 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 "") | |
| 65 | 93 | normalized_scoped_url = str(scoped_addon_url or "") |
| 66 | return "{}|{}|{}".format( | |
| 94 | return "{}|{}|{}|{}|{}|{}".format( | |
| 67 | 95 | "|".join([f"{key}:{int(value)}" for key, value in sorted(flags.items())]), |
| 68 | 96 | selected_stream_addons, |
| 97 | stremio_addon_aliases, | |
| 98 | source_manager_selection, | |
| 99 | external_scraper_module, | |
| 69 | 100 | normalized_scoped_url, |
| 70 | 101 | ) |
| 71 | 102 | |
| 72 | 103 | |
| 104 | BUILTIN_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 | ||
| 115 | def _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 | ||
| 131 | def _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 | ||
| 73 | 154 | def _clean_title_candidate(value) -> str: |
| 74 | 155 | if value is None: |
| 75 | 156 | return "" |
| … | … | @@ -133,6 +214,27 @@ def _extract_english_tmdb_title(details, mode: str) -> str: |
| 133 | 214 | return "" |
| 134 | 215 | |
| 135 | 216 | |
| 217 | def _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 | ||
| 136 | 238 | def _build_title_fallback_queries( |
| 137 | 239 | query: str, |
| 138 | 240 | ids: dict, |
| … | … | @@ -180,9 +282,7 @@ def _build_title_fallback_queries( |
| 180 | 282 | return _unique_title_candidates(candidates) |
| 181 | 283 | |
| 182 | 284 | 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", "") | |
| 186 | 286 | |
| 187 | 287 | english_candidate = "" |
| 188 | 288 | original_candidate = "" |
| … | … | @@ -195,16 +295,7 @@ def _build_title_fallback_queries( |
| 195 | 295 | if original_title: |
| 196 | 296 | original_candidate = f"{original_title} {cleaned_year}" |
| 197 | 297 | |
| 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: | |
| 208 | 299 | if title_language_mode == TITLE_LANGUAGE_ENGLISH_ONLY: |
| 209 | 300 | candidates = [english_candidate, original_candidate] |
| 210 | 301 | if not any(candidates) and localized_candidate: |
| … | … | @@ -262,7 +353,14 @@ def _perform_search_with_title_fallback( |
| 262 | 353 | ) |
| 263 | 354 | |
| 264 | 355 | 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, | |
| 266 | 364 | ) |
| 267 | 365 | if results: |
| 268 | 366 | return results |
| … | … | @@ -281,7 +379,7 @@ def _handle_super_quick_play(params: dict) -> bool: |
| 281 | 379 | if key: |
| 282 | 380 | tv_data = safe_json_loads(params.get("tv_data") or "{}") |
| 283 | 381 | 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']}" | |
| 285 | 383 | |
| 286 | 384 | if not key: |
| 287 | 385 | kodilog("Super quick play: No valid key found from ids") |
| … | … | @@ -332,6 +430,7 @@ def _process_search_results( |
| 332 | 430 | query, |
| 333 | 431 | media_type, |
| 334 | 432 | rescrape, |
| 433 | suppress_debrid_dialog=False, | |
| 335 | 434 | ): |
| 336 | 435 | bypassed_streams = [] |
| 337 | 436 | other_results = results |
| … | … | @@ -344,19 +443,11 @@ def _process_search_results( |
| 344 | 443 | if result.addonKey and result.addonKey in bypass_addons: |
| 345 | 444 | return True |
| 346 | 445 | |
| 347 | legacy_name = (result.addonName or result.indexer or "").lower() | |
| 446 | legacy_name = (result.addonSourceName or result.indexer or "").lower() | |
| 348 | 447 | return any(addon == legacy_name for addon in bypass_addons) |
| 349 | 448 | |
| 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)] | |
| 360 | 451 | |
| 361 | 452 | pre_results = [] |
| 362 | 453 | if other_results: |
| … | … | @@ -372,7 +463,13 @@ def _process_search_results( |
| 372 | 463 | post_results = [] |
| 373 | 464 | if pre_results: |
| 374 | 465 | 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, | |
| 376 | 473 | ) |
| 377 | 474 | |
| 378 | 475 | # Combine results, prioritizing bypassed streams exact native sorting |
| … | … | @@ -390,6 +487,7 @@ def run_search_entry(params: dict): |
| 390 | 487 | tv_data = normalize_tv_data(safe_json_loads(params.get("tv_data"))) |
| 391 | 488 | direct = params.get("direct", False) |
| 392 | 489 | rescrape = params.get("rescrape", False) |
| 490 | skip_cancel = params.get("skip_cancel_on_back", False) | |
| 393 | 491 | |
| 394 | 492 | variant = _normalize_search_variant(params.get("search_variant", SearchVariant.DEFAULT)) |
| 395 | 493 | title_language_mode = _normalize_title_language_mode( |
| … | … | @@ -406,8 +504,11 @@ def run_search_entry(params: dict): |
| 406 | 504 | year = None |
| 407 | 505 | |
| 408 | 506 | 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)}" | |
| 410 | 510 | ) |
| 511 | kodilog(f"[PLAYNEXT] run_search_entry: tv_data from params={tv_data}, ids={ids}") | |
| 411 | 512 | |
| 412 | 513 | library_data = None |
| 413 | 514 | if params.get("stremio_addon_url") and params.get("stremio_catalog_type"): |
| … | … | @@ -427,22 +528,30 @@ def run_search_entry(params: dict): |
| 427 | 528 | |
| 428 | 529 | scoped_addon_url = params.get("scoped_addon_url", "") |
| 429 | 530 | |
| 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 | ||
| 443 | 551 | if not results: |
| 444 | 552 | notification("No results found") |
| 445 | cancel_playback() | |
| 553 | if not skip_cancel: | |
| 554 | cancel_playback() | |
| 446 | 555 | return |
| 447 | 556 | |
| 448 | 557 | final_results = _process_search_results( |
| … | … | @@ -455,18 +564,28 @@ def run_search_entry(params: dict): |
| 455 | 564 | query, |
| 456 | 565 | media_type, |
| 457 | 566 | rescrape, |
| 567 | suppress_debrid_dialog=suppress_debrid_dialog, | |
| 458 | 568 | ) |
| 459 | 569 | |
| 460 | 570 | if not final_results: |
| 461 | 571 | notification(translation(90358)) |
| 462 | cancel_playback() | |
| 572 | if not skip_cancel: | |
| 573 | cancel_playback() | |
| 463 | 574 | return |
| 464 | 575 | |
| 465 | 576 | preferred_group = params.get("preferred_group") |
| 466 | 577 | force_select = params.get("force_select", False) |
| 578 | autoplay_context = params.get("autoplay_context") | |
| 467 | 579 | |
| 468 | 580 | 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: | |
| 470 | 589 | cancel_playback() |
| 471 | 590 | return |
| 472 | 591 | |
| … | … | @@ -479,7 +598,8 @@ def run_search_entry(params: dict): |
| 479 | 598 | media_type, |
| 480 | 599 | rescrape, |
| 481 | 600 | direct, |
| 482 | ): | |
| 601 | autoplay_context=autoplay_context, | |
| 602 | ) and not skip_cancel: | |
| 483 | 603 | cancel_playback() |
| 484 | 604 | |
| 485 | 605 | |
| … | … | @@ -521,9 +641,9 @@ def _perform_search(indexer_key, dialog, *args, **kwargs): |
| 521 | 641 | |
| 522 | 642 | # Try TMDB ID for addons that declare tmdb: prefix |
| 523 | 643 | 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 | ): | |
| 527 | 647 | video_id = f"tmdb:{ids_dict['tmdb_id']}" |
| 528 | 648 | |
| 529 | 649 | if video_id: |
| … | … | @@ -535,11 +655,13 @@ def _perform_search(indexer_key, dialog, *args, **kwargs): |
| 535 | 655 | |
| 536 | 656 | return results |
| 537 | 657 | |
| 658 | kodilog(f"[ExternalScraper] _perform_search for {indexer_key}, getting client...") | |
| 538 | 659 | client = get_client(indexer_key) |
| 660 | kodilog(f"[ExternalScraper] client={client}") | |
| 539 | 661 | if not client: |
| 540 | 662 | return [] |
| 541 | 663 | |
| 542 | if indexer_key == Indexer.BURST and not show_dialog: | |
| 664 | if indexer_key == Indexer.BURST: | |
| 543 | 665 | kwargs["silent"] = True |
| 544 | 666 | |
| 545 | 667 | return client.search(*args, **kwargs) |
| … | … | @@ -562,6 +684,7 @@ def _submit_search_tasks( |
| 562 | 684 | variant: str = SearchVariant.DEFAULT, |
| 563 | 685 | title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST, |
| 564 | 686 | year: Optional[int] = None, |
| 687 | title_aliases: Optional[List[str]] = None, | |
| 565 | 688 | ): |
| 566 | 689 | def submit_performer(*args, **kwargs): |
| 567 | 690 | if "show_dialog" not in kwargs: |
| … | … | @@ -581,49 +704,54 @@ def _submit_search_tasks( |
| 581 | 704 | ) |
| 582 | 705 | |
| 583 | 706 | 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 | ): | |
| 585 | 713 | 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) | |
| 589 | 715 | ) |
| 590 | 716 | 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): | |
| 627 | 755 | indexers_ids = get_setting("prowlarr_indexer_ids") |
| 628 | 756 | tasks.append( |
| 629 | 757 | executor.submit( |
| … | … | @@ -643,47 +771,70 @@ def _submit_search_tasks( |
| 643 | 771 | year=year, |
| 644 | 772 | ) |
| 645 | 773 | ) |
| 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) | |
| 682 | 835 | ): |
| 683 | 836 | 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) | |
| 687 | 838 | ) |
| 688 | 839 | |
| 689 | 840 | |
| … | … | @@ -697,9 +848,7 @@ def _collect_search_results(tasks, listener, show_dialog) -> List[TorrentStream] |
| 697 | 848 | completed_tasks += 1 |
| 698 | 849 | if show_dialog and total_tasks > 0: |
| 699 | 850 | 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) | |
| 703 | 852 | |
| 704 | 853 | results = future.result() |
| 705 | 854 | kodilog(f"Results from {future}: {results}", level=xbmc.LOGDEBUG) |
| … | … | @@ -709,9 +858,7 @@ def _collect_search_results(tasks, listener, show_dialog) -> List[TorrentStream] |
| 709 | 858 | import traceback |
| 710 | 859 | |
| 711 | 860 | 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}") | |
| 715 | 862 | |
| 716 | 863 | return total_results |
| 717 | 864 | |
| … | … | @@ -731,6 +878,7 @@ def _submit_search_tasks_managed( |
| 731 | 878 | variant: str = SearchVariant.DEFAULT, |
| 732 | 879 | title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST, |
| 733 | 880 | year: Optional[int] = None, |
| 881 | title_aliases: Optional[List[str]] = None, | |
| 734 | 882 | ): |
| 735 | 883 | def submit_performer_managed(name, indexer_key, *args, **kwargs): |
| 736 | 884 | kwargs["show_dialog"] = False |
| … | … | @@ -753,11 +901,13 @@ def _submit_search_tasks_managed( |
| 753 | 901 | |
| 754 | 902 | if scoped_addon_url: |
| 755 | 903 | 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()) | |
| 758 | 908 | ): |
| 759 | 909 | submit_performer_managed( |
| 760 | addon.manifest.name, | |
| 910 | get_addon_display_name(addon), | |
| 761 | 911 | Indexer.STREMIO, |
| 762 | 912 | dialog, |
| 763 | 913 | ids, |
| … | … | @@ -768,40 +918,42 @@ def _submit_search_tasks_managed( |
| 768 | 918 | scoped_addon_url=scoped_addon_url, |
| 769 | 919 | ) |
| 770 | 920 | 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): | |
| 805 | 957 | indexers_ids = get_setting("prowlarr_indexer_ids") |
| 806 | 958 | manager.submit_task( |
| 807 | 959 | "Prowlarr", |
| … | … | @@ -821,45 +973,71 @@ def _submit_search_tasks_managed( |
| 821 | 973 | title_language_mode=title_language_mode, |
| 822 | 974 | year=year, |
| 823 | 975 | ) |
| 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")): | |
| 859 | 1032 | 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: | |
| 861 | 1039 | submit_performer_managed( |
| 862 | addon.manifest.name, | |
| 1040 | get_addon_display_name(addon), | |
| 863 | 1041 | Indexer.STREMIO, |
| 864 | 1042 | dialog, |
| 865 | 1043 | ids, |
| … | … | @@ -871,54 +1049,73 @@ def _submit_search_tasks_managed( |
| 871 | 1049 | ) |
| 872 | 1050 | |
| 873 | 1051 | |
| 874 | def search_client( | |
| 1052 | def _check_search_caches( | |
| 875 | 1053 | query: str, |
| 876 | 1054 | ids: dict, |
| 877 | 1055 | mode: str, |
| 878 | 1056 | media_type: str, |
| 879 | rescrape: bool, | |
| 880 | season: int, | |
| 881 | 1057 | 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 | |
| 890 | 1081 | |
| 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 | |
| 897 | 1084 | |
| 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 | |
| 901 | 1093 | |
| 902 | use_detailed_status = show_dialog and get_setting("search_dialog_style", "0") == "1" | |
| 903 | 1094 | |
| 904 | if use_detailed_status: | |
| 905 | executor = ThreadPoolExecutor(max_workers=int(get_setting("thread_number", 6))) | |
| 906 | manager = SearchTaskManager(executor) | |
| 1095 | def _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: | |
| 907 | 1114 | _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, | |
| 922 | 1119 | ) |
| 923 | 1120 | |
| 924 | 1121 | item_info = {"ids": ids, "mode": mode} |
| … | … | @@ -926,57 +1123,116 @@ def search_client( |
| 926 | 1123 | item_info.update(build_media_metadata(ids, mode)) |
| 927 | 1124 | |
| 928 | 1125 | 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, | |
| 933 | 1128 | ) |
| 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 | |
| 938 | 1134 | |
| 939 | 1135 | 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()) | |
| 969 | 1138 | |
| 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) | |
| 971 | 1145 | |
| 972 | cache_results( | |
| 973 | total_results, | |
| 974 | query, | |
| 975 | mode, | |
| 976 | media_type, | |
| 977 | episode, | |
| 978 | cache_scope=cache_scope, | |
| 1146 | ||
| 1147 | def _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 | ||
| 1182 | def 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, | |
| 979 | 1203 | ) |
| 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) | |
| 980 | 1236 | return total_results |
| 981 | 1237 | |
| 982 | 1238 | |
| … | … | @@ -998,15 +1254,21 @@ def process_results( |
| 998 | 1254 | media_type: str, |
| 999 | 1255 | rescrape: bool, |
| 1000 | 1256 | episode: int, |
| 1257 | suppress_dialog: bool = False, | |
| 1001 | 1258 | ) -> List[TorrentStream]: |
| 1002 | 1259 | torrent_results = [] |
| 1003 | 1260 | if get_setting("torrent_enable"): |
| 1004 | 1261 | torrent_results = post_process(pre_results) |
| 1005 | 1262 | close_busy_dialog() |
| 1006 | with DialogListener() as listener: | |
| 1263 | if suppress_dialog: | |
| 1007 | 1264 | 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 | |
| 1009 | 1266 | ) |
| 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 | ) | |
| 1010 | 1272 | return debrid_results + torrent_results |
| 1011 | 1273 | |
| 1012 | 1274 | |
| … | … | @@ -1019,6 +1281,7 @@ def show_source_select( |
| 1019 | 1281 | media_type: str, |
| 1020 | 1282 | rescrape: bool, |
| 1021 | 1283 | direct: bool = False, |
| 1284 | autoplay_context: Optional[str] = None, | |
| 1022 | 1285 | ) -> bool: |
| 1023 | 1286 | item_info = { |
| 1024 | 1287 | "tv_data": tv_data, |
| … | … | @@ -1028,17 +1291,20 @@ def show_source_select( |
| 1028 | 1291 | "media_type": media_type, |
| 1029 | 1292 | "rescrape": rescrape, |
| 1030 | 1293 | } |
| 1294 | if autoplay_context: | |
| 1295 | item_info["playnext_context"] = True | |
| 1296 | item_info["direct_play"] = True | |
| 1031 | 1297 | |
| 1032 | 1298 | if not direct and ids: |
| 1033 | 1299 | item_info.update(build_media_metadata(ids, mode)) |
| 1034 | 1300 | |
| 1035 | 1301 | 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}" | |
| 1037 | 1305 | ) |
| 1038 | 1306 | |
| 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" | |
| 1042 | 1308 | |
| 1043 | 1309 | return source_select(item_info, xml_file=xml_file_string, sources=results) |
| 1044 | 1310 | |
| … | … | @@ -1050,6 +1316,7 @@ def auto_play( |
| 1050 | 1316 | mode, |
| 1051 | 1317 | preferred_group=None, |
| 1052 | 1318 | force_select=False, |
| 1319 | autoplay_context: Optional[str] = None, | |
| 1053 | 1320 | ) -> bool: |
| 1054 | 1321 | if force_select: |
| 1055 | 1322 | return False |
| … | … | @@ -1070,9 +1337,7 @@ def auto_play( |
| 1070 | 1337 | |
| 1071 | 1338 | selected_result = quality_matches[0] |
| 1072 | 1339 | 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()] | |
| 1076 | 1341 | if group_matches: |
| 1077 | 1342 | selected_result = group_matches[0] |
| 1078 | 1343 | |
| … | … | @@ -1086,7 +1351,7 @@ def auto_play( |
| 1086 | 1351 | "ids": ids, |
| 1087 | 1352 | "info_hash": selected_result.infoHash, |
| 1088 | 1353 | "url": selected_result.url, |
| 1089 | # getattr — see prepare_source_data: unpickled cached results predate | |
| 1354 | # getattr -- see prepare_source_data: unpickled cached results predate | |
| 1090 | 1355 | # this field and pickle does not apply dataclass defaults. |
| 1091 | 1356 | "proxyHeaders": getattr(selected_result, "proxyHeaders", None) or {}, |
| 1092 | 1357 | "tv_data": tv_data, |
| … | … | @@ -1097,6 +1362,11 @@ def auto_play( |
| 1097 | 1362 | if not playback_info: |
| 1098 | 1363 | return False |
| 1099 | 1364 | |
| 1365 | playback_info["autoplay"] = True | |
| 1366 | if autoplay_context: | |
| 1367 | playback_info["playnext_context"] = True | |
| 1368 | playback_info["direct_play"] = True | |
| 1369 | ||
| 1100 | 1370 | player = JacktookPLayer() |
| 1101 | 1371 | player.run(data=playback_info) |
| 1102 | 1372 | del player |
| … | … | @@ -1115,9 +1385,7 @@ def add_task_if_enabled( |
| 1115 | 1385 | ): |
| 1116 | 1386 | """Add a search task to the task list if the corresponding setting is enabled.""" |
| 1117 | 1387 | 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)) | |
| 1121 | 1389 | |
| 1122 | 1390 | |
| 1123 | 1391 | def add_task_if_enabled_managed( |
| … | … | @@ -1126,6 +1394,4 @@ def add_task_if_enabled_managed( |
| 1126 | 1394 | """Add a managed search task if the corresponding setting is enabled.""" |
| 1127 | 1395 | if get_setting(setting_key): |
| 1128 | 1396 | 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) |
| … | … | @@ -0,0 +1,13 @@ |
| 1 | import xbmc | |
| 2 | ||
| 3 | from lib.utils.kodi.logging import kodilog | |
| 4 | from lib.utils.kodi.utils import execute_builtin, get_setting | |
| 5 | ||
| 6 | ||
| 7 | class 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) |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | import os | |
| 1 | 2 | from threading import Thread |
| 2 | 3 | from time import time |
| 3 | import os | |
| 4 | 4 | |
| 5 | 5 | import xbmc |
| 6 | 6 | import xbmcaddon |
| … | … | @@ -21,11 +21,10 @@ from lib.utils.kodi.utils import ( |
| 21 | 21 | get_setting, |
| 22 | 22 | kodilog, |
| 23 | 23 | set_property_no_fallback, |
| 24 | translation, | |
| 25 | 24 | translatePath, |
| 25 | translation, | |
| 26 | 26 | ) |
| 27 | 27 | |
| 28 | ||
| 29 | 28 | first_run_update_prop = "jacktook.first_run_update" |
| 30 | 29 | pause_services_prop = "jacktook.pause_services" |
| 31 | 30 | |
| … | … | @@ -72,9 +71,7 @@ class UpdateCheck: |
| 72 | 71 | |
| 73 | 72 | |
| 74 | 73 | def 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" | |
| 78 | 75 | |
| 79 | 76 | if not xbmcvfs.exists(jacktook_select_path): |
| 80 | 77 | kodilog("jacktook.select.json file not found!", level=xbmc.LOGERROR) |
| … | … | @@ -134,7 +131,7 @@ def _read_text_file(path): |
| 134 | 131 | return None |
| 135 | 132 | |
| 136 | 133 | try: |
| 137 | with open(translated_path, "r", encoding="utf-8") as file_obj: | |
| 134 | with open(translated_path, encoding="utf-8") as file_obj: | |
| 138 | 135 | return file_obj.read() |
| 139 | 136 | except OSError as error: |
| 140 | 137 | kodilog(f"Failed to read {path}: {error}", level=xbmc.LOGERROR) |
| … | … | @@ -227,22 +224,36 @@ class JacktookMOnitor(xbmc.Monitor): |
| 227 | 224 | StartupPreloader().run() |
| 228 | 225 | DownloaderSetup().run() |
| 229 | 226 | 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) | |
| 230 | 239 | |
| 231 | 240 | def onScreensaverActivated(self): |
| 232 | 241 | 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) | |
| 236 | 243 | |
| 237 | 244 | def onScreensaverDeactivated(self): |
| 238 | 245 | 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) | |
| 242 | 247 | |
| 243 | 248 | def onSettingsChanged(self): |
| 244 | 249 | 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 | ) | |
| 246 | 257 | |
| 247 | 258 | def onNotification(self, sender, method, data): |
| 248 | 259 | if method == "System.OnSleep": |
| … | … | @@ -1,3 +1,4 @@ |
| 1 | import contextlib | |
| 1 | 2 | from time import time |
| 2 | 3 | |
| 3 | 4 | from lib.gui.qr_progress_dialog import QRProgressDialog |
| … | … | @@ -9,9 +10,11 @@ from lib.utils.kodi.utils import ( |
| 9 | 10 | dialog_ok, |
| 10 | 11 | progressDialog, |
| 11 | 12 | set_setting, |
| 12 | sleep as ksleep, | |
| 13 | 13 | translation, |
| 14 | 14 | ) |
| 15 | from lib.utils.kodi.utils import ( | |
| 16 | sleep as ksleep, | |
| 17 | ) | |
| 15 | 18 | |
| 16 | 19 | |
| 17 | 20 | def run_realdebrid_auth(client): |
| … | … | @@ -136,7 +139,7 @@ def run_premiumize_auth(client): |
| 136 | 139 | response = client.get_device_code() |
| 137 | 140 | user_code = response["user_code"] |
| 138 | 141 | copy2clip(user_code) |
| 139 | content = "%s[CR]%s[CR]%s" % ( | |
| 142 | content = "{}[CR]{}[CR]{}".format( | |
| 140 | 143 | translation(90540), |
| 141 | 144 | translation(90541) % response.get("verification_uri"), |
| 142 | 145 | translation(90542) % user_code, |
| … | … | @@ -179,10 +182,8 @@ def run_premiumize_auth(client): |
| 179 | 182 | dialog_ok(translation(90546), translation(90547) % error) |
| 180 | 183 | break |
| 181 | 184 | |
| 182 | try: | |
| 185 | with contextlib.suppress(Exception): | |
| 183 | 186 | progressDialog.close() |
| 184 | except Exception: | |
| 185 | pass | |
| 186 | 187 | |
| 187 | 188 | |
| 188 | 189 | def run_torbox_auth(client): |
| … | … | @@ -250,6 +251,86 @@ def run_torbox_auth(client): |
| 250 | 251 | return |
| 251 | 252 | |
| 252 | 253 | |
| 254 | def 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 | ||
| 253 | 334 | def run_debrider_auth(client): |
| 254 | 335 | response = client.get_device_code() |
| 255 | 336 | if not response: |
| … | … | @@ -1,7 +1,10 @@ |
| 1 | from lib.utils.general.utils import supported_video_extensions | |
| 2 | from lib.utils.kodi.utils import dialogyesno, notification, sleep as ksleep, translation | |
| 1 | import contextlib | |
| 2 | ||
| 3 | 3 | from xbmcgui import DialogProgress |
| 4 | 4 | |
| 5 | from lib.utils.general.utils import supported_video_extensions | |
| 6 | from lib.utils.kodi.utils import dialogyesno, notification, translation | |
| 7 | from lib.utils.kodi.utils import sleep as ksleep | |
| 5 | 8 | |
| 6 | 9 | DEBRID_ERROR_STATUS = ("magnet_error", "error", "virus", "dead") |
| 7 | 10 | |
| … | … | @@ -44,16 +47,12 @@ def run_realdebrid_download(client, magnet_url, pack=False): |
| 44 | 47 | client, torrent_id, torrent_info, progress_dialog, interval, pack |
| 45 | 48 | ) |
| 46 | 49 | |
| 47 | try: | |
| 50 | with contextlib.suppress(Exception): | |
| 48 | 51 | progress_dialog.close() |
| 49 | except Exception: | |
| 50 | pass | |
| 51 | 52 | |
| 52 | 53 | ksleep(500) |
| 53 | 54 | if cancelled: |
| 54 | response = dialogyesno( | |
| 55 | translation(32029), translation(90564) | |
| 56 | ) | |
| 55 | response = dialogyesno(translation(32029), translation(90564)) | |
| 57 | 56 | if response: |
| 58 | 57 | notification(translation(90641)) |
| 59 | 58 | else: |
| … | … | @@ -1,17 +1,13 @@ |
| 1 | 1 | from lib.utils.kodi.utils import ( |
| 2 | 2 | get_setting, |
| 3 | set_setting, | |
| 4 | 3 | kodilog, |
| 4 | set_setting, | |
| 5 | 5 | ) |
| 6 | 6 | |
| 7 | 7 | |
| 8 | 8 | def _migrate_trakt_credentials(): |
| 9 | DEFAULT_CLIENT = ( | |
| 10 | "687c707dc7073be777a6dd6869ffd9034109d75dcda6599a7273f49a68266ff8" | |
| 11 | ) | |
| 12 | DEFAULT_SECRET = ( | |
| 13 | "3191ae63a3cf60d767b1d6fc1e39b1dedc6f3346b826d62901f9f2c3c9925e26" | |
| 14 | ) | |
| 9 | DEFAULT_CLIENT = "687c707dc7073be777a6dd6869ffd9034109d75dcda6599a7273f49a68266ff8" | |
| 10 | DEFAULT_SECRET = "3191ae63a3cf60d767b1d6fc1e39b1dedc6f3346b826d62901f9f2c3c9925e26" | |
| 15 | 11 | |
| 16 | 12 | current_client = get_setting("trakt_client", "") |
| 17 | 13 | current_secret = get_setting("trakt_secret", "") |
| … | … | @@ -34,6 +30,7 @@ def _migrate_trakt_credentials(): |
| 34 | 30 | set_setting("trakt_client", DEFAULT_CLIENT) |
| 35 | 31 | set_setting("trakt_secret", DEFAULT_SECRET) |
| 36 | 32 | |
| 33 | ||
| 37 | 34 | def run_migrations(): |
| 38 | 35 | _migrate_trakt_credentials() |
| 39 | kodilog(f"Trakt migration complete") | |
| 36 | kodilog("Trakt migration complete") |
| … | … | @@ -1,8 +1,10 @@ |
| 1 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 2 | from threading import Thread | |
| 1 | import contextlib | |
| 3 | 2 | from concurrent.futures import ThreadPoolExecutor |
| 4 | from lib.utils.kodi.utils import kodilog, get_setting | |
| 3 | from threading import Thread | |
| 4 | ||
| 5 | 5 | from lib.api.tmdbv3api.tmdb import TMDb |
| 6 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 7 | from lib.utils.kodi.utils import get_setting, kodilog | |
| 6 | 8 | |
| 7 | 9 | |
| 8 | 10 | class StartupPreloader: |
| … | … | @@ -12,53 +14,63 @@ class StartupPreloader: |
| 12 | 14 | def _preload(self): |
| 13 | 15 | tmdb = TMDb() |
| 14 | 16 | tmdb.api_key = get_setting("tmdb_api_key", "b70756b7083d9ee60f849d82d94a0d80") |
| 15 | ||
| 17 | ||
| 16 | 18 | # Helper to fetch seasons for a list of shows |
| 17 | 19 | def _deep_preload_tv(results): |
| 18 | 20 | try: |
| 19 | if not results: | |
| 21 | if not results: | |
| 20 | 22 | return |
| 21 | ||
| 23 | ||
| 22 | 24 | # Handle list vs dict (TMDB response structure variants) |
| 23 | 25 | items = [] |
| 24 | if hasattr(results, 'results'): | |
| 26 | if hasattr(results, "results"): | |
| 25 | 27 | items = results.results |
| 26 | 28 | elif isinstance(results, dict): |
| 27 | items = results.get('results', []) | |
| 29 | items = results.get("results", []) | |
| 28 | 30 | else: |
| 29 | try: | |
| 31 | with contextlib.suppress(BaseException): | |
| 30 | 32 | items = list(results) |
| 31 | except: | |
| 32 | pass | |
| 33 | ||
| 33 | ||
| 34 | 34 | if not isinstance(items, list): |
| 35 | 35 | items = list(items) |
| 36 | ||
| 36 | ||
| 37 | 37 | top_items = items[:5] |
| 38 | 38 | |
| 39 | 39 | for item in top_items: |
| 40 | 40 | 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 | ||
| 46 | 53 | # 1. Fetch Show Details |
| 47 | 54 | details = tmdb_get("tv_details", tmdb_id) |
| 48 | if not details: | |
| 55 | if not details: | |
| 49 | 56 | continue |
| 50 | ||
| 57 | ||
| 51 | 58 | # 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 | ||
| 54 | 61 | # 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 | ) | |
| 56 | 67 | 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: | |
| 59 | 70 | pass |
| 60 | 71 | except Exception as e: |
| 61 | 72 | import traceback |
| 73 | ||
| 62 | 74 | kodilog(f"[StartupPreloader] CRASH in _deep_preload_tv: {e}") |
| 63 | 75 | kodilog(traceback.format_exc()) |
| 64 | 76 | |
| … | … | @@ -68,13 +80,15 @@ class StartupPreloader: |
| 68 | 80 | f_trend_tv = executor.submit(tmdb_get, "trending_tv", 1) |
| 69 | 81 | executor.submit(tmdb_get, "popular_movie", 1) |
| 70 | 82 | f_pop_tv = executor.submit(tmdb_get, "popular_shows", 1) |
| 71 | ||
| 83 | ||
| 72 | 84 | # Get results (this waits for the specific future, but others run in parallel) |
| 73 | 85 | # We treat TV lists specially to trigger deep preload |
| 74 | 86 | trending_tv = f_trend_tv.result() |
| 75 | 87 | popular_tv = f_pop_tv.result() |
| 76 | ||
| 88 | ||
| 77 | 89 | # Submit deep preload tasks |
| 78 | 90 | # 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) |
| … | … | @@ -1,21 +1,19 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | 1 | """ |
| 3 | 2 | Local web server for uploading subtitle files from a browser. |
| 4 | 3 | |
| 5 | 4 | Serves a web UI with drag & drop file upload and exposes an API endpoint |
| 6 | 5 | to receive the subtitle file via HTTP POST. |
| 7 | 6 | """ |
| 7 | import json | |
| 8 | 8 | import os |
| 9 | 9 | import socket |
| 10 | 10 | import threading |
| 11 | import json | |
| 12 | 11 | import uuid |
| 13 | 12 | from datetime import datetime |
| 14 | from http.server import HTTPServer, BaseHTTPRequestHandler | |
| 13 | from http.server import BaseHTTPRequestHandler, HTTPServer | |
| 15 | 14 | |
| 16 | 15 | from lib.utils.kodi.utils import ADDON_PROFILE_PATH, kodilog |
| 17 | 16 | |
| 18 | ||
| 19 | 17 | _WEB_DIR = os.path.join( |
| 20 | 18 | os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), |
| 21 | 19 | "resources", |
| … | … | @@ -23,12 +21,13 @@ _WEB_DIR = os.path.join( |
| 23 | 21 | ) |
| 24 | 22 | |
| 25 | 23 | # Supported subtitle extensions |
| 26 | SUBTITLE_EXTENSIONS = {'.srt', '.ass', '.ssa', '.sub', '.vtt', '.txt'} | |
| 24 | SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".sub", ".vtt", ".txt"} | |
| 27 | 25 | |
| 28 | 26 | |
| 29 | 27 | def _ensure_upload_dir(): |
| 30 | 28 | """Create upload directory if it doesn't exist.""" |
| 31 | 29 | upload_dir = os.path.join(ADDON_PROFILE_PATH, "Subtitles", "uploads") |
| 30 | ||
| 32 | 31 | os.makedirs(upload_dir, exist_ok=True) |
| 33 | 32 | return upload_dir |
| 34 | 33 | |
| … | … | @@ -36,17 +35,19 @@ def _ensure_upload_dir(): |
| 36 | 35 | def _is_valid_extension(filename): |
| 37 | 36 | """Check if file has a supported subtitle extension.""" |
| 38 | 37 | ext = os.path.splitext(filename)[1].lower() |
| 38 | ||
| 39 | 39 | return ext in SUBTITLE_EXTENSIONS |
| 40 | 40 | |
| 41 | 41 | |
| 42 | 42 | class SubtitleUploadHandler(BaseHTTPRequestHandler): |
| 43 | 43 | """Handles HTTP requests for subtitle upload.""" |
| 44 | ||
| 45 | 44 | @property |
| 45 | ||
| 46 | 46 | def wrapper_server(self): |
| 47 | 47 | """Get the SubtitleUploadServer wrapper instance.""" |
| 48 | 48 | # 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) | |
| 50 | 51 | |
| 51 | 52 | def log_message(self, format, *args): |
| 52 | 53 | kodilog(f"[SubtitleServer] {format % args}") |
| … | … | @@ -77,9 +78,9 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler): |
| 77 | 78 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 78 | 79 | <title>Upload Error</title> |
| 79 | 80 | <style> |
| 80 | body {{ font-family: sans-serif; background: #0d1117; color: #e6edf3; | |
| 81 | body {{ font-family: sans-serif; background: #0d1117; color: #e6edf3; | |
| 81 | 82 | 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; | |
| 83 | 84 | padding: 30px; text-align: center; max-width: 400px; }} |
| 84 | 85 | h1 {{ color: #f85149; margin: 0 0 15px 0; }} |
| 85 | 86 | p {{ color: #8b949e; margin: 0; }} |
| … | … | @@ -99,7 +100,14 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler): |
| 99 | 100 | @staticmethod |
| 100 | 101 | def _html_escape(text): |
| 101 | 102 | """Escape HTML special characters to prevent XSS.""" |
| 102 | return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """).replace("'", "'") | |
| 103 | return ( | |
| 104 | ||
| 105 | text.replace("&", "&") | |
| 106 | .replace("<", "<") | |
| 107 | .replace(">", ">") | |
| 108 | .replace('"', """) | |
| 109 | .replace("'", "'") | |
| 110 | ) | |
| 103 | 111 | |
| 104 | 112 | def _send_success_page(self, filename): |
| 105 | 113 | safe_filename = self._html_escape(filename) |
| … | … | @@ -110,9 +118,9 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler): |
| 110 | 118 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 111 | 119 | <title>Upload Successful</title> |
| 112 | 120 | <style> |
| 113 | body {{ font-family: sans-serif; background: #0d1117; color: #e6edf3; | |
| 121 | body {{ font-family: sans-serif; background: #0d1117; color: #e6edf3; | |
| 114 | 122 | 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; | |
| 116 | 124 | padding: 30px; text-align: center; max-width: 400px; }} |
| 117 | 125 | h1 {{ color: #3fb950; margin: 0 0 15px 0; }} |
| 118 | 126 | p {{ color: #8b949e; margin: 0; }} |
| … | … | @@ -155,7 +163,7 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler): |
| 155 | 163 | def _serve_index(self): |
| 156 | 164 | index_path = os.path.join(_WEB_DIR, "upload.html") |
| 157 | 165 | try: |
| 158 | with open(index_path, "r", encoding="utf-8") as f: | |
| 166 | with open(index_path, encoding="utf-8") as f: | |
| 159 | 167 | self._send_html(f.read()) |
| 160 | 168 | except FileNotFoundError: |
| 161 | 169 | self.send_error(500, "Upload page not found") |
| … | … | @@ -163,40 +171,39 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler): |
| 163 | 171 | def _serve_status(self): |
| 164 | 172 | """Return current upload status.""" |
| 165 | 173 | wrapper = self.wrapper_server |
| 174 | ||
| 166 | 175 | 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}) | |
| 171 | 177 | |
| 172 | 178 | def _parse_multipart(self): |
| 173 | 179 | """ |
| 174 | 180 | Parse multipart/form-data without cgi module. |
| 181 | ||
| 175 | 182 | Returns (filename, file_content) tuple or (None, error_message). |
| 176 | 183 | """ |
| 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"): | |
| 180 | 187 | return None, "Invalid content type" |
| 181 | 188 | |
| 182 | 189 | # Extract boundary from content-type |
| 183 | boundary_marker = 'boundary=' | |
| 190 | boundary_marker = "boundary=" | |
| 184 | 191 | boundary_idx = content_type.find(boundary_marker) |
| 185 | 192 | if boundary_idx == -1: |
| 186 | 193 | 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) :] | |
| 189 | 196 | 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") | |
| 192 | 199 | |
| 193 | content_length = int(self.headers.get('Content-Length', 0)) | |
| 200 | content_length = int(self.headers.get("Content-Length", 0)) | |
| 194 | 201 | if content_length == 0: |
| 195 | 202 | return None, "No data received" |
| 196 | 203 | |
| 197 | 204 | # Read raw POST data |
| 198 | 205 | post_data = self.rfile.read(content_length) |
| 199 | ||
| 206 | ||
| 200 | 207 | # Find first boundary |
| 201 | 208 | start_idx = post_data.find(boundary_bytes) |
| 202 | 209 | if start_idx == -1: |
| … | … | @@ -204,49 +211,49 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler): |
| 204 | 211 | |
| 205 | 212 | # Parse each part |
| 206 | 213 | pos = start_idx + len(boundary_bytes) |
| 207 | ||
| 214 | ||
| 208 | 215 | while pos < len(post_data): |
| 209 | 216 | # Check for end boundary |
| 210 | if post_data[pos:pos + 2] == b'--': | |
| 217 | if post_data[pos : pos + 2] == b"--": | |
| 211 | 218 | break |
| 212 | ||
| 219 | ||
| 213 | 220 | # Skip CRLF after boundary |
| 214 | if post_data[pos:pos + 2] == b'\r\n': | |
| 221 | if post_data[pos : pos + 2] == b"\r\n": | |
| 215 | 222 | pos += 2 |
| 216 | elif post_data[pos:pos + 1] == b'\n': | |
| 223 | elif post_data[pos : pos + 1] == b"\n": | |
| 217 | 224 | pos += 1 |
| 218 | ||
| 225 | ||
| 219 | 226 | # 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) | |
| 221 | 228 | if header_end == -1: |
| 222 | header_end = post_data.find(b'\n\n', pos) | |
| 229 | header_end = post_data.find(b"\n\n", pos) | |
| 223 | 230 | header_end_len = 2 |
| 224 | 231 | if header_end == -1: |
| 225 | 232 | continue |
| 226 | 233 | else: |
| 227 | 234 | 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") | |
| 230 | 237 | pos = header_end + header_end_len |
| 231 | ||
| 238 | ||
| 232 | 239 | # Check if this is a file field |
| 233 | if 'Content-Disposition' not in headers: | |
| 240 | if "Content-Disposition" not in headers: | |
| 234 | 241 | continue |
| 235 | ||
| 242 | ||
| 236 | 243 | # Find next boundary |
| 237 | 244 | next_boundary = post_data.find(boundary_bytes, pos) |
| 238 | 245 | if next_boundary == -1: |
| 239 | 246 | next_boundary = post_data.find(end_boundary_bytes, pos) |
| 240 | 247 | if next_boundary == -1: |
| 241 | 248 | break |
| 242 | ||
| 249 | ||
| 243 | 250 | # Extract field data (remove trailing CRLF) |
| 244 | 251 | field_data = post_data[pos:next_boundary] |
| 245 | if field_data.endswith(b'\r\n'): | |
| 252 | if field_data.endswith(b"\r\n"): | |
| 246 | 253 | field_data = field_data[:-2] |
| 247 | elif field_data.endswith(b'\n'): | |
| 254 | elif field_data.endswith(b"\n"): | |
| 248 | 255 | field_data = field_data[:-1] |
| 249 | ||
| 256 | ||
| 250 | 257 | # Parse Content-Disposition for filename |
| 251 | 258 | if 'filename="' in headers or "filename='" in headers: |
| 252 | 259 | # Extract filename |
| … | … | @@ -256,23 +263,24 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler): |
| 256 | 263 | quote_char = "'" |
| 257 | 264 | else: |
| 258 | 265 | quote_char = '"' |
| 259 | ||
| 266 | ||
| 260 | 267 | if filename_start != -1: |
| 261 | 268 | filename_start += 10 # len('filename="') |
| 262 | 269 | filename_end = headers.find(quote_char, filename_start) |
| 263 | 270 | if filename_end != -1: |
| 264 | 271 | filename = headers[filename_start:filename_end] |
| 265 | 272 | return filename, field_data |
| 266 | ||
| 273 | ||
| 267 | 274 | pos = next_boundary |
| 268 | ||
| 275 | ||
| 269 | 276 | return None, "No file found in upload" |
| 270 | 277 | |
| 271 | 278 | def _handle_upload(self): |
| 272 | 279 | """Handle multipart form data upload.""" |
| 273 | 280 | try: |
| 281 | ||
| 274 | 282 | filename, file_content = self._parse_multipart() |
| 275 | ||
| 283 | ||
| 276 | 284 | if filename is None: |
| 277 | 285 | error_msg = file_content |
| 278 | 286 | kodilog(f"[SubtitleServer] Upload error: {error_msg}") |
| … | … | @@ -280,13 +288,16 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler): |
| 280 | 288 | return |
| 281 | 289 | |
| 282 | 290 | filename = os.path.basename(filename) |
| 283 | ||
| 291 | ||
| 284 | 292 | # Validate extension |
| 285 | 293 | if not _is_valid_extension(filename): |
| 286 | 294 | 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 | ) | |
| 290 | 301 | return |
| 291 | 302 | |
| 292 | 303 | # Save the file with unique name to avoid collisions |
| … | … | @@ -296,40 +307,41 @@ class SubtitleUploadHandler(BaseHTTPRequestHandler): |
| 296 | 307 | safe_filename = f"{timestamp}_{unique_id}_{filename}" |
| 297 | 308 | file_path = os.path.join(upload_dir, safe_filename) |
| 298 | 309 | |
| 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 | ) | |
| 301 | 316 | |
| 302 | 317 | kodilog(f"[SubtitleServer] File uploaded: {file_path}") |
| 303 | ||
| 318 | ||
| 304 | 319 | # Notify server instance |
| 305 | 320 | wrapper = self.wrapper_server |
| 306 | 321 | if wrapper: |
| 307 | 322 | wrapper.set_uploaded_file(file_path) |
| 308 | 323 | else: |
| 309 | 324 | kodilog("[SubtitleServer] Warning: wrapper_server not available") |
| 310 | ||
| 325 | ||
| 311 | 326 | # 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}) | |
| 319 | 330 | else: |
| 320 | 331 | self._send_success_page(filename) |
| 321 | 332 | |
| 322 | 333 | except Exception as e: |
| 323 | 334 | kodilog(f"[SubtitleServer] Upload error: {e}") |
| 324 | 335 | import traceback |
| 336 | ||
| 325 | 337 | kodilog(traceback.format_exc()) |
| 326 | 338 | self._send_json({"error": str(e)}, 500) |
| 327 | 339 | |
| 328 | 340 | |
| 329 | 341 | class SubtitleUploadServer: |
| 330 | 342 | """Threaded HTTP server for subtitle uploads.""" |
| 331 | ||
| 332 | 343 | def __init__(self, port=8082): |
| 344 | ||
| 333 | 345 | self.port = port |
| 334 | 346 | self._server = None |
| 335 | 347 | self._thread = None |
| … | … | @@ -343,9 +355,7 @@ class SubtitleUploadServer: |
| 343 | 355 | self._server = HTTPServer(("0.0.0.0", self.port), SubtitleUploadHandler) |
| 344 | 356 | # Store reference to this wrapper so handlers can access it |
| 345 | 357 | 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) | |
| 349 | 359 | self._thread.start() |
| 350 | 360 | kodilog(f"[SubtitleServer] Started on port {self.port}") |
| 351 | 361 | except OSError: |
| … | … | @@ -354,9 +364,7 @@ class SubtitleUploadServer: |
| 354 | 364 | try: |
| 355 | 365 | self._server = HTTPServer(("0.0.0.0", self.port), SubtitleUploadHandler) |
| 356 | 366 | 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) | |
| 360 | 368 | self._thread.start() |
| 361 | 369 | kodilog(f"[SubtitleServer] Started on fallback port {self.port}") |
| 362 | 370 | except Exception as e: |
| … | … | @@ -380,6 +388,7 @@ class SubtitleUploadServer: |
| 380 | 388 | def set_uploaded_file(self, file_path): |
| 381 | 389 | """Called by handler when file is uploaded.""" |
| 382 | 390 | self._uploaded_file = file_path |
| 391 | ||
| 383 | 392 | self._upload_event.set() |
| 384 | 393 | |
| 385 | 394 | def get_uploaded_file(self): |
| … | … | @@ -398,6 +407,7 @@ class SubtitleUploadServer: |
| 398 | 407 | def _is_port_available(port): |
| 399 | 408 | """Check if a port is available for binding.""" |
| 400 | 409 | try: |
| 410 | ||
| 401 | 411 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| 402 | 412 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) |
| 403 | 413 | s.bind(("0.0.0.0", port)) |
| … | … | @@ -410,6 +420,7 @@ def _is_port_available(port): |
| 410 | 420 | def find_available_port(preferred=8082, max_attempts=10): |
| 411 | 421 | """Find an available port, starting from the preferred one.""" |
| 412 | 422 | for offset in range(max_attempts): |
| 423 | ||
| 413 | 424 | port = preferred + offset |
| 414 | 425 | if _is_port_available(port): |
| 415 | 426 | return port |
| … | … | @@ -427,6 +438,7 @@ def find_available_port(preferred=8082, max_attempts=10): |
| 427 | 438 | def get_local_ip(): |
| 428 | 439 | """Get the machine's LAN IP address.""" |
| 429 | 440 | try: |
| 441 | ||
| 430 | 442 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
| 431 | 443 | s.connect(("8.8.8.8", 80)) |
| 432 | 444 | ip = s.getsockname()[0] |
| … | … | @@ -1,8 +1,9 @@ |
| 1 | 1 | from time import monotonic |
| 2 | 2 | |
| 3 | 3 | import xbmc |
| 4 | from lib.api.trakt.trakt import TraktAPI | |
| 4 | ||
| 5 | 5 | from lib.api.trakt.lists_cache import lists_cache |
| 6 | from lib.api.trakt.trakt import TraktAPI | |
| 6 | 7 | from lib.api.trakt.trakt_cache import ( |
| 7 | 8 | clear_trakt_calendar, |
| 8 | 9 | clear_trakt_favorites, |
| … | … | @@ -13,13 +14,13 @@ from lib.api.trakt.trakt_cache import ( |
| 13 | 14 | reset_activity, |
| 14 | 15 | trakt_watched_cache, |
| 15 | 16 | ) |
| 16 | from lib.utils.kodi.utils import get_property_no_fallback, kodilog, get_setting | |
| 17 | ||
| 17 | from lib.utils.kodi.utils import get_property_no_fallback, get_setting, kodilog | |
| 18 | 18 | |
| 19 | 19 | PAUSE_SERVICES_PROP = "jacktook.pause_services" |
| 20 | 20 | DEFAULT_SYNC_INTERVAL_MINUTES = 15 |
| 21 | 21 | WAIT_STEP_SECONDS = 5 |
| 22 | 22 | |
| 23 | ||
| 23 | 24 | class TraktSyncService: |
| 24 | 25 | def __init__(self, api=None, monitor=None): |
| 25 | 26 | self.api = api or TraktAPI() |
| … | … | @@ -48,22 +49,12 @@ class TraktSyncService: |
| 48 | 49 | ): |
| 49 | 50 | duration_ms = int((monotonic() - started_at) * 1000) |
| 50 | 51 | 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}" | |
| 61 | 53 | ) |
| 62 | 54 | |
| 63 | 55 | def _log_sync_failure(self, cycle_context, force, error): |
| 64 | 56 | 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}", | |
| 67 | 58 | level=xbmc.LOGERROR, |
| 68 | 59 | ) |
| 69 | 60 | |
| … | … | @@ -173,10 +164,7 @@ class TraktSyncService: |
| 173 | 164 | ) |
| 174 | 165 | return applied_buckets |
| 175 | 166 | |
| 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)))) | |
| 180 | 168 | self._apply_activity_changes(applied_buckets) |
| 181 | 169 | self._log_sync_summary( |
| 182 | 170 | cycle_context, |
| … | … | @@ -1,33 +1,33 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | 1 | """ |
| 3 | 2 | Local web server for managing Stremio addon sources from a phone/browser. |
| 4 | 3 | |
| 5 | 4 | Serves a web UI and exposes small JSON endpoints for CRUD operations |
| 6 | 5 | on the Stremio custom addons stored in the Kodi addon cache. |
| 7 | 6 | """ |
| 7 | ||
| 8 | import ipaddress | |
| 8 | 9 | import json |
| 9 | 10 | import os |
| 10 | 11 | import socket |
| 11 | 12 | import threading |
| 12 | import ipaddress | |
| 13 | 13 | from datetime import timedelta |
| 14 | from http.server import HTTPServer, BaseHTTPRequestHandler | |
| 14 | from http.server import BaseHTTPRequestHandler, HTTPServer | |
| 15 | 15 | from urllib.parse import urlsplit, urlunsplit |
| 16 | 16 | |
| 17 | 17 | import requests |
| 18 | 18 | |
| 19 | from lib.db.cached import cache | |
| 20 | from lib.utils.general.utils import USER_AGENT_HEADER | |
| 21 | from lib.utils.kodi.utils import kodilog | |
| 19 | from lib.api.stremio.addon_manager import build_addon_instance_key | |
| 22 | 20 | from lib.clients.stremio.constants import ( |
| 23 | STREMIO_ADDONS_KEY, | |
| 24 | 21 | STREMIO_ADDONS_CATALOGS_KEY, |
| 22 | STREMIO_ADDONS_KEY, | |
| 25 | 23 | STREMIO_TV_ADDONS_KEY, |
| 26 | 24 | STREMIO_USER_ADDONS, |
| 27 | encode_selected_ids, | |
| 28 | 25 | decode_selected_ids, |
| 26 | encode_selected_ids, | |
| 29 | 27 | ) |
| 30 | from lib.api.stremio.addon_manager import build_addon_instance_key | |
| 28 | from lib.db.cached import cache | |
| 29 | from lib.utils.general.utils import USER_AGENT_HEADER | |
| 30 | from lib.utils.kodi.utils import kodilog | |
| 31 | 31 | |
| 32 | 32 | _CACHE_EXPIRY = timedelta(days=365 * 20) |
| 33 | 33 | _WEB_DIR = os.path.join( |
| … | … | @@ -44,11 +44,7 @@ _WEB_DIR = os.path.join( |
| 44 | 44 | |
| 45 | 45 | def _get_user_addons(): |
| 46 | 46 | """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"] | |
| 52 | 48 | |
| 53 | 49 | |
| 54 | 50 | def _get_selected_ids(): |
| … | … | @@ -65,6 +61,7 @@ def _get_selected_ids(): |
| 65 | 61 | |
| 66 | 62 | def _addon_capabilities(manifest): |
| 67 | 63 | """Determine stream/catalog/tv capabilities from a manifest dict.""" |
| 64 | ||
| 68 | 65 | def supports_searchable_stream(prefixes): |
| 69 | 66 | normalized = {str(prefix).rstrip(":") for prefix in (prefixes or []) if prefix} |
| 70 | 67 | return bool(normalized.intersection({"tt", "tmdb"})) |
| … | … | @@ -84,9 +81,10 @@ def _addon_capabilities(manifest): |
| 84 | 81 | |
| 85 | 82 | if res_name == "stream": |
| 86 | 83 | # 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): | |
| 90 | 88 | is_stream = True |
| 91 | 89 | |
| 92 | 90 | if any(t in res_types for t in ("tv", "channel")): |
| … | … | @@ -97,7 +95,9 @@ def _addon_capabilities(manifest): |
| 97 | 95 | |
| 98 | 96 | elif isinstance(res, str): |
| 99 | 97 | 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): | |
| 101 | 101 | is_stream = True |
| 102 | 102 | if any(t in types for t in ("tv", "channel")): |
| 103 | 103 | is_tv = True |
| … | … | @@ -178,16 +178,11 @@ def _sync_add_addon(url): |
| 178 | 178 | kodilog(f"[WebServer] Failed to add addon: {error}") |
| 179 | 179 | return None, f"Failed to fetch manifest: {error or 'unknown error'}" |
| 180 | 180 | |
| 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}) | |
| 184 | 182 | |
| 185 | 183 | # Check duplicate |
| 186 | 184 | 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): | |
| 191 | 186 | return None, "This addon is already added." |
| 192 | 187 | |
| 193 | 188 | caps = _addon_capabilities(manifest) |
| … | … | @@ -214,11 +209,7 @@ def _sync_add_addon(url): |
| 214 | 209 | def _sync_remove_addon(addon_id): |
| 215 | 210 | """Remove addon by manifest ID from all caches.""" |
| 216 | 211 | 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] | |
| 222 | 213 | cache.set(STREMIO_USER_ADDONS, new_addons, _CACHE_EXPIRY) |
| 223 | 214 | |
| 224 | 215 | for cache_key in [ |
| … | … | @@ -348,9 +339,7 @@ class StremioRequestHandler(BaseHTTPRequestHandler): |
| 348 | 339 | def do_OPTIONS(self): |
| 349 | 340 | self.send_response(200) |
| 350 | 341 | 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") | |
| 354 | 343 | self.send_header("Access-Control-Allow-Headers", "Content-Type") |
| 355 | 344 | self.end_headers() |
| 356 | 345 | |
| … | … | @@ -359,7 +348,7 @@ class StremioRequestHandler(BaseHTTPRequestHandler): |
| 359 | 348 | def _serve_index(self): |
| 360 | 349 | index_path = os.path.join(_WEB_DIR, "index.html") |
| 361 | 350 | try: |
| 362 | with open(index_path, "r", encoding="utf-8") as f: | |
| 351 | with open(index_path, encoding="utf-8") as f: | |
| 363 | 352 | self._send_html(f.read()) |
| 364 | 353 | except FileNotFoundError: |
| 365 | 354 | self.send_error(500, "Web UI not found") |
| … | … | @@ -500,9 +489,7 @@ class StremioWebServer: |
| 500 | 489 | return |
| 501 | 490 | try: |
| 502 | 491 | 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) | |
| 506 | 493 | self._thread.start() |
| 507 | 494 | kodilog(f"[WebServer] Started on port {self.port}") |
| 508 | 495 | except Exception as e: |
| … | … | @@ -0,0 +1,78 @@ |
| 1 | import contextlib | |
| 2 | from time import time | |
| 3 | ||
| 4 | import xbmc | |
| 5 | ||
| 6 | from lib.utils.kodi.utils import ( | |
| 7 | execute_builtin, | |
| 8 | get_property, | |
| 9 | get_setting, | |
| 10 | kodilog, | |
| 11 | notification, | |
| 12 | ) | |
| 13 | ||
| 14 | ||
| 15 | class 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) |
| … | … | @@ -1,32 +1,32 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | import shutil | |
| 3 | import requests | |
| 1 | import contextlib | |
| 4 | 2 | import os |
| 5 | from zipfile import ZipFile, BadZipFile | |
| 3 | import shutil | |
| 6 | 4 | import xml.etree.ElementTree as ET |
| 5 | from zipfile import BadZipFile, ZipFile | |
| 6 | ||
| 7 | import requests | |
| 7 | 8 | import xbmc |
| 8 | 9 | from xbmcvfs import translatePath as translate_path |
| 9 | 10 | |
| 11 | from lib.utils.general.utils import unzip | |
| 10 | 12 | from lib.utils.kodi.utils import ( |
| 11 | 13 | ADDON_VERSION, |
| 12 | notification, | |
| 13 | dialog_ok, | |
| 14 | dialogyesno, | |
| 15 | 14 | close_all_dialog, |
| 16 | execute_builtin, | |
| 15 | close_busy_dialog, | |
| 17 | 16 | delete_file, |
| 18 | update_local_addons, | |
| 19 | disable_enable_addon, | |
| 20 | update_kodi_addons_db, | |
| 21 | dialog_text, | |
| 17 | dialog_ok, | |
| 22 | 18 | dialog_select, |
| 23 | show_busy_dialog, | |
| 24 | close_busy_dialog, | |
| 19 | dialog_text, | |
| 20 | dialogyesno, | |
| 21 | disable_enable_addon, | |
| 22 | execute_builtin, | |
| 25 | 23 | kodilog, |
| 24 | notification, | |
| 25 | show_busy_dialog, | |
| 26 | 26 | translation, |
| 27 | update_kodi_addons_db, | |
| 28 | update_local_addons, | |
| 27 | 29 | ) |
| 28 | from lib.utils.general.utils import unzip | |
| 29 | ||
| 30 | 30 | |
| 31 | 31 | # ========================= |
| 32 | 32 | # Constants |
| … | … | @@ -44,9 +44,10 @@ CHANGELOG_PATH = translate_path(f"special://home/addons/{ADDON_ID}/CHANGELOG.md" |
| 44 | 44 | # |
| 45 | 45 | # This tree carries local patches (Stremio behaviorHints.proxyHeaders plumbing), |
| 46 | 46 | # 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. | |
| 50 | 51 | # |
| 51 | 52 | # Served by the kodi-repo unit on stortron, which tracks the addon repo from the |
| 52 | 53 | # 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): |
| 127 | 128 | |
| 128 | 129 | xml_data = zip_file.read(addon_xml) |
| 129 | 130 | except (BadZipFile, KeyError, ValueError) as exc: |
| 130 | raise ValueError(str(exc)) | |
| 131 | raise ValueError(str(exc)) from exc | |
| 131 | 132 | |
| 132 | 133 | try: |
| 133 | 134 | version = ET.fromstring(xml_data).attrib.get("version") |
| 134 | 135 | except ET.ParseError as exc: |
| 135 | raise ValueError(f"Invalid addon.xml: {exc}") | |
| 136 | raise ValueError(f"Invalid addon.xml: {exc}") from exc | |
| 136 | 137 | |
| 137 | 138 | if version != expected_version: |
| 138 | 139 | raise ValueError( |
| 139 | "Package version mismatch: expected %s, found %s" | |
| 140 | % (expected_version, version) | |
| 140 | f"Package version mismatch: expected {expected_version}, found {version}" | |
| 141 | 141 | ) |
| 142 | 142 | |
| 143 | 143 | |
| … | … | @@ -149,8 +149,7 @@ def _validate_installed_version(destination_dir, expected_version): |
| 149 | 149 | version = _read_addon_version_from_xml(addon_xml_path) |
| 150 | 150 | if version != expected_version: |
| 151 | 151 | raise ValueError( |
| 152 | "Installed version mismatch: expected %s, found %s" | |
| 153 | % (expected_version, version) | |
| 152 | f"Installed version mismatch: expected {expected_version}, found {version}" | |
| 154 | 153 | ) |
| 155 | 154 | |
| 156 | 155 | |
| … | … | @@ -264,9 +263,7 @@ def downgrade_addon_menu(): |
| 264 | 263 | |
| 265 | 264 | versions.sort(key=n, reverse=True) |
| 266 | 265 | |
| 267 | selected_index = dialog_select( | |
| 268 | heading=translation(90585), _list=versions | |
| 269 | ) | |
| 266 | selected_index = dialog_select(heading=translation(90585), _list=versions) | |
| 270 | 267 | |
| 271 | 268 | if selected_index == -1: |
| 272 | 269 | return |
| … | … | @@ -341,13 +338,9 @@ def update_addon(new_version): |
| 341 | 338 | except Exception as e: |
| 342 | 339 | kodilog(f"Error extracting update to staging: {e}") |
| 343 | 340 | delete_file(zip_path) |
| 344 | try: | |
| 341 | with contextlib.suppress(Exception): | |
| 345 | 342 | _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)) | |
| 351 | 344 | return |
| 352 | 345 | |
| 353 | 346 | replaced_existing_install = False |
| … | … | @@ -374,10 +367,8 @@ def update_addon(new_version): |
| 374 | 367 | kodilog(f"Error restoring previous addon version: {restore_error}") |
| 375 | 368 | |
| 376 | 369 | delete_file(zip_path) |
| 377 | try: | |
| 370 | with contextlib.suppress(Exception): | |
| 378 | 371 | _safe_remove_path(staging_dir) |
| 379 | except Exception: | |
| 380 | pass | |
| 381 | 372 | dialog_ok(heading=HEADING, line1=translation(90590)) |
| 382 | 373 | return |
| 383 | 374 | |
| … | … | @@ -399,7 +390,7 @@ def update_addon(new_version): |
| 399 | 390 | disable_enable_addon() |
| 400 | 391 | update_kodi_addons_db() |
| 401 | 392 | 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) | |
| 403 | 394 | |
| 404 | 395 | notification(heading=HEADING, message=translation(90593)) |
| 405 | 396 | kodilog("Update process finished successfully.") |
| … | … | @@ -1,7 +1,8 @@ |
| 1 | 1 | from typing import Any, Callable, Dict, Optional |
| 2 | from lib.utils.kodi.utils import get_setting, notification, translation | |
| 2 | ||
| 3 | 3 | from lib.utils.general.utils import Indexer |
| 4 | 4 | from lib.utils.kodi.settings import get_int_setting |
| 5 | from lib.utils.kodi.utils import get_setting, notification, translation | |
| 5 | 6 | |
| 6 | 7 | |
| 7 | 8 | def 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 |
| 23 | 24 | dialog.update(percent, translation(90661) % title, message) |
| 24 | 25 | |
| 25 | 26 | |
| 26 | def validate_credentials( | |
| 27 | indexer: str, host: Optional[str], api_key: Optional[str] = None | |
| 28 | ) -> bool: | |
| 27 | def validate_credentials(indexer: str, host: Optional[str], api_key: Optional[str] = None) -> bool: | |
| 29 | 28 | """ |
| 30 | 29 | Validates the host and API key for a given indexer. |
| 31 | 30 | """ |
| 32 | 31 | if not validate_host(host, indexer): |
| 33 | 32 | 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)) | |
| 37 | 34 | |
| 38 | 35 | |
| 39 | 36 | def build_jackett_client() -> Optional[object]: |
| … | … | @@ -100,12 +97,36 @@ def build_easynews_client() -> Optional[object]: |
| 100 | 97 | return Easynews(user, password, timeout, notification) |
| 101 | 98 | |
| 102 | 99 | |
| 100 | def 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 | ||
| 103 | 123 | CLIENT_BUILDERS: Dict[str, Callable[[], Optional[object]]] = { |
| 104 | 124 | Indexer.JACKETT: build_jackett_client, |
| 105 | 125 | Indexer.PROWLARR: build_prowlarr_client, |
| 106 | 126 | Indexer.JACKGRAM: build_jackgram_client, |
| 107 | 127 | Indexer.BURST: build_burst_client, |
| 108 | 128 | Indexer.EASYNEWS: build_easynews_client, |
| 129 | Indexer.EXTERNAL_SCRAPER: build_external_scraper_client, | |
| 109 | 130 | } |
| 110 | 131 | |
| 111 | 132 |
| … | … | @@ -1,23 +1,26 @@ |
| 1 | 1 | import base64 |
| 2 | from concurrent.futures import ThreadPoolExecutor | |
| 3 | 2 | import copy |
| 4 | 3 | import json |
| 5 | import requests | |
| 4 | from concurrent.futures import ThreadPoolExecutor | |
| 6 | 5 | from threading import Lock |
| 7 | 6 | from typing import Any, Dict, List, Optional, Tuple |
| 8 | 7 | from urllib.parse import urljoin |
| 9 | 8 | |
| 9 | import requests | |
| 10 | from xbmc import LOGDEBUG | |
| 11 | from xbmcgui import Dialog | |
| 12 | ||
| 10 | 13 | from lib.api.debrid.base import ProviderException |
| 11 | from lib.db.cached import cache | |
| 12 | 14 | from lib.clients.debrid.alldebrid import AllDebridHelper |
| 13 | 15 | from lib.clients.debrid.debrider import DebriderHelper |
| 14 | 16 | from lib.clients.debrid.easydebrid import EasyDebridHelper |
| 17 | from lib.clients.debrid.offcloud import OffcloudHelper | |
| 15 | 18 | from lib.clients.debrid.premiumize import PremiumizeHelper |
| 16 | from lib.clients.debrid.torbox import TorboxHelper | |
| 17 | 19 | from lib.clients.debrid.realdebrid import RealDebridHelper |
| 18 | from lib.clients.stremio.constants import STREMIO_ADDONS_KEY | |
| 19 | from lib.utils.kodi.utils import get_setting, kodilog, notification, translation | |
| 20 | from lib.utils.torrent.torrserver_utils import extract_torrent_metadata | |
| 20 | from lib.clients.debrid.torbox import TorboxHelper | |
| 21 | from lib.clients.stremio.constants import STREMIO_ADDON_ALIASES_KEY, STREMIO_ADDONS_KEY | |
| 22 | from lib.db.cached import cache | |
| 23 | from lib.domain.torrent import TorrentStream | |
| 21 | 24 | from lib.utils.general.utils import ( |
| 22 | 25 | USER_AGENT_HEADER, |
| 23 | 26 | DebridType, |
| … | … | @@ -29,23 +32,22 @@ from lib.utils.general.utils import ( |
| 29 | 32 | is_ad_enabled, |
| 30 | 33 | is_debrider_enabled, |
| 31 | 34 | is_ed_enabled, |
| 35 | is_oc_enabled, | |
| 32 | 36 | is_pm_enabled, |
| 33 | 37 | is_rd_enabled, |
| 34 | 38 | is_tb_enabled, |
| 35 | 39 | is_url, |
| 36 | 40 | set_cached, |
| 37 | 41 | ) |
| 38 | from lib.domain.torrent import TorrentStream | |
| 39 | 42 | from lib.utils.kodi.logging import summarize_locator_for_log |
| 40 | ||
| 41 | from xbmcgui import Dialog | |
| 42 | from xbmc import LOGDEBUG | |
| 43 | ||
| 43 | from lib.utils.kodi.utils import get_setting, kodilog, notification, translation | |
| 44 | from lib.utils.torrent.torrserver_utils import extract_torrent_metadata | |
| 44 | 45 | |
| 45 | 46 | DEBRID_HELPERS = { |
| 46 | 47 | DebridType.RD: RealDebridHelper, |
| 47 | 48 | DebridType.PM: PremiumizeHelper, |
| 48 | 49 | DebridType.TB: TorboxHelper, |
| 50 | DebridType.OC: OffcloudHelper, | |
| 49 | 51 | DebridType.DB: DebriderHelper, |
| 50 | 52 | DebridType.AD: AllDebridHelper, |
| 51 | 53 | DebridType.ED: EasyDebridHelper, |
| … | … | @@ -54,17 +56,19 @@ DEBRID_HELPERS = { |
| 54 | 56 | DEBRID_CHECKS = { |
| 55 | 57 | DebridType.RD: is_rd_enabled, |
| 56 | 58 | DebridType.TB: is_tb_enabled, |
| 59 | DebridType.OC: is_oc_enabled, | |
| 57 | 60 | DebridType.PM: is_pm_enabled, |
| 58 | 61 | DebridType.DB: is_debrider_enabled, |
| 59 | 62 | DebridType.AD: is_ad_enabled, |
| 60 | 63 | } |
| 61 | 64 | |
| 62 | PACK_DIRECT_DEBRID_TYPES = {DebridType.RD, DebridType.TB, DebridType.AD} | |
| 65 | PACK_DIRECT_DEBRID_TYPES = {DebridType.RD, DebridType.TB, DebridType.OC, DebridType.AD} | |
| 63 | 66 | |
| 64 | 67 | SUPPORTED_CLOUD_TRANSFER_DEBRIDS = ( |
| 65 | 68 | DebridType.RD, |
| 66 | 69 | DebridType.AD, |
| 67 | 70 | DebridType.TB, |
| 71 | DebridType.OC, | |
| 68 | 72 | DebridType.DB, |
| 69 | 73 | ) |
| 70 | 74 | |
| … | … | @@ -76,14 +80,19 @@ def _build_debrid_cache_scope() -> str: |
| 76 | 80 | "rd": bool(get_setting("real_debrid_enabled")), |
| 77 | 81 | "ad": bool(get_setting("alldebrid_enabled")), |
| 78 | 82 | "tb": bool(get_setting("torbox_enabled")), |
| 83 | "oc": bool(get_setting("offcloud_enabled")), | |
| 79 | 84 | "pm": bool(get_setting("premiumize_enabled")), |
| 80 | 85 | "db": bool(get_setting("debrider_enabled")), |
| 81 | 86 | "ed": bool(get_setting("easydebrid_enabled")), |
| 82 | 87 | } |
| 83 | 88 | 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( | |
| 85 | 93 | "|".join([f"{key}:{int(value)}" for key, value in sorted(flags.items())]), |
| 86 | 94 | selected_stream_addons, |
| 95 | stremio_addon_aliases, | |
| 87 | 96 | ) |
| 88 | 97 | |
| 89 | 98 | |
| … | … | @@ -125,7 +134,7 @@ def _is_torrent_ready_in_debrid(debrid_type: str, info_hash: str) -> bool: |
| 125 | 134 | """Check if a torrent is ready/cached in the specified debrid service.""" |
| 126 | 135 | if not info_hash: |
| 127 | 136 | return False |
| 128 | ||
| 137 | ||
| 129 | 138 | try: |
| 130 | 139 | if debrid_type == DebridType.RD: |
| 131 | 140 | 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: |
| 138 | 147 | return True |
| 139 | 148 | elif debrid_type == DebridType.TB: |
| 140 | 149 | 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"): | |
| 144 | 161 | return True |
| 145 | 162 | elif debrid_type == DebridType.DB: |
| 146 | 163 | info = DebriderHelper().get_info(info_hash) |
| … | … | @@ -148,7 +165,7 @@ def _is_torrent_ready_in_debrid(debrid_type: str, info_hash: str) -> bool: |
| 148 | 165 | return True |
| 149 | 166 | except Exception as e: |
| 150 | 167 | kodilog(f"Error checking if torrent is ready in {debrid_type}: {e}") |
| 151 | ||
| 168 | ||
| 152 | 169 | return False |
| 153 | 170 | |
| 154 | 171 | |
| … | … | @@ -185,6 +202,14 @@ def _add_to_torbox(info_hash: str, torrent_data: bytes, torrent_name: str): |
| 185 | 202 | return None |
| 186 | 203 | |
| 187 | 204 | |
| 205 | def _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 | ||
| 188 | 213 | def _add_to_debrider(info_hash: str): |
| 189 | 214 | if not info_hash: |
| 190 | 215 | notification(translation(90361)) |
| … | … | @@ -197,6 +222,7 @@ _DEBRID_ADDERS = { |
| 197 | 222 | DebridType.RD: _add_to_realdebrid, |
| 198 | 223 | DebridType.AD: _add_to_alldebrid, |
| 199 | 224 | DebridType.TB: _add_to_torbox, |
| 225 | DebridType.OC: _add_to_offcloud, | |
| 200 | 226 | DebridType.DB: _add_to_debrider, |
| 201 | 227 | } |
| 202 | 228 | |
| … | … | @@ -215,23 +241,23 @@ def add_source_to_debrid( |
| 215 | 241 | adder = _DEBRID_ADDERS.get(debrid_type) |
| 216 | 242 | if not adder: |
| 217 | 243 | raise ValueError(f"Unsupported debrid cloud transfer type: {debrid_type}") |
| 218 | ||
| 244 | ||
| 219 | 245 | if debrid_type == DebridType.DB: |
| 220 | 246 | result = adder(info_hash) |
| 221 | 247 | else: |
| 222 | 248 | result = adder(info_hash, torrent_data, torrent_name) |
| 223 | ||
| 249 | ||
| 224 | 250 | if not result: |
| 225 | 251 | raise ProviderException(f"Failed to add torrent to {debrid_type}: no response data") |
| 226 | 252 | |
| 227 | 253 | kodilog(f"Debrid transfer succeeded: debrid={debrid_type}, result={result is not None}") |
| 228 | ||
| 254 | ||
| 229 | 255 | is_ready = _is_torrent_ready_in_debrid(debrid_type, info_hash) |
| 230 | 256 | if is_ready: |
| 231 | 257 | notification(translation(90362) % debrid_type) |
| 232 | 258 | else: |
| 233 | 259 | notification(translation(90694) % debrid_type) |
| 234 | ||
| 260 | ||
| 235 | 261 | return debrid_type |
| 236 | 262 | except Exception as exc: |
| 237 | 263 | kodilog(f"Failed to add source to debrid cloud: {exc}") |
| … | … | @@ -270,9 +296,7 @@ def check_debrid_cached( |
| 270 | 296 | kodilog("No debrid services enabled for caching check.") |
| 271 | 297 | return direct_results |
| 272 | 298 | |
| 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) | |
| 276 | 300 | |
| 277 | 301 | cached_results.extend(direct_results) |
| 278 | 302 | if should_include_uncached(): |
| … | … | @@ -315,9 +339,7 @@ def execute_debrid_checks( |
| 315 | 339 | dialog: Dialog, |
| 316 | 340 | lock: Lock, |
| 317 | 341 | ): |
| 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: | |
| 321 | 343 | futures = [ |
| 322 | 344 | executor.submit( |
| 323 | 345 | fn, |
| … | … | @@ -338,7 +360,7 @@ def execute_debrid_checks( |
| 338 | 360 | |
| 339 | 361 | |
| 340 | 362 | def 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( | |
| 342 | 364 | get_setting("show_uncached") |
| 343 | 365 | ) |
| 344 | 366 | |
| … | … | @@ -366,18 +388,18 @@ def get_source_status(res: TorrentStream) -> str: |
| 366 | 388 | |
| 367 | 389 | if res.isPack: |
| 368 | 390 | if is_cached: |
| 369 | label = f"[B]Cached-[Pack][/B]" | |
| 391 | label = "[B]Cached-[Pack][/B]" | |
| 370 | 392 | elif res.addedToDebrid: |
| 371 | 393 | label = f"[B]{added_label}-[Pack][/B]" |
| 372 | 394 | else: |
| 373 | label = f"[B]Download-[Pack][/B]" | |
| 395 | label = "[B]Download-[Pack][/B]" | |
| 374 | 396 | else: |
| 375 | 397 | if is_cached: |
| 376 | label = f"[B]Cached[/B]" | |
| 398 | label = "[B]Cached[/B]" | |
| 377 | 399 | elif res.addedToDebrid: |
| 378 | 400 | label = f"[B]{added_label}[/B]" |
| 379 | 401 | else: |
| 380 | label = f"[B]Download[/B]" | |
| 402 | label = "[B]Download[/B]" | |
| 381 | 403 | return label |
| 382 | 404 | |
| 383 | 405 | |
| … | … | @@ -385,9 +407,7 @@ def get_pack_info(debrid_type, info_hash): |
| 385 | 407 | return get_debrid_helper(debrid_type).get_pack_info(info_hash) |
| 386 | 408 | |
| 387 | 409 | |
| 388 | def filter_results( | |
| 389 | results: List[TorrentStream], direct_results: List[TorrentStream] | |
| 390 | ) -> None: | |
| 410 | def filter_results(results: List[TorrentStream], direct_results: List[TorrentStream]) -> None: | |
| 391 | 411 | filtered_results = [] |
| 392 | 412 | dropped_results = [] |
| 393 | 413 | |
| … | … | @@ -412,11 +432,10 @@ def extract_info_hash(res: TorrentStream) -> Optional[str]: |
| 412 | 432 | if res.infoHash: |
| 413 | 433 | return res.infoHash.lower() |
| 414 | 434 | |
| 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() | |
| 420 | 439 | |
| 421 | 440 | if res.url and res.url.startswith("magnet:?"): |
| 422 | 441 | 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]: |
| 440 | 459 | |
| 441 | 460 | try: |
| 442 | 461 | current_uri = uri |
| 443 | for redirect_count in range(5): | |
| 462 | for _redirect_count in range(5): | |
| 444 | 463 | res = requests.get( |
| 445 | 464 | current_uri, |
| 446 | 465 | allow_redirects=False, |
| … | … | @@ -448,10 +467,7 @@ def get_torrent_data_from_uri(uri: str) -> Tuple[bytes, str, str, str]: |
| 448 | 467 | headers=USER_AGENT_HEADER, |
| 449 | 468 | ) |
| 450 | 469 | 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}" | |
| 455 | 471 | ) |
| 456 | 472 | |
| 457 | 473 | if 300 <= res.status_code < 400: |
| … | … | @@ -460,10 +476,7 @@ def get_torrent_data_from_uri(uri: str) -> Tuple[bytes, str, str, str]: |
| 460 | 476 | break |
| 461 | 477 | |
| 462 | 478 | 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)}" | |
| 467 | 480 | ) |
| 468 | 481 | |
| 469 | 482 | if location.startswith("magnet:?"): |
| … | … | @@ -476,9 +489,7 @@ def get_torrent_data_from_uri(uri: str) -> Tuple[bytes, str, str, str]: |
| 476 | 489 | |
| 477 | 490 | if res.status_code == 200: |
| 478 | 491 | 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)}" | |
| 482 | 493 | ) |
| 483 | 494 | if res.url.startswith("magnet:"): |
| 484 | 495 | magnet = str(res.url or "") |
| … | … | @@ -487,21 +498,20 @@ def get_torrent_data_from_uri(uri: str) -> Tuple[bytes, str, str, str]: |
| 487 | 498 | |
| 488 | 499 | magnet = extract_torrent_metadata(res.content) |
| 489 | 500 | 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)}" | |
| 493 | 502 | ) |
| 494 | 503 | if magnet: |
| 495 | 504 | torrent_data = bytes(res.content or b"") |
| 496 | 505 | info_hash = get_info_hash_from_magnet(magnet).lower() |
| 497 | 506 | torrent_url = current_uri |
| 498 | 507 | 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)}" | |
| 502 | 509 | ) |
| 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 ""), | |
| 505 | 515 | ) |
| 506 | 516 | |
| 507 | 517 | break |
| … | … | @@ -570,9 +580,9 @@ def process_external_cache(data: dict, debrid: str, token: str, url: str): |
| 570 | 580 | }, |
| 571 | 581 | } |
| 572 | 582 | 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 | ) | |
| 576 | 586 | } |
| 577 | 587 | |
| 578 | 588 | elif service == "comet": |
| … | … | @@ -593,9 +603,7 @@ def process_external_cache(data: dict, debrid: str, token: str, url: str): |
| 593 | 603 | "remove_unknown_languages": False, |
| 594 | 604 | }, |
| 595 | 605 | } |
| 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") | |
| 599 | 607 | base_link = f"https://comet.elfhosted.com/{params_encoded}" |
| 600 | 608 | headers = {} |
| 601 | 609 |
| … | … | @@ -1,10 +1,11 @@ |
| 1 | 1 | from os import path |
| 2 | 2 | |
| 3 | import xbmcgui | |
| 4 | import xbmcvfs | |
| 5 | ||
| 3 | 6 | from lib.jacktook.utils import ADDON_PATH, kodilog |
| 4 | 7 | from lib.utils.kodi.utils import ADDON_PROFILE_PATH |
| 5 | 8 | from lib.vendor.segno import make as segnomake |
| 6 | import xbmcvfs | |
| 7 | import xbmcgui | |
| 8 | 9 | |
| 9 | 10 | |
| 10 | 11 | def make_qrcode(url): |
| … | … | @@ -1,52 +1,243 @@ |
| 1 | from lib.utils.kodi.utils import translation, get_setting | |
| 1 | from lib.utils.kodi.utils import get_setting, translation | |
| 2 | 2 | from lib.utils.views.continue_watching import has_continue_watching_items |
| 3 | 3 | |
| 4 | ||
| 5 | 4 | trakt_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 | }, | |
| 15 | 68 | ] |
| 16 | 69 | |
| 17 | 70 | |
| 18 | 71 | trakt_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 | }, | |
| 26 | 121 | ] |
| 27 | 122 | |
| 28 | 123 | |
| 29 | 124 | trakt_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 | }, | |
| 39 | 188 | ] |
| 40 | 189 | |
| 41 | 190 | |
| 42 | 191 | trakt_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 | }, | |
| 50 | 241 | ] |
| 51 | 242 | |
| 52 | 243 | |
| … | … | @@ -344,17 +535,13 @@ library_menu_items = [ |
| 344 | 535 | "name": 90202, |
| 345 | 536 | "icon": "tv.png", |
| 346 | 537 | "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}}], | |
| 350 | 539 | }, # My Shows |
| 351 | 540 | { |
| 352 | 541 | "name": 90203, |
| 353 | 542 | "icon": "movies.png", |
| 354 | 543 | "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}}], | |
| 358 | 545 | }, # My Movies |
| 359 | 546 | { |
| 360 | 547 | "name": 90021, |
| … | … | @@ -1,9 +1,10 @@ |
| 1 | from enum import Enum | |
| 2 | 1 | import re |
| 3 | from typing import List, Dict, Optional | |
| 2 | from enum import Enum | |
| 3 | from typing import Dict, List, Optional | |
| 4 | 4 | |
| 5 | from lib.utils.kodi.utils import get_setting, kodilog | |
| 6 | 5 | from lib.clients.base import TorrentStream |
| 6 | from lib.utils.kodi.utils import get_setting, kodilog | |
| 7 | from lib.utils.parsers.title_parser import extract_codec_hdr | |
| 7 | 8 | |
| 8 | 9 | |
| 9 | 10 | class Quality(Enum): |
| … | … | @@ -166,11 +167,7 @@ class PreProcessBuilder(BaseProcessBuilder): |
| 166 | 167 | |
| 167 | 168 | def filter_season_packs(self, season_num: int) -> List[TorrentStream]: |
| 168 | 169 | 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)] | |
| 174 | 171 | |
| 175 | 172 | def filter_sources( |
| 176 | 173 | self, episode_name: str, episode_num: int, season_num: int |
| … | … | @@ -198,17 +195,13 @@ class PreProcessBuilder(BaseProcessBuilder): |
| 198 | 195 | patterns.append(re.escape(episode_name)) |
| 199 | 196 | |
| 200 | 197 | 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) | |
| 204 | 199 | ] |
| 205 | 200 | self.results = season_pack_results + episode_results |
| 206 | 201 | return self |
| 207 | 202 | |
| 208 | 203 | 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} | |
| 212 | 205 | for res in self.results: |
| 213 | 206 | title = res.title |
| 214 | 207 | matched_quality = False |
| … | … | @@ -235,9 +228,11 @@ class PreProcessBuilder(BaseProcessBuilder): |
| 235 | 228 | def filter_by_source(self) -> "PreProcessBuilder": |
| 236 | 229 | """ |
| 237 | 230 | Categorize torrents into SourceCategory buckets and filter them |
| 231 | ||
| 238 | 232 | depending on Kodi settings (quality_filter group). |
| 239 | 233 | """ |
| 240 | 234 | source_buckets: Dict[SourceCategory, List[TorrentStream]] = { |
| 235 | ||
| 241 | 236 | cat: [] for cat in SourceCategory |
| 242 | 237 | } |
| 243 | 238 | |
| … | … | @@ -291,3 +286,58 @@ class PreProcessBuilder(BaseProcessBuilder): |
| 291 | 286 | |
| 292 | 287 | self.results = filtered |
| 293 | 288 | 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 |
| … | … | @@ -1,35 +1,44 @@ |
| 1 | import contextlib | |
| 2 | import hashlib | |
| 3 | import json | |
| 1 | 4 | import os |
| 2 | 5 | import re |
| 3 | import hashlib | |
| 4 | 6 | import unicodedata |
| 5 | import json | |
| 7 | from collections import deque | |
| 8 | from concurrent.futures import ThreadPoolExecutor, as_completed | |
| 9 | from datetime import datetime, timedelta | |
| 6 | 10 | from email.utils import parsedate_to_datetime |
| 7 | 11 | from enum import Enum |
| 8 | from urllib.parse import unquote | |
| 9 | import requests | |
| 10 | 12 | from typing import Dict, List |
| 13 | from urllib.parse import unquote | |
| 11 | 14 | from zipfile import ZipFile |
| 12 | from concurrent.futures import ThreadPoolExecutor, as_completed | |
| 13 | from datetime import datetime, timedelta | |
| 15 | ||
| 16 | import requests | |
| 17 | import xbmc | |
| 18 | from xbmc import getSupportedMedia | |
| 19 | from xbmcgui import Dialog, DialogProgressBG | |
| 20 | from xbmcplugin import addDirectoryItem, setContent, setPluginCategory | |
| 14 | 21 | |
| 15 | 22 | from lib.api.fanart.fanart import get_fanart |
| 16 | from lib.clients.subtitle.utils import get_language_code | |
| 17 | from lib.gui.qr_progress_dialog import QRProgressDialog | |
| 18 | from lib.api.trakt.trakt_cache import trakt_watched_cache, trakt_cache | |
| 23 | from lib.api.tmdbv3api.tmdb import TMDb | |
| 19 | 24 | from lib.api.trakt.lists_cache import lists_cache |
| 20 | 25 | from lib.api.trakt.main_cache import main_cache |
| 21 | from lib.utils.debrid.qrcode_utils import make_qrcode | |
| 22 | from lib.utils.general.processors import PostProcessBuilder, PreProcessBuilder | |
| 23 | from lib.clients.base import TorrentStream | |
| 26 | from lib.api.trakt.trakt_cache import trakt_cache, trakt_watched_cache | |
| 24 | 27 | from lib.api.tvdbapi.tvdbapi import TVDBAPI |
| 25 | from lib.db.cached import cache | |
| 28 | from lib.clients.base import TorrentStream | |
| 26 | 29 | from lib.clients.stremio.constants import ( |
| 27 | STREMIO_ADDONS_KEY, | |
| 28 | 30 | STREMIO_ADDONS_CATALOGS_KEY, |
| 31 | STREMIO_ADDONS_KEY, | |
| 29 | 32 | STREMIO_TV_ADDONS_KEY, |
| 30 | 33 | STREMIO_USER_ADDONS, |
| 31 | 34 | ) |
| 35 | from lib.clients.subtitle.utils import get_language_code | |
| 36 | from lib.db.cached import cache | |
| 32 | 37 | from lib.db.pickle_db import PickleDatabase |
| 38 | from lib.gui.qr_progress_dialog import QRProgressDialog | |
| 39 | from lib.utils.debrid.qrcode_utils import make_qrcode | |
| 40 | from lib.utils.general.processors import PostProcessBuilder, PreProcessBuilder | |
| 41 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled | |
| 33 | 42 | from lib.utils.kodi.utils import ( |
| 34 | 43 | ADDON_HANDLE, |
| 35 | 44 | ADDON_PATH, |
| … | … | @@ -52,19 +61,8 @@ from lib.utils.kodi.utils import ( |
| 52 | 61 | translatePath, |
| 53 | 62 | translation, |
| 54 | 63 | ) |
| 55 | ||
| 56 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled | |
| 57 | 64 | from lib.vendor.torf._magnet import Magnet |
| 58 | 65 | |
| 59 | from collections import deque | |
| 60 | ||
| 61 | from xbmcgui import Dialog | |
| 62 | from xbmcgui import DialogProgressBG | |
| 63 | from xbmcplugin import addDirectoryItem, setContent, setPluginCategory | |
| 64 | from xbmc import getSupportedMedia | |
| 65 | import xbmc | |
| 66 | ||
| 67 | ||
| 68 | 66 | pickle_db = PickleDatabase() |
| 69 | 67 | |
| 70 | 68 | PROVIDER_COLOR_MIN_BRIGHTNESS = 128 |
| … | … | @@ -77,7 +75,24 @@ USER_AGENT_HEADER = { |
| 77 | 75 | |
| 78 | 76 | USER_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" |
| 79 | 77 | |
| 80 | TMDB_POSTER_URL = "http://image.tmdb.org/t/p/w780" | |
| 78 | TMDB_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 | ||
| 91 | def 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 | ||
| 81 | 96 | |
| 82 | 97 | MEDIA_FUSION_DEFAULT_KEY = "eJwBYACf_4hAkZJe85krAoD5hN50-2M0YuyGmgswr-cis3uap4FNnLMvSfOc4e1IcejWJmykujTnWAlQKRi9cct5k3IRqhu-wFBnDoe_QmwMjJI3FnQtFNp2u3jDo23THEEgKXHYqTMrLos=" |
| 83 | 98 | |
| … | … | @@ -189,6 +204,7 @@ class DebridType(Enum): |
| 189 | 204 | RD = "RealDebrid" |
| 190 | 205 | PM = "Premiumize" |
| 191 | 206 | TB = "Torbox" |
| 207 | OC = "Offcloud" | |
| 192 | 208 | ED = "EasyDebrid" |
| 193 | 209 | DB = "Debrider" |
| 194 | 210 | AD = "AllDebrid" |
| … | … | @@ -214,6 +230,7 @@ class Indexer(Enum): |
| 214 | 230 | BURST = "Burst" |
| 215 | 231 | ZILEAN = "Zilean" |
| 216 | 232 | EASYNEWS = "Easynews" |
| 233 | EXTERNAL_SCRAPER = "External Scraper" | |
| 217 | 234 | |
| 218 | 235 | |
| 219 | 236 | class SearchVariant(Enum): |
| … | … | @@ -268,6 +285,7 @@ torrent_indexers = [ |
| 268 | 285 | Indexer.PEERFLIX, |
| 269 | 286 | Indexer.ELHOSTED, |
| 270 | 287 | Indexer.BURST, |
| 288 | Indexer.EXTERNAL_SCRAPER, | |
| 271 | 289 | ] |
| 272 | 290 | |
| 273 | 291 | |
| … | … | @@ -291,10 +309,8 @@ class DialogListener: |
| 291 | 309 | return self |
| 292 | 310 | |
| 293 | 311 | def __exit__(self, exc_type, exc_val, exc_tb): |
| 294 | try: | |
| 312 | with contextlib.suppress(BaseException): | |
| 295 | 313 | self._dialog.close() |
| 296 | except: | |
| 297 | pass | |
| 298 | 314 | |
| 299 | 315 | |
| 300 | 316 | def is_debrid_activated(): |
| … | … | @@ -302,6 +318,7 @@ def is_debrid_activated(): |
| 302 | 318 | get_setting_fresh("real_debrid_enabled") |
| 303 | 319 | or get_setting_fresh("premiumize_enabled") |
| 304 | 320 | or get_setting_fresh("torbox_enabled") |
| 321 | or get_setting_fresh("offcloud_enabled") | |
| 305 | 322 | or get_setting_fresh("debrider_enabled") |
| 306 | 323 | or get_setting_fresh("alldebrid_enabled") |
| 307 | 324 | ) |
| … | … | @@ -314,6 +331,8 @@ def check_debrid_enabled(debrid_type): |
| 314 | 331 | return is_pm_enabled() |
| 315 | 332 | elif debrid_type == DebridType.TB: |
| 316 | 333 | return is_tb_enabled() |
| 334 | elif debrid_type == DebridType.OC: | |
| 335 | return is_oc_enabled() | |
| 317 | 336 | elif debrid_type == DebridType.DB: |
| 318 | 337 | return is_debrider_enabled() |
| 319 | 338 | elif debrid_type == DebridType.AD: |
| … | … | @@ -342,6 +361,10 @@ def is_tb_enabled(): |
| 342 | 361 | return get_setting_fresh("torbox_enabled") |
| 343 | 362 | |
| 344 | 363 | |
| 364 | def is_oc_enabled(): | |
| 365 | return get_setting_fresh("offcloud_enabled") | |
| 366 | ||
| 367 | ||
| 345 | 368 | def is_ed_enabled(): |
| 346 | 369 | return get_setting("easydebrid_enabled") |
| 347 | 370 | |
| … | … | @@ -381,7 +404,9 @@ def make_listing(data): |
| 381 | 404 | return list_item |
| 382 | 405 | |
| 383 | 406 | |
| 384 | def set_media_infoTag(list_item, data, fanart_data={}, mode="video", detailed=False): | |
| 407 | def set_media_infoTag(list_item, data, fanart_data=None, mode="video", detailed=False): | |
| 408 | if fanart_data is None: | |
| 409 | fanart_data = {} | |
| 385 | 410 | mode = "movies" if mode in ("movie", "movies") else mode |
| 386 | 411 | info_tag = list_item.getVideoInfoTag() |
| 387 | 412 | |
| … | … | @@ -563,9 +588,7 @@ def _set_trakt_ui_state(list_item, info_tag, data, mode): |
| 563 | 588 | if not tmdb_id or season is None or episode is None: |
| 564 | 589 | return |
| 565 | 590 | |
| 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) | |
| 569 | 592 | if is_watched: |
| 570 | 593 | info_tag.setPlaycount(1) |
| 571 | 594 | return |
| … | … | @@ -579,8 +602,7 @@ def _set_show_info(info_tag, data, mode): |
| 579 | 602 | seasons = list(data["seasons"]) |
| 580 | 603 | if isinstance(seasons, list): |
| 581 | 604 | 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 | |
| 584 | 606 | ] |
| 585 | 607 | info_tag.addSeasons(named_seasons) |
| 586 | 608 | else: |
| … | … | @@ -706,17 +728,17 @@ def build_actor(member, is_cast=True): |
| 706 | 728 | |
| 707 | 729 | def set_listitem_artwork(list_item, data, fanart_data): |
| 708 | 730 | 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")), | |
| 711 | 733 | ] |
| 712 | 734 | 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")), | |
| 716 | 738 | ] |
| 717 | 739 | 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")), | |
| 720 | 742 | ] |
| 721 | 743 | |
| 722 | 744 | clear_logo = [(extract_tmdb_logo_url(data), "original")] |
| … | … | @@ -760,6 +782,29 @@ def tmdb_url(path, size): |
| 760 | 782 | return f"http://image.tmdb.org/t/p/{size}{path}" if path else "" |
| 761 | 783 | |
| 762 | 784 | |
| 785 | def 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 | ||
| 763 | 808 | def build_media_metadata(ids, mode: str) -> dict: |
| 764 | 809 | metadata = { |
| 765 | 810 | "poster": "", |
| … | … | @@ -795,9 +840,7 @@ def build_media_metadata(ids, mode: str) -> dict: |
| 795 | 840 | if mode == "movies" and getattr(res, "movie_results", []): |
| 796 | 841 | tmdb_id = str(res.movie_results[0]["id"]) |
| 797 | 842 | 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", []): | |
| 801 | 844 | tmdb_id = str(res.tv_results[0]["id"]) |
| 802 | 845 | ids["tmdb_id"] = tmdb_id |
| 803 | 846 | |
| … | … | @@ -812,7 +855,15 @@ def build_media_metadata(ids, mode: str) -> dict: |
| 812 | 855 | |
| 813 | 856 | details = get_tmdb_media_details(tmdb_id, mode) |
| 814 | 857 | 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 | ||
| 816 | 867 | metadata["overview"] = getattr(details, "overview", "") |
| 817 | 868 | metadata["title"] = getattr(details, "title", getattr(details, "name", "")) |
| 818 | 869 | metadata["original_title"] = getattr( |
| … | … | @@ -919,9 +970,7 @@ def is_in_library(title): |
| 919 | 970 | |
| 920 | 971 | |
| 921 | 972 | def 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) | |
| 925 | 974 | cached = cache.get(identifier) |
| 926 | 975 | if cached: |
| 927 | 976 | return cached |
| … | … | @@ -959,13 +1008,17 @@ def cache_results(results, query, mode, media_type, episode, cache_scope=""): |
| 959 | 1008 | set_cached(results, query, params=("index", cache_scope)) |
| 960 | 1009 | |
| 961 | 1010 | |
| 962 | def get_cached(path, params={}): | |
| 963 | identifier = "{}|{}".format(path, params) | |
| 1011 | def get_cached(path, params=None): | |
| 1012 | if params is None: | |
| 1013 | params = {} | |
| 1014 | identifier = f"{path}|{params}" | |
| 964 | 1015 | return cache.get(identifier) |
| 965 | 1016 | |
| 966 | 1017 | |
| 967 | def set_cached(data, path, params={}): | |
| 968 | identifier = "{}|{}".format(path, params) | |
| 1018 | def set_cached(data, path, params=None): | |
| 1019 | if params is None: | |
| 1020 | params = {} | |
| 1021 | identifier = f"{path}|{params}" | |
| 969 | 1022 | cache.set( |
| 970 | 1023 | identifier, |
| 971 | 1024 | data, |
| … | … | @@ -974,7 +1027,7 @@ def set_cached(data, path, params={}): |
| 974 | 1027 | |
| 975 | 1028 | |
| 976 | 1029 | def db_get(name, func, path, params): |
| 977 | identifier = "{}|{}".format(path, params) | |
| 1030 | identifier = f"{path}|{params}" | |
| 978 | 1031 | data = cache.get(identifier) |
| 979 | 1032 | if not data: |
| 980 | 1033 | if name == "search_client": |
| … | … | @@ -987,8 +1040,10 @@ def db_get(name, func, path, params): |
| 987 | 1040 | return data |
| 988 | 1041 | |
| 989 | 1042 | |
| 990 | def tvdb_get(path, params={}): | |
| 991 | identifier = "{}|{}".format(path, params) | |
| 1043 | def tvdb_get(path, params=None): | |
| 1044 | if params is None: | |
| 1045 | params = {} | |
| 1046 | identifier = f"{path}|{params}" | |
| 992 | 1047 | data = cache.get(identifier) |
| 993 | 1048 | if data: |
| 994 | 1049 | return data |
| … | … | @@ -1043,9 +1098,7 @@ def get_random_color(provider_name, formatted=True): |
| 1043 | 1098 | spec = 10 |
| 1044 | 1099 | for i in range(0, 3): |
| 1045 | 1100 | 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) | |
| 1049 | 1102 | colors.append(int(max(rounded, PROVIDER_COLOR_MIN_BRIGHTNESS))) |
| 1050 | 1103 | |
| 1051 | 1104 | while (sum(colors) / 3) < PROVIDER_COLOR_MIN_BRIGHTNESS: |
| … | … | @@ -1135,12 +1188,14 @@ def clear_tmdb_cache(): |
| 1135 | 1188 | "collection_%", |
| 1136 | 1189 | "get_imdb_id%", |
| 1137 | 1190 | "latest_%", |
| 1191 | "tmdb_ui_meta", | |
| 1138 | 1192 | ] |
| 1139 | 1193 | cache.delete("movie_genres|None") |
| 1140 | 1194 | cache.delete("show_genres|None") |
| 1141 | 1195 | |
| 1142 | 1196 | for prefix in prefixes: |
| 1143 | 1197 | cache.delete_like(f"{prefix}|%") |
| 1198 | TMDb().cache_clear() | |
| 1144 | 1199 | notification(translation(30240)) |
| 1145 | 1200 | |
| 1146 | 1201 | |
| … | … | @@ -1241,7 +1296,7 @@ def unzip(zip_location, destination_location, destination_check): |
| 1241 | 1296 | status = True |
| 1242 | 1297 | else: |
| 1243 | 1298 | status = False |
| 1244 | except: | |
| 1299 | except Exception: | |
| 1245 | 1300 | status = False |
| 1246 | 1301 | return status |
| 1247 | 1302 | |
| … | … | @@ -1255,6 +1310,9 @@ def pre_process( |
| 1255 | 1310 | skip_episode_filter: bool = False, |
| 1256 | 1311 | ) -> List[TorrentStream]: |
| 1257 | 1312 | builder = PreProcessBuilder(results).remove_duplicates() |
| 1313 | builder.extract_codec_hdr() | |
| 1314 | builder.filter_by_codec() | |
| 1315 | builder.filter_by_hdr() | |
| 1258 | 1316 | if mode == "tv" and not skip_episode_filter: |
| 1259 | 1317 | builder.filter_sources(episode_name, episode, season) |
| 1260 | 1318 | builder.filter_by_source() |
| … | … | @@ -1284,13 +1342,7 @@ def filter_debrid_episode( |
| 1284 | 1342 | ep_minus_1 = str(episode_num - 1).zfill(2) |
| 1285 | 1343 | |
| 1286 | 1344 | 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 "" | |
| 1294 | 1346 | |
| 1295 | 1347 | # Normalize filenames for matching |
| 1296 | 1348 | def normalize_title(title): |
| … | … | @@ -1356,13 +1408,7 @@ def clean_auto_play_undesired(results: List[TorrentStream]) -> List[TorrentStrea |
| 1356 | 1408 | |
| 1357 | 1409 | def is_torrent_url(uri): |
| 1358 | 1410 | 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") | |
| 1366 | 1412 | |
| 1367 | 1413 | |
| 1368 | 1414 | def supported_video_extensions(): |
| … | … | @@ -1372,9 +1418,7 @@ def supported_video_extensions(): |
| 1372 | 1418 | |
| 1373 | 1419 | def add_next_button(func_name, page=1, **kwargs): |
| 1374 | 1420 | 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")}) | |
| 1378 | 1422 | |
| 1379 | 1423 | page += 1 |
| 1380 | 1424 | addDirectoryItem( |
| … | … | @@ -1506,7 +1550,7 @@ def show_log_export_dialog(params): |
| 1506 | 1550 | kodi_log_path = os.path.join(translatePath("special://logpath"), log_file) |
| 1507 | 1551 | if os.path.exists(kodi_log_path): |
| 1508 | 1552 | 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: | |
| 1510 | 1554 | lines = deque(f, maxlen=500) |
| 1511 | 1555 | |
| 1512 | 1556 | content = "".join(reversed(lines)) |
| … | … | @@ -1525,7 +1569,7 @@ def show_log_export_dialog(params): |
| 1525 | 1569 | copy2clip(paste_url) |
| 1526 | 1570 | progressDialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH) |
| 1527 | 1571 | progressDialog.setup( |
| 1528 | translation(90569), | |
| 1572 | translation(90569), | |
| 1529 | 1573 | qr_code, |
| 1530 | 1574 | paste_url, |
| 1531 | 1575 | is_debrid=False, |
| … | … | @@ -1537,7 +1581,7 @@ def show_log_export_dialog(params): |
| 1537 | 1581 | try: |
| 1538 | 1582 | count -= 1 |
| 1539 | 1583 | progressDialog.update_progress(count) |
| 1540 | except: | |
| 1584 | except Exception: | |
| 1541 | 1585 | pass |
| 1542 | 1586 | sleep(1000 * count) |
| 1543 | 1587 | else: |
| … | … | @@ -1656,8 +1700,8 @@ def parse_time(item): |
| 1656 | 1700 | if "," in normalized: |
| 1657 | 1701 | weekday_part, remainder = normalized.split(",", 1) |
| 1658 | 1702 | 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}" | |
| 1661 | 1705 | ) |
| 1662 | 1706 | |
| 1663 | 1707 | normalized = re.sub( |
| … | … | @@ -1698,17 +1742,17 @@ def format_season_episode(season, episode): |
| 1698 | 1742 | episode_value = normalize(episode) |
| 1699 | 1743 | |
| 1700 | 1744 | 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}" | |
| 1702 | 1746 | 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}" | |
| 1704 | 1748 | if season_value is not None: |
| 1705 | 1749 | 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}" | |
| 1708 | 1752 | if episode_value is not None: |
| 1709 | 1753 | 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}" | |
| 1712 | 1756 | return "" |
| 1713 | 1757 | |
| 1714 | 1758 | |
| … | … | @@ -1734,6 +1778,11 @@ def normalize_tv_data(tv_data): |
| 1734 | 1778 | |
| 1735 | 1779 | normalized = dict(tv_data) |
| 1736 | 1780 | |
| 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 | ||
| 1737 | 1786 | for key in ("season", "episode"): |
| 1738 | 1787 | value = _coerce_int(normalized.get(key)) |
| 1739 | 1788 | if value is None: |
| … | … | @@ -1,50 +1,486 @@ |
| 1 | 1 | video_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 | ) | |
| 7 | 64 | |
| 8 | 65 | music_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 | ) | |
| 13 | 123 | |
| 14 | 124 | picture_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 | ) | |
| 16 | 140 | |
| 17 | 141 | subtitle_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 | ) | |
| 19 | 157 | |
| 20 | 158 | text_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 | ) | |
| 48 | 484 | |
| 49 | 485 | |
| 50 | 486 | def _contains_extension(s, extensions): |
| … | … | @@ -1,9 +1,10 @@ |
| 1 | import json | |
| 2 | ||
| 1 | 3 | from lib.utils.kodi.utils import ( |
| 2 | 4 | action_url_run, |
| 3 | 5 | kodi_play_media, |
| 4 | 6 | translation, |
| 5 | 7 | ) |
| 6 | import json | |
| 7 | 8 | |
| 8 | 9 | |
| 9 | 10 | def add_last_files_context_menu(data): |
| … | … | @@ -3,7 +3,6 @@ from urllib.parse import urlsplit |
| 3 | 3 | |
| 4 | 4 | import xbmc |
| 5 | 5 | |
| 6 | ||
| 7 | 6 | _ERROR_PREFIX_RE = re.compile( |
| 8 | 7 | r"^(?:\[[^\]]+\]\s*)?(?:error|failed|traceback|exception|crash)\b", |
| 9 | 8 | re.IGNORECASE, |
| … | … | @@ -32,15 +31,15 @@ def summarize_locator_for_log(value): |
| 32 | 31 | magnet_match = re.search(r"btih:([A-Fa-f0-9]{8,40})", text) |
| 33 | 32 | if text.startswith("magnet:?"): |
| 34 | 33 | if magnet_match: |
| 35 | return "magnet:{}".format(magnet_match.group(1).lower()[:12]) | |
| 34 | return f"magnet:{magnet_match.group(1).lower()[:12]}" | |
| 36 | 35 | return "magnet" |
| 37 | 36 | |
| 38 | 37 | if re.fullmatch(r"[A-Fa-f0-9]{40}", text): |
| 39 | return "infohash:{}".format(text.lower()[:12]) | |
| 38 | return f"infohash:{text.lower()[:12]}" | |
| 40 | 39 | |
| 41 | 40 | if text.startswith(("http://", "https://")): |
| 42 | 41 | parts = urlsplit(text) |
| 43 | 42 | path = parts.path or "/" |
| 44 | return "{}://{}{}".format(parts.scheme, parts.netloc, path) | |
| 43 | return f"{parts.scheme}://{parts.netloc}{path}" | |
| 45 | 44 | |
| 46 | 45 | return text[:80] |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | from .utils import get_setting, get_property, ADDON_ID | |
| 2 | 1 | import xbmc |
| 3 | 2 | |
| 3 | from .utils import ADDON_ID, get_setting | |
| 4 | 4 | |
| 5 | 5 | EMPTY_USER = "unknown_user" |
| 6 | 6 | |
| … | … | @@ -50,7 +50,7 @@ def trakt_secret(): |
| 50 | 50 | |
| 51 | 51 | |
| 52 | 52 | def 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")) | |
| 54 | 54 | |
| 55 | 55 | |
| 56 | 56 | def get_update_action(): |
| … | … | @@ -1,11 +1,12 @@ |
| 1 | 1 | import json |
| 2 | 2 | import os |
| 3 | from datetime import datetime, timedelta, timezone | |
| 4 | 3 | import xml.etree.ElementTree as ET |
| 4 | from datetime import datetime, timedelta, timezone | |
| 5 | 5 | |
| 6 | 6 | import requests |
| 7 | 7 | import xbmcgui |
| 8 | 8 | |
| 9 | from lib.api.stremio.addon_manager import build_addon_instance_key | |
| 9 | 10 | from lib.clients.stremio.constants import ( |
| 10 | 11 | STREMIO_ADDONS_CATALOGS_KEY, |
| 11 | 12 | STREMIO_ADDONS_KEY, |
| … | … | @@ -14,7 +15,6 @@ from lib.clients.stremio.constants import ( |
| 14 | 15 | decode_selected_ids, |
| 15 | 16 | encode_selected_ids, |
| 16 | 17 | ) |
| 17 | from lib.api.stremio.addon_manager import build_addon_instance_key | |
| 18 | 18 | from lib.db.cached import cache |
| 19 | 19 | from lib.db.pickle_db import PickleDatabase |
| 20 | 20 | from lib.utils.general.utils import ( |
| … | … | @@ -37,11 +37,10 @@ from lib.utils.kodi.utils import ( |
| 37 | 37 | notification, |
| 38 | 38 | set_cached_setting_property, |
| 39 | 39 | set_setting, |
| 40 | translation, | |
| 41 | 40 | translatePath, |
| 41 | translation, | |
| 42 | 42 | ) |
| 43 | 43 | |
| 44 | ||
| 45 | 44 | BACKUP_VERSION = 1 |
| 46 | 45 | CUSTOM_ADDON_EXPIRY = timedelta(days=365 * 20) |
| 47 | 46 | SETTINGS_XML_PATH = os.path.join(ADDON_PATH, "resources", "settings.xml") |
| … | … | @@ -60,6 +59,7 @@ SENSITIVE_SETTING_IDS = { |
| 60 | 59 | "alldebrid_token", |
| 61 | 60 | "debrider_token", |
| 62 | 61 | "torbox_token", |
| 62 | "offcloud_token", | |
| 63 | 63 | "premiumize_token", |
| 64 | 64 | "easynews_password", |
| 65 | 65 | "stremio_pass", |
| … | … | @@ -81,6 +81,7 @@ IDENTITY_SETTING_IDS = { |
| 81 | 81 | "real_debrid_user", |
| 82 | 82 | "alldebrid_user", |
| 83 | 83 | "torbox_user", |
| 84 | "offcloud_user", | |
| 84 | 85 | "trakt_user", |
| 85 | 86 | } |
| 86 | 87 | |
| … | … | @@ -92,9 +93,7 @@ AUTH_STATE_SETTING_IDS = { |
| 92 | 93 | "is_trakt_auth", |
| 93 | 94 | } |
| 94 | 95 | |
| 95 | SCRUBBED_SETTING_IDS = ( | |
| 96 | SENSITIVE_SETTING_IDS | IDENTITY_SETTING_IDS | AUTH_STATE_SETTING_IDS | |
| 97 | ) | |
| 96 | SCRUBBED_SETTING_IDS = SENSITIVE_SETTING_IDS | IDENTITY_SETTING_IDS | AUTH_STATE_SETTING_IDS | |
| 98 | 97 | |
| 99 | 98 | EXTRA_DYNAMIC_SETTINGS = { |
| 100 | 99 | "trakt_token": "", |
| … | … | @@ -158,7 +157,9 @@ def _get_custom_stremio_addons(user_addons=None): |
| 158 | 157 | |
| 159 | 158 | def _get_custom_selection_map(custom_addons): |
| 160 | 159 | 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 | |
| 162 | 163 | } |
| 163 | 164 | selections = {} |
| 164 | 165 | for label, cache_key in CUSTOM_SELECTION_KEYS.items(): |
| … | … | @@ -245,7 +246,9 @@ def _normalize_custom_addons(custom_addons): |
| 245 | 246 | |
| 246 | 247 | def _filtered_custom_selection_map(cache_payload, custom_addons): |
| 247 | 248 | 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 | |
| 249 | 252 | } |
| 250 | 253 | raw_selections = cache_payload.get("custom_stremio_selections", {}) |
| 251 | 254 | filtered = {} |
| … | … | @@ -1,30 +1,35 @@ |
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | from datetime import date, datetime, timedelta | |
| 2 | import contextlib | |
| 3 | 3 | import json |
| 4 | 4 | import os |
| 5 | 5 | import re |
| 6 | import xml.etree.ElementTree as ET | |
| 7 | 6 | import sys |
| 8 | 7 | import time |
| 8 | import xml.etree.ElementTree as ET | |
| 9 | from datetime import date, datetime, timedelta | |
| 9 | 10 | from typing import Any, Union |
| 10 | ||
| 11 | 11 | from urllib.parse import quote, urlencode |
| 12 | from lib.db.cached import cache | |
| 13 | from lib.utils.kodi.logging import kodilog | |
| 14 | 12 | |
| 15 | 13 | import xbmc |
| 16 | 14 | import xbmcaddon |
| 17 | 15 | import xbmcgui |
| 18 | ||
| 19 | from xbmcgui import Window, ListItem | |
| 20 | from xbmcplugin import addDirectoryItems, setResolvedUrl, endOfDirectory | |
| 16 | from xbmcgui import ListItem, Window | |
| 17 | from xbmcplugin import addDirectoryItems, endOfDirectory, setResolvedUrl | |
| 18 | from xbmcvfs import ( | |
| 19 | File as xbmcvfs_File, | |
| 20 | ) | |
| 21 | 21 | from xbmcvfs import ( |
| 22 | translatePath as translate_path, | |
| 23 | 22 | delete as xbmc_delete, |
| 23 | ) | |
| 24 | from xbmcvfs import ( | |
| 24 | 25 | listdir, |
| 25 | File as xbmcvfs_File, | |
| 26 | ) | |
| 27 | from xbmcvfs import ( | |
| 28 | translatePath as translate_path, | |
| 26 | 29 | ) |
| 27 | 30 | |
| 31 | from lib.db.cached import cache | |
| 32 | from lib.utils.kodi.logging import kodilog | |
| 28 | 33 | |
| 29 | 34 | _URL = sys.argv[0] |
| 30 | 35 | |
| … | … | @@ -54,10 +59,43 @@ JACKTORR_ADDON = _get_jacktorr_addon() |
| 54 | 59 | |
| 55 | 60 | |
| 56 | 61 | ADDON = xbmcaddon.Addon() |
| 57 | try: | |
| 58 | ADDON_HANDLE = int(sys.argv[1]) | |
| 59 | except IndexError: | |
| 60 | ADDON_HANDLE = 0 | |
| 62 | ||
| 63 | ||
| 64 | class _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 | ||
| 97 | ADDON_HANDLE = _AddonHandle() | |
| 98 | ||
| 61 | 99 | |
| 62 | 100 | ADDON_PATH = ADDON.getAddonInfo("path") |
| 63 | 101 | ADDON_PROFILE_PATH = translate_path(ADDON.getAddonInfo("profile")) |
| … | … | @@ -69,9 +107,7 @@ ADDON_VERSION = ADDON.getAddonInfo("version") |
| 69 | 107 | ADDON_NAME = ADDON.getAddonInfo("name") |
| 70 | 108 | PLAYLIST = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) |
| 71 | 109 | |
| 72 | CHANGELOG_PATH = translate_path( | |
| 73 | "special://home/addons/plugin.video.jacktook/CHANGELOG.md" | |
| 74 | ) | |
| 110 | CHANGELOG_PATH = translate_path("special://home/addons/plugin.video.jacktook/CHANGELOG.md") | |
| 75 | 111 | |
| 76 | 112 | progressDialog = xbmcgui.DialogProgress() |
| 77 | 113 | |
| … | … | @@ -120,9 +156,7 @@ def rebuild_settings_snapshot(): |
| 120 | 156 | if os.path.exists(profile_xml): |
| 121 | 157 | root = ET.parse(profile_xml).getroot() |
| 122 | 158 | 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") | |
| 126 | 160 | } |
| 127 | 161 | else: |
| 128 | 162 | settings_dict = {} |
| … | … | @@ -227,12 +261,13 @@ def set_property(prop: str, value: Any): |
| 227 | 261 | |
| 228 | 262 | |
| 229 | 263 | def clear_property(prop): |
| 264 | cache.delete(prop) | |
| 230 | 265 | return Window(10000).clearProperty(prop) |
| 231 | 266 | |
| 232 | 267 | |
| 233 | 268 | def burst_addon_settings(): |
| 234 | 269 | close_all_dialog() |
| 235 | xbmc.executebuiltin("Addon.OpenSettings(%s)" % JACKTOOK_BURST_ADOON_ID) | |
| 270 | xbmc.executebuiltin(f"Addon.OpenSettings({JACKTOOK_BURST_ADOON_ID})") | |
| 236 | 271 | |
| 237 | 272 | |
| 238 | 273 | def get_kodi_version(): |
| … | … | @@ -253,7 +288,7 @@ def is_jacktorr_addon_enabled(): |
| 253 | 288 | try: |
| 254 | 289 | if not xbmc.getCondVisibility(f"System.HasAddon({JACKTORR_ADDON_ID})"): |
| 255 | 290 | return False |
| 256 | addon = xbmcaddon.Addon(JACKTORR_ADDON_ID) | |
| 291 | xbmcaddon.Addon(JACKTORR_ADDON_ID) | |
| 257 | 292 | # If the addon is disabled, this will raise RuntimeError |
| 258 | 293 | return True |
| 259 | 294 | except RuntimeError: |
| … | … | @@ -265,7 +300,7 @@ def is_youtube_addon_enabled(): |
| 265 | 300 | try: |
| 266 | 301 | if not xbmc.getCondVisibility(f"System.HasAddon({YOUTUBE_ADDON_ID})"): |
| 267 | 302 | return False |
| 268 | addon = xbmcaddon.Addon(YOUTUBE_ADDON_ID) | |
| 303 | xbmcaddon.Addon(YOUTUBE_ADDON_ID) | |
| 269 | 304 | # If the addon is disabled, this will raise RuntimeError |
| 270 | 305 | return True |
| 271 | 306 | except RuntimeError: |
| … | … | @@ -284,6 +319,7 @@ def is_burst_addon(): |
| 284 | 319 | def enable_addon(addon_id: str): |
| 285 | 320 | """ |
| 286 | 321 | Enable a Kodi addon using JSON-RPC. |
| 322 | ||
| 287 | 323 | Returns True if successful, False otherwise. |
| 288 | 324 | """ |
| 289 | 325 | request = { |
| … | … | @@ -311,7 +347,7 @@ def logger(message, level=xbmc.LOGINFO): |
| 311 | 347 | |
| 312 | 348 | |
| 313 | 349 | def get_url(**kwargs): |
| 314 | return "{}?{}".format(_URL, urlencode(kwargs)) | |
| 350 | return f"{_URL}?{urlencode(kwargs)}" | |
| 315 | 351 | |
| 316 | 352 | |
| 317 | 353 | def set_art(list_item, artwork_url): |
| … | … | @@ -340,7 +376,7 @@ def compat(line1, line2, line3): |
| 340 | 376 | |
| 341 | 377 | |
| 342 | 378 | def kodi_refresh(): |
| 343 | execute_builtin('UpdateLibrary(video,special://skin/foo)') | |
| 379 | execute_builtin("UpdateLibrary(video,special://skin/foo)") | |
| 344 | 380 | |
| 345 | 381 | |
| 346 | 382 | def refresh(): |
| … | … | @@ -375,10 +411,7 @@ def dialogyesno(header, text): |
| 375 | 411 | header, |
| 376 | 412 | text, |
| 377 | 413 | ) |
| 378 | if confirmed: | |
| 379 | return True | |
| 380 | else: | |
| 381 | return False | |
| 414 | return bool(confirmed) | |
| 382 | 415 | |
| 383 | 416 | |
| 384 | 417 | def dialog_select(heading, _list): |
| … | … | @@ -397,7 +430,7 @@ def container_update(name, **kwargs): |
| 397 | 430 | :param path: The path where to update. |
| 398 | 431 | :type path: str |
| 399 | 432 | """ |
| 400 | return "Container.Update({})".format(build_url(name, **kwargs)) | |
| 433 | return f"Container.Update({build_url(name, **kwargs)})" | |
| 401 | 434 | |
| 402 | 435 | |
| 403 | 436 | def container_refresh(): |
| … | … | @@ -405,7 +438,7 @@ def container_refresh(): |
| 405 | 438 | |
| 406 | 439 | |
| 407 | 440 | def action_url_run(name, **kwargs): |
| 408 | return "RunPlugin({})".format(build_url(name, **kwargs)) | |
| 441 | return f"RunPlugin({build_url(name, **kwargs)})" | |
| 409 | 442 | |
| 410 | 443 | |
| 411 | 444 | def build_url(action, **params): |
| … | … | @@ -433,7 +466,7 @@ def buffer_and_play(info_hash, file_id, path): |
| 433 | 466 | |
| 434 | 467 | |
| 435 | 468 | def kodi_play_media(name, *args, **kwargs): |
| 436 | return "PlayMedia({})".format(build_url(name, *args, **kwargs)) | |
| 469 | return f"PlayMedia({build_url(name, *args, **kwargs)})" | |
| 437 | 470 | |
| 438 | 471 | |
| 439 | 472 | def show_busy_dialog(): |
| … | … | @@ -441,7 +474,7 @@ def show_busy_dialog(): |
| 441 | 474 | |
| 442 | 475 | |
| 443 | 476 | def show_picture(url): |
| 444 | xbmc.executebuiltin('ShowPicture("{}")'.format(url)) | |
| 477 | xbmc.executebuiltin(f'ShowPicture("{url}")') | |
| 445 | 478 | |
| 446 | 479 | |
| 447 | 480 | def close_busy_dialog(): |
| … | … | @@ -488,24 +521,22 @@ def disable_enable_addon(addon_id=ADDON_ID): |
| 488 | 521 | } |
| 489 | 522 | ) |
| 490 | 523 | ) |
| 491 | except: | |
| 524 | except Exception: | |
| 492 | 525 | pass |
| 493 | 526 | |
| 494 | 527 | |
| 495 | 528 | def update_kodi_addons_db(addon_id=ADDON_ID): |
| 496 | 529 | try: |
| 497 | 530 | import sqlite3 as database |
| 498 | ||
| 531 | ||
| 499 | 532 | 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) | |
| 503 | 534 | dbcon.execute( |
| 504 | 535 | "INSERT OR REPLACE INTO installed (addonID, enabled, lastUpdated) VALUES (?, ?, ?)", |
| 505 | 536 | (addon_id, 1, date), |
| 506 | 537 | ) |
| 507 | 538 | dbcon.close() |
| 508 | except: | |
| 539 | except Exception: | |
| 509 | 540 | pass |
| 510 | 541 | |
| 511 | 542 | |
| … | … | @@ -520,17 +551,28 @@ def bytes_to_human_readable(size: int, unit: str = "B") -> str: |
| 520 | 551 | |
| 521 | 552 | |
| 522 | 553 | def 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 | ||
| 525 | 567 | if match: |
| 526 | 568 | size, unit = match.groups() |
| 527 | 569 | size = float(size) |
| 528 | 570 | unit = unit.upper() |
| 529 | if unit == "GB": | |
| 571 | if unit in ("GB", "GO"): | |
| 530 | 572 | return int(size * 1024**3) |
| 531 | if unit == "MB": | |
| 573 | if unit in ("MB", "MO"): | |
| 532 | 574 | return int(size * 1024**2) |
| 533 | if unit == "KB": | |
| 575 | if unit in ("KB", "KO"): | |
| 534 | 576 | return int(size * 1024) |
| 535 | 577 | return int(size) |
| 536 | 578 | return 0 |
| … | … | @@ -564,11 +606,11 @@ SECTION_VIEW_KEYS = ( |
| 564 | 606 | |
| 565 | 607 | |
| 566 | 608 | def get_view_setting_key(view_key): |
| 567 | return "saved_%s" % view_key.replace(".", "_") | |
| 609 | return "saved_{}".format(view_key.replace(".", "_")) | |
| 568 | 610 | |
| 569 | 611 | |
| 570 | 612 | def get_view_property_key(view_key): |
| 571 | return "jacktook.%s" % view_key | |
| 613 | return f"jacktook.{view_key}" | |
| 572 | 614 | |
| 573 | 615 | |
| 574 | 616 | def _get_named_view_id(name, default="current"): |
| … | … | @@ -696,9 +738,9 @@ def copy2clip(txt): |
| 696 | 738 | platform = sys.platform |
| 697 | 739 | if platform == "win32": |
| 698 | 740 | try: |
| 699 | cmd = "echo %s|clip" % txt.strip() | |
| 741 | cmd = f"echo {txt.strip()}|clip" | |
| 700 | 742 | return subprocess.check_call(cmd, shell=True) |
| 701 | except: | |
| 743 | except Exception: | |
| 702 | 744 | pass |
| 703 | 745 | elif platform == "linux2": |
| 704 | 746 | try: |
| … | … | @@ -706,7 +748,7 @@ def copy2clip(txt): |
| 706 | 748 | |
| 707 | 749 | p = Popen(["xsel", "-pi"], stdin=PIPE) |
| 708 | 750 | p.communicate(input=txt) |
| 709 | except: | |
| 751 | except Exception: | |
| 710 | 752 | pass |
| 711 | 753 | |
| 712 | 754 | |
| … | … | @@ -718,6 +760,7 @@ def get_datetime(string: bool = False, dt: bool = False) -> Union[date, datetime |
| 718 | 760 | :return: By default, returns a date object. |
| 719 | 761 | """ |
| 720 | 762 | now = datetime.now() |
| 763 | ||
| 721 | 764 | if dt: |
| 722 | 765 | return now |
| 723 | 766 | else: |
| … | … | @@ -740,11 +783,11 @@ def cancel_playback(): |
| 740 | 783 | PLAYLIST.clear() |
| 741 | 784 | close_busy_dialog() |
| 742 | 785 | close_all_dialog() |
| 743 | try: | |
| 786 | with contextlib.suppress(Exception): | |
| 744 | 787 | 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)) | |
| 748 | 791 | |
| 749 | 792 | |
| 750 | 793 | def finish_action(): |
| … | … | @@ -770,6 +813,4 @@ def is_widget(): |
| 770 | 813 | |
| 771 | 814 | |
| 772 | 815 | def 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)) |
| … | … | @@ -1,1238 +1,1739 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | 1 | # ref: https://gist.github.com/tientran0019/c68b7095976386cf8b1812327228450e |
| 3 | 2 | |
| 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 | ] | |
| 1236 | 1737 | |
| 1237 | 1738 | LANGUAGE_NAME_TO_CODE = { |
| 1238 | 1739 | "English": "en", |
| … | … | @@ -1255,31 +1756,47 @@ LANGUAGE_NAME_TO_CODE = { |
| 1255 | 1756 | "Vietnamese": "vi", |
| 1256 | 1757 | "Chinese": "zh", |
| 1257 | 1758 | "Persian": "fa", |
| 1258 | "None": "None" | |
| 1759 | "None": "None", | |
| 1259 | 1760 | } |
| 1260 | 1761 | |
| 1762 | ||
| 1261 | 1763 | def _check_flag(flag, country): |
| 1262 | if country['flag'] == flag: return True | |
| 1263 | return False | |
| 1764 | return country["flag"] == flag | |
| 1765 | ||
| 1264 | 1766 | |
| 1265 | 1767 | def _return_language(country): |
| 1266 | return country['language']['name'] | |
| 1768 | return country["language"]["name"] | |
| 1769 | ||
| 1267 | 1770 | |
| 1268 | 1771 | def _flag_is_minor_than(flag, country): |
| 1269 | if country['flag'] < flag: return True | |
| 1270 | return False | |
| 1772 | return country["flag"] < flag | |
| 1773 | ||
| 1271 | 1774 | |
| 1272 | 1775 | def _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 | ||
| 1283 | 1792 | |
| 1284 | 1793 | def 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 | ) |
| … | … | @@ -1,6 +1,7 @@ |
| 1 | from lib.utils.localization.countries import find_language_by_unicode | |
| 2 | 1 | import re |
| 3 | 2 | |
| 3 | from lib.utils.localization.countries import find_language_by_unicode | |
| 4 | ||
| 4 | 5 | langsSet = { |
| 5 | 6 | "bosnian", |
| 6 | 7 | "ro", |
| … | … | @@ -541,9 +542,7 @@ def extract_unicode_flags(text): |
| 541 | 542 | |
| 542 | 543 | def find_languages_in_string(s: str) -> set: |
| 543 | 544 | 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)]) | |
| 547 | 546 | swf = f"{s} {flags}" |
| 548 | 547 | matches = re.findall(pattern, swf.lower()) |
| 549 | 548 | matches = [language_codes.get(match) for match in matches] |
| … | … | @@ -1,56 +1,168 @@ |
| 1 | 1 | import re |
| 2 | 2 | |
| 3 | ||
| 3 | 4 | def get_color_tag(label, text, color): |
| 4 | 5 | # Standardized format [B]LABEL: VALUE[/B] |
| 5 | 6 | clean_color = color.replace("FF", "CC") |
| 6 | 7 | return f"[B][COLOR CCFFFFFF]{label}[/COLOR] [COLOR {clean_color}]{text}[/COLOR][/B]" |
| 7 | 8 | |
| 9 | ||
| 8 | 10 | def clean_display_title(title): |
| 9 | 11 | if not title: |
| 10 | 12 | return "" |
| 11 | ||
| 13 | ||
| 12 | 14 | # Redundant tags to remove (expanded list) |
| 13 | 15 | 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", | |
| 26 | 70 | ] |
| 27 | ||
| 71 | ||
| 28 | 72 | clean_title = title |
| 29 | 73 | for tag in tags_to_remove: |
| 30 | 74 | clean_title = re.sub(tag, "", clean_title, flags=re.IGNORECASE) |
| 31 | ||
| 75 | ||
| 32 | 76 | # 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 | ) | |
| 34 | 85 | clean_title = re.sub(r"\s+", " ", clean_title).strip() |
| 35 | ||
| 86 | ||
| 36 | 87 | if clean_title: |
| 37 | 88 | clean_title = clean_title.capitalize() |
| 38 | ||
| 89 | ||
| 39 | 90 | return clean_title |
| 40 | 91 | |
| 92 | ||
| 93 | def 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 | ||
| 41 | 127 | def parse_title_info(title): |
| 42 | 128 | 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 | ||
| 45 | 143 | 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": "", | |
| 48 | 153 | "clean_title": clean_display_title(title), |
| 49 | "badges": "" | |
| 154 | "badges": "", | |
| 50 | 155 | } |
| 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 | ||
| 54 | 166 | # 1. Video & Bit Depth |
| 55 | 167 | bit_depth = "10bit" if "10BIT" in title_upper else "12bit" if "12BIT" in title_upper else "" |
| 56 | 168 | if any(x in title_upper for x in ["HEVC", "H265", "X265"]): |
| … | … | @@ -59,78 +171,119 @@ def parse_title_info(title): |
| 59 | 171 | elif any(x in title_upper for x in ["H264", "X264", "AVC"]): |
| 60 | 172 | val = f"H.264 {bit_depth}".strip() |
| 61 | 173 | 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 | ||
| 63 | 178 | # 2. Audio & Channels |
| 64 | 179 | 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 | ||
| 69 | 187 | 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 | ||
| 77 | 201 | if audio_format or channels: |
| 78 | 202 | val = f"{audio_format} {channels}".strip() |
| 79 | 203 | info["audio"] = get_color_tag("AUDIO:", val, "FF00BFFF") |
| 80 | ||
| 204 | ||
| 81 | 205 | # 3. HDR & Hybrid |
| 82 | 206 | hdr_val = "" |
| 83 | 207 | 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 | ||
| 89 | 217 | if hdr_val: |
| 90 | 218 | val = f"{hdr_val} HYBRID" if is_hybrid else hdr_val |
| 91 | 219 | info["hdr"] = get_color_tag("HDR:", val, "FFFFA500") |
| 92 | ||
| 220 | ||
| 93 | 221 | # 4. Editions |
| 94 | 222 | 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") | |
| 100 | 233 | if editions: |
| 101 | 234 | info["edition"] = get_color_tag("EDITION:", " ".join(editions), "FFDA70D6") |
| 102 | ||
| 235 | ||
| 103 | 236 | # 5. Integrity Notes |
| 104 | 237 | 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") | |
| 108 | 244 | if notes: |
| 109 | 245 | info["note"] = get_color_tag("NOTE:", " ".join(notes), "FFFF4500") |
| 110 | ||
| 246 | ||
| 111 | 247 | # 6. Language Details |
| 112 | 248 | 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" | |
| 117 | 257 | if lang: |
| 118 | 258 | info["lang_detail"] = get_color_tag("LANG:", lang, "FFFFD700") |
| 119 | 259 | |
| 120 | 260 | # 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 | ||
| 124 | 266 | # Build Badge Line |
| 125 | 267 | 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 | ||
| 129 | 281 | bullet = "[COLOR 80FFFFFF] • [/COLOR]" |
| 130 | 282 | info["badges"] = f" {bullet} ".join(badges_list) |
| 131 | ||
| 283 | ||
| 132 | 284 | # Release Group |
| 133 | 285 | 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 | ||
| 136 | 289 | return info |
| … | … | @@ -1,5 +1,5 @@ |
| 1 | 1 | #!/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""" | |
| 3 | 3 | |
| 4 | 4 | try: |
| 5 | 5 | from defusedexpat import pyexpat as expat |
| … | … | @@ -7,6 +7,7 @@ except ImportError: |
| 7 | 7 | from xml.parsers import expat |
| 8 | 8 | from xml.sax.saxutils import XMLGenerator |
| 9 | 9 | from xml.sax.xmlreader import AttributesImpl |
| 10 | ||
| 10 | 11 | try: # pragma no cover |
| 11 | 12 | from cStringIO import StringIO |
| 12 | 13 | except ImportError: # pragma no cover |
| … | … | @@ -17,6 +18,7 @@ except ImportError: # pragma no cover |
| 17 | 18 | |
| 18 | 19 | _dict = dict |
| 19 | 20 | import platform |
| 21 | ||
| 20 | 22 | if tuple(map(int, platform.python_version_tuple()[:2])) < (3, 7): |
| 21 | 23 | from collections import OrderedDict as _dict |
| 22 | 24 | |
| … | … | @@ -31,31 +33,33 @@ try: # pragma no cover |
| 31 | 33 | except NameError: # pragma no cover |
| 32 | 34 | _unicode = str |
| 33 | 35 | |
| 34 | __author__ = 'Martin Blech' | |
| 35 | __version__ = '0.13.0' | |
| 36 | __license__ = 'MIT' | |
| 36 | __author__ = "Martin Blech" | |
| 37 | __version__ = "0.13.0" | |
| 38 | __license__ = "MIT" | |
| 37 | 39 | |
| 38 | 40 | |
| 39 | 41 | class ParsingInterrupted(Exception): |
| 40 | 42 | pass |
| 41 | 43 | |
| 42 | 44 | |
| 43 | class _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'): | |
| 45 | class _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 | ): | |
| 59 | 63 | self.path = [] |
| 60 | 64 | self.stack = [] |
| 61 | 65 | self.data = [] |
| … | … | @@ -82,7 +86,7 @@ class _DictSAXHandler(object): |
| 82 | 86 | i = full_name.rfind(self.namespace_separator) |
| 83 | 87 | if i == -1: |
| 84 | 88 | return full_name |
| 85 | namespace, name = full_name[:i], full_name[i+1:] | |
| 89 | namespace, name = full_name[:i], full_name[i + 1 :] | |
| 86 | 90 | try: |
| 87 | 91 | short_namespace = self.namespaces[namespace] |
| 88 | 92 | except KeyError: |
| … | … | @@ -98,13 +102,13 @@ class _DictSAXHandler(object): |
| 98 | 102 | return self.dict_constructor(zip(attrs[0::2], attrs[1::2])) |
| 99 | 103 | |
| 100 | 104 | def startNamespaceDecl(self, prefix, uri): |
| 101 | self.namespace_declarations[prefix or ''] = uri | |
| 105 | self.namespace_declarations[prefix or ""] = uri | |
| 102 | 106 | |
| 103 | 107 | def startElement(self, full_name, attrs): |
| 104 | 108 | name = self._build_name(full_name) |
| 105 | 109 | attrs = self._attrs_to_dict(attrs) |
| 106 | 110 | if attrs and self.namespace_declarations: |
| 107 | attrs['xmlns'] = self.namespace_declarations | |
| 111 | attrs["xmlns"] = self.namespace_declarations | |
| 108 | 112 | self.namespace_declarations = self.dict_constructor() |
| 109 | 113 | self.path.append((name, attrs or None)) |
| 110 | 114 | if len(self.path) >= self.item_depth: |
| … | … | @@ -112,7 +116,7 @@ class _DictSAXHandler(object): |
| 112 | 116 | if self.xml_attribs: |
| 113 | 117 | attr_entries = [] |
| 114 | 118 | for key, value in attrs.items(): |
| 115 | key = self.attr_prefix+self._build_name(key) | |
| 119 | key = self.attr_prefix + self._build_name(key) | |
| 116 | 120 | if self.postprocessor: |
| 117 | 121 | entry = self.postprocessor(self.path, key, value) |
| 118 | 122 | else: |
| … | … | @@ -130,15 +134,13 @@ class _DictSAXHandler(object): |
| 130 | 134 | if len(self.path) == self.item_depth: |
| 131 | 135 | item = self.item |
| 132 | 136 | 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) | |
| 135 | 138 | |
| 136 | 139 | should_continue = self.item_callback(self.path, item) |
| 137 | 140 | if not should_continue: |
| 138 | 141 | raise ParsingInterrupted() |
| 139 | 142 | 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) | |
| 142 | 144 | item = self.item |
| 143 | 145 | self.item, self.data = self.stack.pop() |
| 144 | 146 | if self.strip_whitespace and data and item: |
| … | … | @@ -199,8 +201,16 @@ class _DictSAXHandler(object): |
| 199 | 201 | return self.force_list(self.path[:-1], key, value) |
| 200 | 202 | |
| 201 | 203 | |
| 202 | def parse(xml_input, encoding=None, expat=expat, process_namespaces=False, | |
| 203 | namespace_separator=':', disable_entities=True, process_comments=False, **kwargs): | |
| 204 | def 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 | ): | |
| 204 | 214 | """Parse the given XML input and convert it into a dictionary. |
| 205 | 215 | |
| 206 | 216 | `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, |
| 333 | 343 | }, |
| 334 | 344 | } |
| 335 | 345 | """ |
| 336 | handler = _DictSAXHandler(namespace_separator=namespace_separator, | |
| 337 | **kwargs) | |
| 346 | handler = _DictSAXHandler(namespace_separator=namespace_separator, **kwargs) | |
| 338 | 347 | if isinstance(xml_input, _unicode): |
| 339 | 348 | if not encoding: |
| 340 | encoding = 'utf-8' | |
| 349 | encoding = "utf-8" | |
| 341 | 350 | xml_input = xml_input.encode(encoding) |
| 342 | 351 | if not process_namespaces: |
| 343 | 352 | namespace_separator = None |
| 344 | parser = expat.ParserCreate( | |
| 345 | encoding, | |
| 346 | namespace_separator | |
| 347 | ) | |
| 353 | parser = expat.ParserCreate(encoding, namespace_separator) | |
| 348 | 354 | try: |
| 349 | 355 | parser.ordered_attributes = True |
| 350 | 356 | except AttributeError: |
| … | … | @@ -368,18 +374,18 @@ def parse(xml_input, encoding=None, expat=expat, process_namespaces=False, |
| 368 | 374 | parser.DefaultHandler = lambda x: None |
| 369 | 375 | # Expects an integer return; zero means failure -> expat.ExpatError. |
| 370 | 376 | parser.ExternalEntityRefHandler = lambda *x: 1 |
| 371 | if hasattr(xml_input, 'read'): | |
| 377 | if hasattr(xml_input, "read"): | |
| 372 | 378 | parser.ParseFile(xml_input) |
| 373 | 379 | elif isgenerator(xml_input): |
| 374 | 380 | for chunk in xml_input: |
| 375 | 381 | parser.Parse(chunk, False) |
| 376 | parser.Parse(b'', True) | |
| 382 | parser.Parse(b"", True) | |
| 377 | 383 | else: |
| 378 | 384 | parser.Parse(xml_input, True) |
| 379 | 385 | return handler.item |
| 380 | 386 | |
| 381 | 387 | |
| 382 | def _process_namespace(name, namespaces, ns_sep=':', attr_prefix='@'): | |
| 388 | def _process_namespace(name, namespaces, ns_sep=":", attr_prefix="@"): | |
| 383 | 389 | if not namespaces: |
| 384 | 390 | return name |
| 385 | 391 | try: |
| … | … | @@ -388,46 +394,52 @@ def _process_namespace(name, namespaces, ns_sep=':', attr_prefix='@'): |
| 388 | 394 | pass |
| 389 | 395 | else: |
| 390 | 396 | 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 | ) | |
| 394 | 404 | return name |
| 395 | 405 | |
| 396 | 406 | |
| 397 | def _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): | |
| 407 | def _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 | ): | |
| 409 | 423 | key = _process_namespace(key, namespaces, namespace_separator, attr_prefix) |
| 410 | 424 | if preprocessor is not None: |
| 411 | 425 | result = preprocessor(key, value) |
| 412 | 426 | if result is None: |
| 413 | 427 | return |
| 414 | 428 | 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): | |
| 418 | 430 | value = [value] |
| 419 | 431 | for index, v in enumerate(value): |
| 420 | 432 | if full_document and depth == 0 and index > 0: |
| 421 | raise ValueError('document with multiple roots') | |
| 433 | raise ValueError("document with multiple roots") | |
| 422 | 434 | if v is None: |
| 423 | 435 | v = _dict() |
| 424 | 436 | elif isinstance(v, bool): |
| 425 | 437 | if v: |
| 426 | v = _unicode('true') | |
| 438 | v = _unicode("true") | |
| 427 | 439 | else: |
| 428 | v = _unicode('false') | |
| 440 | v = _unicode("false") | |
| 429 | 441 | 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): | |
| 431 | 443 | v = _dict(((expand_iter, v),)) |
| 432 | 444 | else: |
| 433 | 445 | v = _unicode(v) |
| … | … | @@ -441,31 +453,40 @@ def _emit(key, value, content_handler, |
| 441 | 453 | cdata = iv |
| 442 | 454 | continue |
| 443 | 455 | 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): | |
| 447 | 458 | for k, v in iv.items(): |
| 448 | attr = 'xmlns{}'.format(':{}'.format(k) if k else '') | |
| 459 | attr = "xmlns{}".format(f":{k}" if k else "") | |
| 449 | 460 | attrs[attr] = _unicode(v) |
| 450 | 461 | continue |
| 451 | 462 | if not isinstance(iv, _unicode): |
| 452 | 463 | iv = _unicode(iv) |
| 453 | attrs[ik[len(attr_prefix):]] = iv | |
| 464 | attrs[ik[len(attr_prefix) :]] = iv | |
| 454 | 465 | continue |
| 455 | 466 | children.append((ik, iv)) |
| 456 | 467 | if type(indent) is int: |
| 457 | indent = ' ' * indent | |
| 468 | indent = " " * indent | |
| 458 | 469 | if pretty: |
| 459 | 470 | content_handler.ignorableWhitespace(depth * indent) |
| 460 | 471 | content_handler.startElement(key, AttributesImpl(attrs)) |
| 461 | 472 | if pretty and children: |
| 462 | 473 | content_handler.ignorableWhitespace(newl) |
| 463 | 474 | 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 | ) | |
| 469 | 490 | if cdata is not None: |
| 470 | 491 | content_handler.characters(cdata) |
| 471 | 492 | if pretty and children: |
| … | … | @@ -475,9 +496,14 @@ def _emit(key, value, content_handler, |
| 475 | 496 | content_handler.ignorableWhitespace(newl) |
| 476 | 497 | |
| 477 | 498 | |
| 478 | def unparse(input_dict, output=None, encoding='utf-8', full_document=True, | |
| 479 | short_empty_elements=False, | |
| 480 | **kwargs): | |
| 499 | def unparse( | |
| 500 | input_dict, | |
| 501 | output=None, | |
| 502 | encoding="utf-8", | |
| 503 | full_document=True, | |
| 504 | short_empty_elements=False, | |
| 505 | **kwargs, | |
| 506 | ): | |
| 481 | 507 | """Emit an XML document for the given `input_dict` (reverse of `parse`). |
| 482 | 508 | |
| 483 | 509 | 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, |
| 493 | 519 | |
| 494 | 520 | """ |
| 495 | 521 | 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.") | |
| 497 | 523 | must_return = False |
| 498 | 524 | if output is None: |
| 499 | 525 | output = StringIO() |
| … | … | @@ -505,8 +531,7 @@ def unparse(input_dict, output=None, encoding='utf-8', full_document=True, |
| 505 | 531 | if full_document: |
| 506 | 532 | content_handler.startDocument() |
| 507 | 533 | 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) | |
| 510 | 535 | if full_document: |
| 511 | 536 | content_handler.endDocument() |
| 512 | 537 | if must_return: |
| … | … | @@ -518,9 +543,10 @@ def unparse(input_dict, output=None, encoding='utf-8', full_document=True, |
| 518 | 543 | return value |
| 519 | 544 | |
| 520 | 545 | |
| 521 | if __name__ == '__main__': # pragma: no cover | |
| 522 | import sys | |
| 546 | if __name__ == "__main__": # pragma: no cover | |
| 523 | 547 | import marshal |
| 548 | import sys | |
| 549 | ||
| 524 | 550 | try: |
| 525 | 551 | stdin = sys.stdin.buffer |
| 526 | 552 | stdout = sys.stdout.buffer |
| … | … | @@ -536,10 +562,12 @@ if __name__ == '__main__': # pragma: no cover |
| 536 | 562 | return True |
| 537 | 563 | |
| 538 | 564 | 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 | ) | |
| 543 | 571 | if item_depth == 0: |
| 544 | 572 | handle_item([], root) |
| 545 | 573 | except KeyboardInterrupt: |
| … | … | @@ -1,10 +1,26 @@ |
| 1 | from datetime import timedelta | |
| 2 | from typing import Any, Dict, List, Optional, Tuple | |
| 1 | 3 | from urllib.parse import quote |
| 4 | ||
| 5 | from xbmc import LOGDEBUG | |
| 6 | from xbmcgui import Dialog | |
| 7 | ||
| 8 | from lib.db.cached import cache | |
| 9 | from lib.domain.torrent import TorrentStream | |
| 2 | 10 | from lib.jacktook.utils import kodilog |
| 3 | 11 | from lib.utils.debrid.debrid_utils import ( |
| 4 | 12 | get_debrid_direct_url, |
| 5 | 13 | get_debrid_pack_direct_url, |
| 6 | 14 | is_supported_debrid_type, |
| 7 | 15 | ) |
| 16 | from lib.utils.general.utils import ( | |
| 17 | DebridType, | |
| 18 | Indexer, | |
| 19 | IndexerType, | |
| 20 | Players, | |
| 21 | torrent_clients, | |
| 22 | ) | |
| 23 | from lib.utils.kodi.logging import summarize_locator_for_log | |
| 8 | 24 | from lib.utils.kodi.utils import ( |
| 9 | 25 | execute_builtin, |
| 10 | 26 | get_setting, |
| … | … | @@ -14,24 +30,13 @@ from lib.utils.kodi.utils import ( |
| 14 | 30 | notification, |
| 15 | 31 | translation, |
| 16 | 32 | ) |
| 17 | from lib.utils.kodi.logging import summarize_locator_for_log | |
| 18 | from lib.utils.general.utils import ( | |
| 19 | DebridType, | |
| 20 | IndexerType, | |
| 21 | Indexer, | |
| 22 | Players, | |
| 23 | torrent_clients, | |
| 24 | ) | |
| 25 | from xbmcgui import Dialog | |
| 26 | from xbmc import LOGDEBUG | |
| 27 | from typing import Any, Dict, Optional | |
| 28 | 33 | |
| 29 | 34 | |
| 30 | 35 | def append_proxy_headers(url: str, headers: Dict[str, str]) -> str: |
| 31 | 36 | """Attach a Stremio source's required request headers to a direct URL. |
| 32 | 37 | |
| 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 | |
| 35 | 40 | percent-encoded, or a Referer's own `?`/`&`/`=` would split the header list. |
| 36 | 41 | |
| 37 | 42 | 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]: |
| 121 | 126 | ) |
| 122 | 127 | |
| 123 | 128 | 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) | |
| 125 | 130 | |
| 126 | 131 | if data.get("is_torrent"): |
| 127 | 132 | selected_client = get_torrent_client_selection(magnet, url, mode, ids) |
| 128 | 133 | 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) | |
| 130 | 135 | else: |
| 131 | 136 | raise TorrentException("No torrent client selected") |
| 132 | 137 | return None |
| 133 | 138 | |
| 134 | 139 | |
| 135 | 140 | def 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, | |
| 137 | 147 | ) -> Optional[str]: |
| 138 | 148 | torrent_client = client or str(get_setting("torrent_client")) |
| 139 | 149 | if torrent_client in [Players.TORREST]: |
| … | … | @@ -141,14 +151,12 @@ def get_torrent_url_for_client( |
| 141 | 151 | elif torrent_client in [Players.ELEMENTUM]: |
| 142 | 152 | return get_elementum_url(magnet, url, mode, ids) |
| 143 | 153 | elif torrent_client in [Players.JACKTORR]: |
| 144 | return get_jacktorr_url(magnet, url) | |
| 154 | return get_jacktorr_url(magnet, url, data=data) | |
| 145 | 155 | else: |
| 146 | 156 | raise TorrentException(f"Unknown torrent client selected: {torrent_client}") |
| 147 | 157 | |
| 148 | 158 | |
| 149 | def get_torrent_client_selection( | |
| 150 | magnet: str, url: str, mode: str, ids: Any | |
| 151 | ) -> Optional[str]: | |
| 159 | def get_torrent_client_selection(magnet: str, url: str, mode: str, ids: Any) -> Optional[str]: | |
| 152 | 160 | chosen_client = Dialog().select(translation(90341), torrent_clients) |
| 153 | 161 | if chosen_client < 0: |
| 154 | 162 | return None |
| … | … | @@ -158,13 +166,18 @@ def get_torrent_client_selection( |
| 158 | 166 | def get_debrid_url( |
| 159 | 167 | data: Dict[str, Any], debrid_type: str, is_pack: bool |
| 160 | 168 | ) -> 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]: | |
| 162 | 170 | return get_debrid_pack_direct_url(debrid_type, data) |
| 163 | 171 | else: |
| 164 | 172 | return get_debrid_direct_url(debrid_type, data) |
| 165 | 173 | |
| 166 | 174 | |
| 167 | def get_elementum_url(magnet: str, url: str, mode: str, ids: Any) -> Optional[str]: | |
| 175 | def get_elementum_url( | |
| 176 | magnet: str, | |
| 177 | url: str, | |
| 178 | mode: str, | |
| 179 | ids: Any, | |
| 180 | ) -> Optional[str]: | |
| 168 | 181 | if not is_elementum_addon(): |
| 169 | 182 | if Dialog().yesno( |
| 170 | 183 | translation(30252), |
| … | … | @@ -179,20 +192,17 @@ def get_elementum_url(magnet: str, url: str, mode: str, ids: Any) -> Optional[st |
| 179 | 192 | |
| 180 | 193 | tmdb_id = ids.get("tmdb_id", "") if isinstance(ids, dict) else "" |
| 181 | 194 | |
| 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}" | |
| 184 | 199 | else: |
| 185 | 200 | raise TorrentException("No magnet or url found for Elementum playback") |
| 186 | 201 | |
| 187 | 202 | |
| 188 | def get_jacktorr_url(magnet: str, url: str) -> Optional[str]: | |
| 203 | def get_jacktorr_url(magnet: str, url: str, data: Optional[Dict[str, Any]] = None) -> Optional[str]: | |
| 189 | 204 | 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)}", | |
| 196 | 206 | level=LOGDEBUG, |
| 197 | 207 | ) |
| 198 | 208 | if not is_jacktorr_addon(): |
| … | … | @@ -206,6 +216,9 @@ def get_jacktorr_url(magnet: str, url: str) -> Optional[str]: |
| 206 | 216 | else: |
| 207 | 217 | notification(translation(30253)) |
| 208 | 218 | return None |
| 219 | ||
| 220 | _save_jacktorr_playback_metadata(magnet, data or {}) | |
| 221 | ||
| 209 | 222 | if magnet: |
| 210 | 223 | _url = f"plugin://plugin.video.jacktorr/play_magnet?magnet={quote(magnet)}" |
| 211 | 224 | elif url: |
| … | … | @@ -216,6 +229,47 @@ def get_jacktorr_url(magnet: str, url: str) -> Optional[str]: |
| 216 | 229 | return _url |
| 217 | 230 | |
| 218 | 231 | |
| 232 | def _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 | ||
| 219 | 273 | def get_torrest_url(magnet: str, url: str) -> Optional[str]: |
| 220 | 274 | if not is_torrest_addon(): |
| 221 | 275 | if Dialog().yesno( |
| … | … | @@ -237,82 +291,146 @@ def get_torrest_url(magnet: str, url: str) -> Optional[str]: |
| 237 | 291 | return _url |
| 238 | 292 | |
| 239 | 293 | |
| 240 | def precache_next_episodes(item_data): | |
| 241 | kodilog("Precaching next episodes...") | |
| 294 | class 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 | ||
| 308 | def 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}" | |
| 242 | 311 | |
| 243 | from lib.search import search_client | |
| 244 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 245 | 312 | |
| 246 | if not get_setting("precaching_enabled", False): | |
| 247 | return | |
| 313 | def 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 | ||
| 318 | def 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 | ||
| 248 | 326 | |
| 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 | ||
| 338 | def _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 | ||
| 351 | def 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): | |
| 250 | 354 | return |
| 251 | 355 | |
| 252 | 356 | ids = item_data.get("ids") |
| 253 | 357 | if not ids: |
| 254 | 358 | return |
| 255 | 359 | |
| 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: | |
| 258 | 362 | return |
| 259 | 363 | |
| 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") | |
| 265 | 366 | if season is None or episode is None: |
| 266 | kodilog("Invalid season or episode data") | |
| 267 | 367 | return |
| 268 | 368 | |
| 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) | |
| 284 | 371 | |
| 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 | |
| 290 | 374 | |
| 291 | search_client( | |
| 292 | query=query, | |
| 375 | results = search_client( | |
| 376 | query=item_data.get("title", ""), | |
| 293 | 377 | ids=ids, |
| 294 | mode=item_data["mode"], | |
| 378 | mode=item_data.get("mode", ""), | |
| 295 | 379 | media_type=item_data.get("media_type", ""), |
| 296 | 380 | rescrape=True, |
| 297 | 381 | season=season, |
| 298 | episode=episode_number, | |
| 382 | episode=episode, | |
| 299 | 383 | show_dialog=False, |
| 300 | 384 | ) |
| 301 | 385 | |
| 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 | ) | |
| 303 | 422 | |
| 423 | if not playback_info: | |
| 424 | kodilog("Autoscrape: failed to resolve playback URL") | |
| 425 | return | |
| 304 | 426 | |
| 305 | class 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}") |
| … | … | @@ -1,20 +1,21 @@ |
| 1 | from xbmcgui import Dialog | |
| 2 | ||
| 1 | 3 | from lib.api.plex.media_server import check_server_connection, get_servers |
| 2 | 4 | from lib.api.plex.plex import PlexApi |
| 3 | 5 | from lib.utils.kodi.utils import get_setting, kodilog, set_setting |
| 4 | from xbmcgui import Dialog | |
| 5 | ||
| 6 | 6 | |
| 7 | 7 | plex = PlexApi() |
| 8 | 8 | |
| 9 | 9 | server_config = {"discoveryUrl": [], "streamingUrl": []} |
| 10 | 10 | |
| 11 | ||
| 11 | 12 | def plex_login(): |
| 12 | 13 | success = plex.login() |
| 13 | 14 | if success: |
| 14 | 15 | user = plex.get_plex_user() |
| 15 | 16 | if user: |
| 16 | 17 | set_setting("plex_user", user.username) |
| 17 | ||
| 18 | ||
| 18 | 19 | |
| 19 | 20 | def validate_server(): |
| 20 | 21 | servers = get_servers("https://plex.tv/api/v2", get_setting("plex_token")) |
| … | … | @@ -55,5 +56,6 @@ def streaming_test(urls, access_token): |
| 55 | 56 | set_setting("plex_streaming_url", url) |
| 56 | 57 | break |
| 57 | 58 | |
| 59 | ||
| 58 | 60 | def plex_logout(): |
| 59 | plex.logout() | |
| 61 | plex.logout() |
| … | … | @@ -1,8 +1,9 @@ |
| 1 | 1 | from datetime import timedelta |
| 2 | ||
| 2 | 3 | from lib.clients.stremio.addon_client import StremioAddonCatalogsClient |
| 3 | 4 | from lib.db.cached import cache |
| 4 | from lib.utils.kodi.utils import kodilog | |
| 5 | 5 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled |
| 6 | from lib.utils.kodi.utils import kodilog | |
| 6 | 7 | |
| 7 | 8 | |
| 8 | 9 | def catalogs_get_cache(path, params, *args, **kwargs): |
| … | … | @@ -12,22 +13,14 @@ def catalogs_get_cache(path, params, *args, **kwargs): |
| 12 | 13 | return data |
| 13 | 14 | |
| 14 | 15 | 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(), | |
| 27 | 22 | "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(), | |
| 31 | 24 | } |
| 32 | 25 | |
| 33 | 26 | try: |
| … | … | @@ -11,14 +11,9 @@ from lib.api.tmdbv3api.tmdb import TMDb |
| 11 | 11 | from lib.clients.tmdb.utils.utils import LANGUAGES |
| 12 | 12 | from lib.utils.kodi.utils import get_setting |
| 13 | 13 | |
| 14 | _tmdb_initialized = False | |
| 15 | ||
| 16 | 14 | |
| 17 | 15 | def 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.""" | |
| 22 | 17 | tmdb = TMDb() |
| 23 | 18 | tmdb.api_key = get_setting("tmdb_api_key", "b70756b7083d9ee60f849d82d94a0d80") |
| 24 | 19 | try: |
| … | … | @@ -26,4 +21,3 @@ def ensure_tmdb_init(): |
| 26 | 21 | tmdb.language = LANGUAGES[int(language_index)] |
| 27 | 22 | except (IndexError, ValueError): |
| 28 | 23 | tmdb.language = "en-US" |
| 29 | _tmdb_initialized = True |
| … | … | @@ -3,14 +3,14 @@ import io |
| 3 | 3 | import itertools |
| 4 | 4 | |
| 5 | 5 | ONE_CHAR = 1 |
| 6 | INTEGER_START = b'i' | |
| 7 | LIST_START = b'l' | |
| 8 | DICT_START = b'd' | |
| 9 | END = b'e' | |
| 10 | NEGATIVE_SIGN = b'-' | |
| 11 | STRING_LENGTH_SEPARATOR = b':' | |
| 6 | INTEGER_START = b"i" | |
| 7 | LIST_START = b"l" | |
| 8 | DICT_START = b"d" | |
| 9 | END = b"e" | |
| 10 | NEGATIVE_SIGN = b"-" | |
| 11 | STRING_LENGTH_SEPARATOR = b":" | |
| 12 | 12 | |
| 13 | __all__ = ['decode', 'DecodingError', 'encode'] | |
| 13 | __all__ = ["DecodingError", "decode", "encode"] | |
| 14 | 14 | |
| 15 | 15 | |
| 16 | 16 | class DecodingError(ValueError): |
| … | … | @@ -18,12 +18,13 @@ class DecodingError(ValueError): |
| 18 | 18 | |
| 19 | 19 | |
| 20 | 20 | def byte_is_integer(b): |
| 21 | return b'0' <= b <= b'9' | |
| 21 | return b"0" <= b <= b"9" | |
| 22 | 22 | |
| 23 | 23 | |
| 24 | 24 | def group_by(it, n): |
| 25 | 25 | """ |
| 26 | 26 | >>> list(group_by([1, 2, 3, 4], 2)) |
| 27 | ||
| 27 | 28 | [(1, 2), (3, 4)] |
| 28 | 29 | """ |
| 29 | 30 | return zip(*[itertools.islice(it2, i, None, n) for i, it2 in enumerate(itertools.tee(it))]) |
| … | … | @@ -53,7 +54,7 @@ def _read_integer(buf): |
| 53 | 54 | c = buf.read(ONE_CHAR) |
| 54 | 55 | |
| 55 | 56 | 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 | |
| 57 | 58 | raise DecodingError |
| 58 | 59 | n = int(n) |
| 59 | 60 | if n == 0 and negative: # '-0' is illegal |
| … | … | @@ -132,7 +133,7 @@ def decode(s): |
| 132 | 133 | def encode(obj): |
| 133 | 134 | def generator(obj): |
| 134 | 135 | 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): | |
| 136 | 137 | raise ValueError("Dictionary keys must be strings") |
| 137 | 138 | yield DICT_START |
| 138 | 139 | # Dictionary keys should be sorted according to the BEP-0003: |
| … | … | @@ -148,14 +149,14 @@ def encode(obj): |
| 148 | 149 | yield from generator(elem) |
| 149 | 150 | yield END |
| 150 | 151 | elif isinstance(obj, bytes): |
| 151 | yield str(len(obj)).encode('ascii') | |
| 152 | yield str(len(obj)).encode("ascii") | |
| 152 | 153 | yield STRING_LENGTH_SEPARATOR |
| 153 | 154 | yield obj |
| 154 | 155 | elif isinstance(obj, int): |
| 155 | 156 | yield INTEGER_START |
| 156 | yield str(obj).encode('ascii') | |
| 157 | yield str(obj).encode("ascii") | |
| 157 | 158 | yield END |
| 158 | 159 | else: |
| 159 | raise ValueError("type {} not supported".format(type(obj))) | |
| 160 | raise ValueError(f"type {type(obj)} not supported") | |
| 160 | 161 | |
| 161 | return b''.join(generator(obj)) | |
| 162 | return b"".join(generator(obj)) |
| … | … | @@ -1,9 +1,9 @@ |
| 1 | 1 | from lib.api.jacktorr.jacktorr import TorrServer |
| 2 | 2 | from lib.utils.general.utils import ( |
| 3 | get_service_host, | |
| 3 | get_password, | |
| 4 | 4 | get_port, |
| 5 | get_service_host, | |
| 5 | 6 | get_username, |
| 6 | get_password, | |
| 7 | 7 | ssl_enabled, |
| 8 | 8 | ) |
| 9 | 9 | from lib.utils.kodi.utils import JACKTORR_ADDON |
| … | … | @@ -1,28 +1,66 @@ |
| 1 | 1 | import hashlib |
| 2 | import json | |
| 3 | import os | |
| 4 | from datetime import timedelta | |
| 2 | 5 | from io import BytesIO |
| 6 | from urllib.parse import quote | |
| 7 | ||
| 3 | 8 | import requests |
| 4 | from lib.utils.kodi.kodi_formats import is_picture, is_text, is_video, is_music | |
| 9 | import xbmc | |
| 10 | import xbmcgui | |
| 11 | from xbmcgui import Dialog | |
| 12 | from xbmcplugin import addDirectoryItem | |
| 13 | ||
| 14 | from lib.clients.subtitle.deepl import DeepLTranslator # noqa: F401 — imported for test mocking | |
| 15 | from lib.clients.subtitle.submanager import SubtitleManager | |
| 16 | from lib.db.cached import cache | |
| 17 | from lib.utils.general.utils import ( | |
| 18 | USER_AGENT_HEADER, | |
| 19 | build_list_item, | |
| 20 | get_info_hash_from_magnet, | |
| 21 | set_pluging_category, | |
| 22 | ) | |
| 23 | from lib.utils.kodi.kodi_formats import is_music, is_picture, is_text, is_video | |
| 5 | 24 | from lib.utils.kodi.utils import ( |
| 6 | 25 | ADDON_HANDLE, |
| 26 | ADDON_PROFILE_PATH, | |
| 7 | 27 | JACKTORR_ADDON, |
| 28 | action_url_run, | |
| 8 | 29 | buffer_and_play, |
| 9 | 30 | build_url, |
| 31 | end_of_directory, | |
| 32 | get_setting, # noqa: F401 — imported for test mocking | |
| 10 | 33 | kodilog, |
| 11 | 34 | notification, |
| 12 | 35 | refresh, |
| 13 | 36 | show_picture, |
| 14 | 37 | translation, |
| 15 | 38 | ) |
| 16 | from lib.utils.general.utils import ( | |
| 17 | USER_AGENT_HEADER, | |
| 18 | build_list_item, | |
| 19 | ) | |
| 39 | from lib.utils.torrent.torrserver_init import get_torrserver_api | |
| 20 | 40 | from lib.vendor.bencodepy import bencodepy |
| 21 | 41 | |
| 22 | from xbmcplugin import addDirectoryItem, endOfDirectory | |
| 23 | from xbmcgui import Dialog | |
| 24 | 42 | |
| 25 | from lib.utils.torrent.torrserver_init import get_torrserver_api | |
| 43 | def _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 | ||
| 50 | def _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] | |
| 26 | 64 | |
| 27 | 65 | |
| 28 | 66 | def torrent_status(info_hash): |
| … | … | @@ -40,6 +78,8 @@ def torrent_files(params): |
| 40 | 78 | info = get_torrserver_api().get_torrent_info(link=info_hash) |
| 41 | 79 | file_stats = info.get("file_stats") |
| 42 | 80 | |
| 81 | set_pluging_category(info.get("title", "")) | |
| 82 | ||
| 43 | 83 | for f in file_stats: |
| 44 | 84 | name = f.get("path") |
| 45 | 85 | id = f.get("id") |
| … | … | @@ -67,8 +107,13 @@ def torrent_files(params): |
| 67 | 107 | info_type = "music" |
| 68 | 108 | |
| 69 | 109 | 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", "")) | |
| 71 | 115 | file_li.setProperty("IsPlayable", "true") |
| 116 | file_li.setPath(jacktorr_url) | |
| 72 | 117 | |
| 73 | 118 | context_menu_items.append( |
| 74 | 119 | ( |
| … | … | @@ -77,17 +122,69 @@ def torrent_files(params): |
| 77 | 122 | ) |
| 78 | 123 | ) |
| 79 | 124 | |
| 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 | ||
| 80 | 177 | file_li.addContextMenuItems(context_menu_items) |
| 81 | 178 | |
| 82 | 179 | if info_type is not None: |
| 83 | 180 | addDirectoryItem( |
| 84 | 181 | ADDON_HANDLE, |
| 85 | build_url("play_url", url=serve_url, name=name), | |
| 182 | jacktorr_url, | |
| 86 | 183 | file_li, |
| 87 | 184 | ) |
| 88 | 185 | else: |
| 89 | 186 | addDirectoryItem(ADDON_HANDLE, url, file_li) |
| 90 | endOfDirectory(ADDON_HANDLE) | |
| 187 | end_of_directory() | |
| 91 | 188 | |
| 92 | 189 | |
| 93 | 190 | def torrent_action(params): |
| … | … | @@ -132,9 +229,7 @@ def display_text(params): |
| 132 | 229 | Dialog().textviewer(params.get("path"), r.text) |
| 133 | 230 | |
| 134 | 231 | |
| 135 | def add_source_to_torrserver( | |
| 136 | magnet="", url="", info_hash="", title="", poster="" | |
| 137 | ): | |
| 232 | def add_source_to_torrserver(magnet="", url="", info_hash="", title="", poster="", data=""): | |
| 138 | 233 | if not JACKTORR_ADDON: |
| 139 | 234 | notification(translation(30253)) |
| 140 | 235 | return None |
| … | … | @@ -146,6 +241,9 @@ def add_source_to_torrserver( |
| 146 | 241 | |
| 147 | 242 | try: |
| 148 | 243 | 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 | ) | |
| 149 | 247 | |
| 150 | 248 | if url and url.startswith("http"): |
| 151 | 249 | try: |
| … | … | @@ -153,9 +251,8 @@ def add_source_to_torrserver( |
| 153 | 251 | response.raise_for_status() |
| 154 | 252 | torrent_obj = BytesIO(response.content) |
| 155 | 253 | 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}") | |
| 159 | 256 | except Exception as exc: |
| 160 | 257 | kodilog(f"Failed to add torrent URL to TorrServer: {exc}") |
| 161 | 258 | |
| … | … | @@ -165,10 +262,29 @@ def add_source_to_torrserver( |
| 165 | 262 | |
| 166 | 263 | if magnet: |
| 167 | 264 | added_hash = api.add_magnet(magnet, title=title, poster=poster) |
| 265 | kodilog(f"add_source_to_torrserver added magnet: returned_hash={added_hash!r}") | |
| 168 | 266 | else: |
| 169 | 267 | notification(translation(90361)) |
| 170 | 268 | return None |
| 171 | 269 | |
| 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 | ||
| 172 | 288 | notification(translation(90360)) |
| 173 | 289 | return added_hash |
| 174 | 290 | except Exception as exc: |
| … | … | @@ -205,3 +321,159 @@ def extract_torrent_metadata(content: bytes): |
| 205 | 321 | |
| 206 | 322 | def convert_info_hash_to_magnet(info_hash: str) -> str: |
| 207 | 323 | return f"magnet:?xt=urn:btih:{info_hash}" |
| 324 | ||
| 325 | ||
| 326 | _TORRENT_META_CACHE_PREFIX = "torrent_meta:" | |
| 327 | ||
| 328 | ||
| 329 | def _normalize_torrent_hash(info_hash): | |
| 330 | return str(info_hash or "").strip().lower() | |
| 331 | ||
| 332 | ||
| 333 | def _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 | ||
| 342 | def 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 | ||
| 358 | def 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 | ||
| 377 | def _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 | ||
| 387 | def _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 | ||
| 396 | def 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 | ||
| 444 | def 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) |
| … | … | @@ -1,9 +1,10 @@ |
| 1 | 1 | from datetime import timedelta |
| 2 | ||
| 3 | import xbmcgui | |
| 4 | ||
| 2 | 5 | from lib.db.cached import cache |
| 3 | 6 | from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled |
| 4 | 7 | from lib.utils.kodi.utils import translation |
| 5 | import xbmcgui | |
| 6 | ||
| 7 | 8 | |
| 8 | 9 | items = [ |
| 9 | 10 | "YTS", |
| … | … | @@ -54,9 +55,7 @@ def providers_selection(identifier="torrentio_providers"): |
| 54 | 55 | providers, |
| 55 | 56 | timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0), |
| 56 | 57 | ) |
| 57 | xbmcgui.Dialog().ok( | |
| 58 | translation(90573), translation(90574) % ",".join(providers) | |
| 59 | ) | |
| 58 | xbmcgui.Dialog().ok(translation(90573), translation(90574) % ",".join(providers)) | |
| 60 | 59 | else: |
| 61 | 60 | xbmcgui.Dialog().notification( |
| 62 | 61 | translation(90575), translation(90576), xbmcgui.NOTIFICATION_INFO |
| … | … | @@ -1,28 +1,31 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | from lib.jacktook.utils import kodilog | |
| 3 | 1 | import json |
| 4 | 2 | import os |
| 3 | ||
| 5 | 4 | from xbmc import executebuiltin |
| 6 | from xbmcplugin import endOfDirectory, setContent | |
| 5 | from xbmcplugin import setContent | |
| 6 | ||
| 7 | from lib.db.pickle_db import PickleDatabase | |
| 8 | from lib.utils.general.utils import ( | |
| 9 | format_season_episode, | |
| 10 | set_pluging_category, | |
| 11 | truncate_text, | |
| 12 | ) | |
| 7 | 13 | from lib.utils.kodi.utils import ( |
| 8 | 14 | ADDON_HANDLE, |
| 9 | 15 | ADDON_PATH, |
| 10 | 16 | add_directory_items_batch, |
| 11 | 17 | apply_section_view, |
| 12 | 18 | build_url, |
| 19 | end_of_directory, | |
| 13 | 20 | make_list_item, |
| 14 | 21 | translation, |
| 15 | 22 | ) |
| 16 | from lib.db.pickle_db import PickleDatabase | |
| 17 | from lib.utils.general.utils import set_pluging_category | |
| 18 | 23 | from lib.utils.views.last_files import add_last_files_context_menu, parse_time |
| 19 | from lib.utils.kodi.utils import end_of_directory | |
| 20 | from lib.utils.general.utils import format_season_episode, truncate_text | |
| 21 | 24 | |
| 22 | 25 | |
| 23 | 26 | def has_continue_watching_items(): |
| 24 | 27 | all_items = PickleDatabase().get_key("jt:lfh").items() |
| 25 | for title, data in all_items: | |
| 28 | for _title, data in all_items: | |
| 26 | 29 | progress = float(data.get("progress", 0)) |
| 27 | 30 | if 5 < progress < 90: |
| 28 | 31 | return True |
| … | … | @@ -56,9 +59,7 @@ def show_continue_watching(): |
| 56 | 59 | |
| 57 | 60 | if tv_data: |
| 58 | 61 | 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")) | |
| 62 | 63 | if episode_label: |
| 63 | 64 | label = f"{show_name or label_title} {episode_label}" |
| 64 | 65 | else: |
| … | … | @@ -1,5 +1,6 @@ |
| 1 | 1 | import json |
| 2 | 2 | import os |
| 3 | ||
| 3 | 4 | from lib.db.pickle_db import PickleDatabase |
| 4 | 5 | from lib.utils.general.utils import ( |
| 5 | 6 | format_season_episode, |
| … | … | @@ -7,10 +8,15 @@ from lib.utils.general.utils import ( |
| 7 | 8 | set_pluging_category, |
| 8 | 9 | ) |
| 9 | 10 | from lib.utils.kodi.last_files_actions import add_last_files_context_menu |
| 10 | from 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 | ||
| 11 | from 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 | ) | |
| 14 | 20 | |
| 15 | 21 | pickle_db = PickleDatabase() |
| 16 | 22 | |
| … | … | @@ -21,9 +27,7 @@ def show_last_files(): |
| 21 | 27 | directory_items = [] |
| 22 | 28 | |
| 23 | 29 | 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")}) | |
| 27 | 31 | directory_items.append((build_url("clear_history", type="lfh"), list_item, True)) |
| 28 | 32 | |
| 29 | 33 | all_items = list(reversed(pickle_db.get_key("jt:lfh").items())) |
| … | … | @@ -35,9 +39,7 @@ def show_last_files(): |
| 35 | 39 | tv_data = data.get("tv_data", {}) |
| 36 | 40 | if tv_data: |
| 37 | 41 | 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")) | |
| 41 | 43 | if episode_label: |
| 42 | 44 | label = f"{title} {episode_label} - {name} — {formatted_time}" |
| 43 | 45 | else: |
| … | … | @@ -46,9 +48,7 @@ def show_last_files(): |
| 46 | 48 | label = f"{title}—{formatted_time}" |
| 47 | 49 | |
| 48 | 50 | 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")}) | |
| 52 | 52 | list_item.setProperty("IsPlayable", "true") |
| 53 | 53 | list_item.addContextMenuItems(add_last_files_context_menu(data)) |
| 54 | 54 | directory_items.append( |
| … | … | @@ -6,7 +6,6 @@ from lib.db.pickle_db import PickleDatabase |
| 6 | 6 | from lib.jacktook.utils import kodilog |
| 7 | 7 | from lib.utils.general.utils import parse_time, set_media_infoTag, set_pluging_category |
| 8 | 8 | from lib.utils.kodi.utils import ( |
| 9 | ADDON_HANDLE, | |
| 10 | 9 | ADDON_PATH, |
| 11 | 10 | add_directory_items_batch, |
| 12 | 11 | apply_section_view, |
| … | … | @@ -17,9 +16,6 @@ from lib.utils.kodi.utils import ( |
| 17 | 16 | translation, |
| 18 | 17 | ) |
| 19 | 18 | |
| 20 | ||
| 21 | ||
| 22 | ||
| 23 | 19 | pickle_db = PickleDatabase() |
| 24 | 20 | |
| 25 | 21 | |
| … | … | @@ -50,9 +46,7 @@ def show_last_titles(params): |
| 50 | 46 | directory_items = [] |
| 51 | 47 | |
| 52 | 48 | 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")}) | |
| 56 | 50 | directory_items.append((build_url("clear_history", type="lth"), list_item, True)) |
| 57 | 51 | |
| 58 | 52 | for title, data in items: |
| … | … | @@ -71,14 +65,12 @@ def show_last_titles(params): |
| 71 | 65 | |
| 72 | 66 | list_item = make_list_item(label=f"{title} — {formatted_time}") |
| 73 | 67 | 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")}) | |
| 77 | 69 | list_item.addContextMenuItems( |
| 78 | 70 | [ |
| 79 | 71 | ( |
| 80 | 72 | "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)})", | |
| 82 | 74 | ) |
| 83 | 75 | ] |
| 84 | 76 | ) |
| … | … | @@ -96,7 +88,8 @@ def show_last_titles(params): |
| 96 | 88 | directory_items.append( |
| 97 | 89 | ( |
| 98 | 90 | 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")), | |
| 100 | 93 | ), |
| 101 | 94 | list_item, |
| 102 | 95 | True, |
| … | … | @@ -105,10 +98,8 @@ def show_last_titles(params): |
| 105 | 98 | |
| 106 | 99 | # "Next Page" |
| 107 | 100 | 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")}) | |
| 112 | 103 | directory_items.append((build_url("titles_history", page=page + 1), list_item, True)) |
| 113 | 104 | |
| 114 | 105 | add_directory_items_batch(directory_items) |
| … | … | @@ -1,20 +1,31 @@ |
| 1 | # -*- coding: utf-8 -*- | |
| 2 | 1 | import os |
| 3 | 2 | |
| 4 | from xbmcgui import ListItem | |
| 5 | from xbmcplugin import addDirectoryItem, setContent | |
| 3 | from xbmcplugin import setContent | |
| 6 | 4 | |
| 7 | 5 | from lib.clients.tmdb.utils.utils import tmdb_get |
| 8 | 6 | from lib.db.cached import cache |
| 9 | 7 | from lib.db.pickle_db import PickleDatabase |
| 10 | from lib.jacktook.utils import kodilog | |
| 11 | from lib.utils.general.utils import remove_from_library, set_media_infoTag, set_pluging_category | |
| 12 | from 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 | |
| 8 | from lib.utils.general.utils import ( | |
| 9 | remove_from_library, | |
| 10 | set_media_infoTag, | |
| 11 | set_pluging_category, | |
| 12 | ) | |
| 13 | from 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 | ) | |
| 13 | 24 | from lib.utils.views.last_titles import parse_time |
| 14 | 25 | |
| 15 | 26 | |
| 16 | 27 | def _library_cache_key(mode): |
| 17 | return "library_view|{}".format(mode) | |
| 28 | return f"library_view|{mode}" | |
| 18 | 29 | |
| 19 | 30 | |
| 20 | 31 | def _is_stremio_library_item(data): |
| … | … | @@ -166,9 +177,7 @@ def show_library_items(mode="tv"): |
| 166 | 177 | list_item.addContextMenuItems(_build_library_context_menu(title)) |
| 167 | 178 | |
| 168 | 179 | # 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")}) | |
| 172 | 181 | |
| 173 | 182 | item_url = _get_library_item_url(entry, mode) |
| 174 | 183 | is_folder = mode == "tv" |
| … | … | @@ -1,27 +1,27 @@ |
| 1 | 1 | import json |
| 2 | from urllib.parse import quote | |
| 3 | ||
| 2 | 4 | from lib.api.trakt.trakt_utils import add_trakt_watched_context_menu, is_trakt_auth |
| 3 | 5 | from lib.clients.tmdb.utils.utils import ( |
| 4 | 6 | add_tmdb_episode_context_menu, |
| 5 | 7 | add_tmdb_show_context_menu, |
| 6 | 8 | tmdb_get, |
| 7 | 9 | ) |
| 10 | from lib.utils.general.utils import ( | |
| 11 | execute_thread_pool_collection, | |
| 12 | get_fanart_details, | |
| 13 | set_content_type, | |
| 14 | set_media_infoTag, | |
| 15 | ) | |
| 8 | 16 | from lib.utils.kodi.utils import ( |
| 9 | ADDON_HANDLE, | |
| 10 | 17 | add_directory_items_batch, |
| 11 | 18 | apply_section_view, |
| 12 | 19 | build_url, |
| 13 | 20 | end_of_directory, |
| 14 | 21 | get_setting, |
| 22 | kodilog, | |
| 15 | 23 | make_list_item, |
| 16 | 24 | ) |
| 17 | from 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 | ||
| 25 | 25 | |
| 26 | 26 | |
| 27 | 27 | def show_seasons_details(params): |
| … | … | @@ -49,8 +49,8 @@ def show_season_info(ids, mode, media_type): |
| 49 | 49 | ids = {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id} |
| 50 | 50 | |
| 51 | 51 | 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 | |
| 54 | 54 | fanart_details = get_fanart_details(tvdb_id=tvdb_id, mode=mode) |
| 55 | 55 | |
| 56 | 56 | results = execute_thread_pool_collection( |
| … | … | @@ -91,9 +91,7 @@ def _process_season(season, details, name, ids, mode, media_type, fanart_details |
| 91 | 91 | |
| 92 | 92 | context_menu = add_tmdb_show_context_menu(mode, ids) |
| 93 | 93 | 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) | |
| 97 | 95 | list_item.addContextMenuItems(context_menu) |
| 98 | 96 | |
| 99 | 97 | url = build_url( |
| … | … | @@ -117,19 +115,35 @@ def show_episodes_details(params): |
| 117 | 115 | mode = params.get("mode", "") |
| 118 | 116 | media_type = params.get("media_type", "") |
| 119 | 117 | |
| 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}") | |
| 121 | 124 | end_of_directory() |
| 125 | kodilog("[EPISODES] show_episodes_details: end_of_directory called") | |
| 122 | 126 | apply_section_view("view.episodes", content_type="episodes") |
| 123 | 127 | |
| 124 | 128 | |
| 125 | 129 | def 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}" | |
| 128 | 142 | ) |
| 129 | 143 | fanart_details = get_fanart_details(tvdb_id=ids.get("tvdb_id"), mode=mode) |
| 130 | 144 | |
| 131 | 145 | results = execute_thread_pool_collection( |
| 132 | getattr(season_details, "episodes"), | |
| 146 | episodes, | |
| 133 | 147 | _process_episode, |
| 134 | 148 | tv_name, |
| 135 | 149 | season, |
| … | … | @@ -141,23 +155,27 @@ def show_episode_info(tv_name, season, ids, mode, media_type): |
| 141 | 155 | |
| 142 | 156 | # Sort by episode number |
| 143 | 157 | 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 | ) | |
| 144 | 163 | |
| 145 | 164 | add_directory_items_batch( |
| 146 | 165 | [(url, list_item, False) for _, url, list_item in results if list_item is not None] |
| 147 | 166 | ) |
| 167 | return item_count | |
| 148 | 168 | |
| 149 | 169 | |
| 150 | 170 | def _process_episode(episode, tv_name, season, ids, mode, media_type, fanart_details): |
| 151 | 171 | ep_name = episode.name |
| 152 | 172 | episode_number = episode.episode_number |
| 153 | 173 | |
| 154 | tv_data = {"name": ep_name, "episode": episode_number, "season": season} | |
| 174 | tv_data = {"name": quote(ep_name), "episode": episode_number, "season": season} | |
| 155 | 175 | |
| 156 | 176 | list_item = make_list_item(label=f"{season}x{episode_number}. {ep_name}") |
| 157 | 177 | 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") | |
| 161 | 179 | |
| 162 | 180 | context_menu = add_tmdb_episode_context_menu(mode, tv_name, tv_data, ids) |
| 163 | 181 | if is_trakt_auth(): |
| … | … | @@ -1,31 +1,29 @@ |
| 1 | from datetime import datetime, date, timedelta | |
| 2 | from copy import copy | |
| 3 | 1 | import os |
| 4 | 2 | import re |
| 3 | from copy import copy | |
| 4 | from datetime import date, datetime, timedelta | |
| 5 | from urllib.parse import quote | |
| 5 | 6 | |
| 6 | 7 | from lib.clients.tmdb.utils.utils import tmdb_get |
| 7 | 8 | from lib.db.pickle_db import PickleDatabase |
| 8 | 9 | from lib.jacktook.utils import kodilog |
| 10 | from lib.utils.general.utils import ( | |
| 11 | execute_thread_pool, | |
| 12 | set_media_infoTag, | |
| 13 | set_pluging_category, | |
| 14 | translate_weekday, | |
| 15 | ) | |
| 9 | 16 | from lib.utils.kodi.utils import ( |
| 10 | ADDON_HANDLE, | |
| 11 | 17 | ADDON_PATH, |
| 12 | 18 | add_directory_items_batch, |
| 13 | 19 | apply_section_view, |
| 14 | 20 | build_url, |
| 15 | 21 | end_of_directory, |
| 16 | translation, | |
| 22 | get_setting, | |
| 17 | 23 | kodi_play_media, |
| 18 | 24 | make_list_item, |
| 25 | translation, | |
| 19 | 26 | ) |
| 20 | from lib.utils.general.utils import ( | |
| 21 | execute_thread_pool, | |
| 22 | set_media_infoTag, | |
| 23 | set_pluging_category, | |
| 24 | translate_weekday, | |
| 25 | ) | |
| 26 | from lib.utils.kodi.utils import get_setting | |
| 27 | ||
| 28 | ||
| 29 | 27 | |
| 30 | 28 | |
| 31 | 29 | def show_weekly_calendar(library=False): |
| … | … | @@ -36,9 +34,7 @@ def show_weekly_calendar(library=False): |
| 36 | 34 | else: |
| 37 | 35 | source_dict = PickleDatabase().get_key("jt:lth") |
| 38 | 36 | |
| 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"] | |
| 42 | 38 | |
| 43 | 39 | results = [] |
| 44 | 40 | |
| … | … | @@ -50,9 +46,11 @@ def show_weekly_calendar(library=False): |
| 50 | 46 | def calculate_adjusted_broadcast(date_str, offset_hours): |
| 51 | 47 | """ |
| 52 | 48 | Calculates an adjusted date and time based on a manual hour offset. |
| 49 | ||
| 53 | 50 | Assumes the original air time is midnight (00:00). |
| 54 | 51 | """ |
| 55 | 52 | if not date_str: |
| 53 | ||
| 56 | 54 | return None, None |
| 57 | 55 | |
| 58 | 56 | try: |
| … | … | @@ -98,9 +96,7 @@ def show_weekly_calendar(library=False): |
| 98 | 96 | date_item = make_list_item( |
| 99 | 97 | label=f"[UPPERCASE][COLOR=orange]Today: {current_date}[/COLOR][/UPPERCASE]" |
| 100 | 98 | ) |
| 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")}) | |
| 104 | 100 | directory_items.append(("", date_item, False)) |
| 105 | 101 | |
| 106 | 102 | today_str = datetime.now().strftime("%Y-%m-%d") |
| … | … | @@ -132,7 +128,7 @@ def show_weekly_calendar(library=False): |
| 132 | 128 | ids["tvdb_id"] = external_ids.get("tvdb_id") |
| 133 | 129 | |
| 134 | 130 | tv_data = { |
| 135 | "name": ep_name or title, | |
| 131 | "name": quote(ep_name or title), | |
| 136 | 132 | "episode": ep["number"], |
| 137 | 133 | "season": ep["season"], |
| 138 | 134 | } |
| … | … | @@ -144,9 +140,7 @@ def show_weekly_calendar(library=False): |
| 144 | 140 | |
| 145 | 141 | # Mark if episode is released today |
| 146 | 142 | 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 "" | |
| 150 | 144 | |
| 151 | 145 | 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}" |
| 152 | 146 | |
| … | … | @@ -201,18 +195,16 @@ def get_episodes_for_show(ids): |
| 201 | 195 | |
| 202 | 196 | try: |
| 203 | 197 | show_details = tmdb_get("tv_details", tmdb_id) |
| 204 | seasons = getattr(show_details, "seasons") | |
| 198 | seasons = show_details.seasons | |
| 205 | 199 | seasons = [s for s in seasons if s.get("season_number", 0) > 0] |
| 206 | 200 | if not seasons: |
| 207 | 201 | return [], show_details |
| 208 | 202 | latest_season = max(seasons, key=lambda s: s.get("season_number", 0)) |
| 209 | 203 | 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}) | |
| 213 | 205 | |
| 214 | 206 | episodes = [] |
| 215 | for ep in getattr(season_details, "episodes"): | |
| 207 | for ep in season_details.episodes: | |
| 216 | 208 | air_date = ep.get("air_date") |
| 217 | 209 | if air_date: |
| 218 | 210 | episodes.append( |
| … | … | @@ -232,18 +224,20 @@ def get_episodes_for_show(ids): |
| 232 | 224 | def is_this_week(date_str): |
| 233 | 225 | """Check if the date_str (YYYY-MM-DD) is in the current week.""" |
| 234 | 226 | try: |
| 227 | ||
| 235 | 228 | d = parse_date_str(date_str) |
| 236 | 229 | today = date.today() |
| 237 | 230 | year, week, _ = today.isocalendar() |
| 238 | 231 | d_year, d_week, _ = d.isocalendar() |
| 239 | 232 | return (d_year, d_week) == (year, week) |
| 240 | 233 | except Exception as e: |
| 241 | kodilog(f"Error: {str(e)}") | |
| 234 | kodilog(f"Error: {e!s}") | |
| 242 | 235 | |
| 243 | 236 | |
| 244 | 237 | def parse_date_str(date_str: str) -> date: |
| 245 | 238 | """Parse a date string (YYYY-MM-DD or similar) into a date object without using datetime.strptime.""" |
| 246 | 239 | date_str_cleaned = date_str.strip().split("T")[0] |
| 240 | ||
| 247 | 241 | match = re.search(r"(\d{4})[\-\/]?(\d{1,2})[\-\/]?(\d{1,2})", date_str_cleaned) |
| 248 | 242 | if match: |
| 249 | 243 | year, month, day = map(int, match.groups()) |
| … | … | @@ -3,7 +3,4 @@ |
| 3 | 3 | # Compatibility with previous versions: |
| 4 | 4 | from bencode.exceptions import BencodeDecodeError as BTFailure # noqa: F401 |
| 5 | 5 | |
| 6 | ||
| 7 | __all__ = ( | |
| 8 | 'BTFailure' | |
| 9 | ) | |
| 6 | __all__ = "BTFailure" |
| … | … | @@ -12,29 +12,29 @@ |
| 12 | 12 | |
| 13 | 13 | """bencode.py - bencode encoder + decoder.""" |
| 14 | 14 | |
| 15 | from bencodepy import Bencached, Bencode | |
| 16 | ||
| 15 | 17 | from bencode.BTL import BTFailure |
| 16 | 18 | from bencode.exceptions import BencodeDecodeError |
| 17 | 19 | |
| 18 | from bencodepy import Bencached, Bencode | |
| 19 | ||
| 20 | 20 | __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", | |
| 30 | 30 | ) |
| 31 | 31 | |
| 32 | 32 | |
| 33 | 33 | DEFAULT = Bencode( |
| 34 | encoding='utf-8', | |
| 35 | encoding_fallback='value', | |
| 34 | encoding="utf-8", | |
| 35 | encoding_fallback="value", | |
| 36 | 36 | dict_ordered=True, |
| 37 | dict_ordered_sort=True | |
| 37 | dict_ordered_sort=True, | |
| 38 | 38 | ) |
| 39 | 39 | |
| 40 | 40 | bdecode = DEFAULT.decode |
| … | … | @@ -1,6 +1,5 @@ |
| 1 | 1 | """bencode.py - encoder + decode exceptions.""" |
| 2 | ||
| 2 | 3 | from bencodepy.exceptions import BencodeDecodeError |
| 3 | 4 | |
| 4 | __all__ = ( | |
| 5 | 'BencodeDecodeError', | |
| 6 | ) | |
| 5 | __all__ = ("BencodeDecodeError",) |
| … | … | @@ -18,7 +18,7 @@ from .encoder import BencodeEncoder |
| 18 | 18 | from .exceptions import BencodeDecodeError |
| 19 | 19 | |
| 20 | 20 | try: |
| 21 | from typing import Dict, List, Tuple, Deque, Union, TextIO, BinaryIO, Any | |
| 21 | from typing import Any, BinaryIO, Deque, Dict, List, TextIO, Tuple, Union | |
| 22 | 22 | except ImportError: |
| 23 | 23 | Dict = List = Tuple = Deque = Union = TextIO = BinaryIO = Any = None |
| 24 | 24 | |
| … | … | @@ -33,27 +33,33 @@ except ImportError: |
| 33 | 33 | pathlib = None |
| 34 | 34 | |
| 35 | 35 | __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", | |
| 47 | 47 | ) |
| 48 | 48 | |
| 49 | 49 | |
| 50 | class Bencode(object): | |
| 51 | def __init__(self, encoding=None, encoding_fallback=None, dict_ordered=False, dict_ordered_sort=False): | |
| 50 | class Bencode: | |
| 51 | def __init__( | |
| 52 | self, | |
| 53 | encoding=None, | |
| 54 | encoding_fallback=None, | |
| 55 | dict_ordered=False, | |
| 56 | dict_ordered_sort=False, | |
| 57 | ): | |
| 52 | 58 | self.decoder = BencodeDecoder( |
| 53 | 59 | encoding=encoding, |
| 54 | 60 | encoding_fallback=encoding_fallback, |
| 55 | 61 | dict_ordered=dict_ordered, |
| 56 | dict_ordered_sort=dict_ordered_sort | |
| 62 | dict_ordered_sort=dict_ordered_sort, | |
| 57 | 63 | ) |
| 58 | 64 | |
| 59 | 65 | self.encoder = BencodeEncoder() |
| … | … | @@ -84,9 +90,10 @@ class Bencode(object): |
| 84 | 90 | """ |
| 85 | 91 | return self.encoder.encode(value) |
| 86 | 92 | |
| 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 | ): | |
| 90 | 97 | # type: (...) -> Union[Tuple, List, OrderedDict, bool, int, str, bytes] |
| 91 | 98 | """Return bdecoded data from filename, file, or file-like object. |
| 92 | 99 | |
| … | … | @@ -95,18 +102,19 @@ class Bencode(object): |
| 95 | 102 | raised. |
| 96 | 103 | """ |
| 97 | 104 | if isinstance(fd, (bytes, str)): |
| 98 | with open(fd, 'rb') as fd: | |
| 105 | with open(fd, "rb") as fd: | |
| 99 | 106 | return self.decode(fd.read()) |
| 100 | 107 | 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: | |
| 102 | 109 | return self.decode(fd.read()) |
| 103 | 110 | else: |
| 104 | 111 | return self.decode(fd.read()) |
| 105 | 112 | |
| 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 | ): | |
| 110 | 118 | # type: (...) -> None |
| 111 | 119 | """Write data in bencoded form to filename, file, or file-like object. |
| 112 | 120 | |
| … | … | @@ -115,10 +123,10 @@ class Bencode(object): |
| 115 | 123 | exception raised. |
| 116 | 124 | """ |
| 117 | 125 | if isinstance(fd, (bytes, str)): |
| 118 | with open(fd, 'wb') as fd: | |
| 126 | with open(fd, "wb") as fd: | |
| 119 | 127 | fd.write(self.encode(data)) |
| 120 | 128 | 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: | |
| 122 | 130 | fd.write(self.encode(data)) |
| 123 | 131 | else: |
| 124 | 132 | fd.write(self.encode(data)) |
| … | … | @@ -155,8 +163,9 @@ def bdecode(value): |
| 155 | 163 | return DEFAULT.decode(value) |
| 156 | 164 | |
| 157 | 165 | |
| 158 | def bread(fd # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO] | |
| 159 | ): | |
| 166 | def bread( | |
| 167 | fd, # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO] | |
| 168 | ): | |
| 160 | 169 | # type: (...) -> Union[Tuple, List, OrderedDict, bool, int, str, bytes] |
| 161 | 170 | """Return bdecoded data from filename, file, or file-like object. |
| 162 | 171 | |
| … | … | @@ -167,9 +176,10 @@ def bread(fd # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, |
| 167 | 176 | return DEFAULT.read(fd) |
| 168 | 177 | |
| 169 | 178 | |
| 170 | def 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 | ): | |
| 179 | def 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 | ): | |
| 173 | 183 | # type: (...) -> None |
| 174 | 184 | """Write data in bencoded form to filename, file, or file-like object. |
| 175 | 185 |
| … | … | @@ -1,8 +1,8 @@ |
| 1 | 1 | """bencode.py - common.""" |
| 2 | 2 | |
| 3 | 3 | |
| 4 | class Bencached(object): | |
| 5 | __slots__ = ['bencoded'] | |
| 4 | class Bencached: | |
| 5 | __slots__ = ["bencoded"] | |
| 6 | 6 | |
| 7 | 7 | def __init__(self, s): |
| 8 | 8 | self.bencoded = s |
| … | … | @@ -25,6 +25,6 @@ def to_binary(s): |
| 25 | 25 | return s |
| 26 | 26 | |
| 27 | 27 | if is_text(s): |
| 28 | return s.encode('utf-8', 'strict') | |
| 28 | return s.encode("utf-8", "strict") | |
| 29 | 29 | |
| 30 | raise TypeError("expected binary or text (found %s)" % type(s)) | |
| 30 | raise TypeError(f"expected binary or text (found {type(s)})") |
| … | … | @@ -12,12 +12,13 @@ |
| 12 | 12 | |
| 13 | 13 | """bencode.py - bencode decoder.""" |
| 14 | 14 | |
| 15 | from collections import OrderedDict | |
| 16 | ||
| 15 | 17 | from .compat import to_binary |
| 16 | 18 | from .exceptions import BencodeDecodeError |
| 17 | from collections import OrderedDict | |
| 18 | 19 | |
| 19 | 20 | try: |
| 20 | from typing import Dict, List, Tuple, Deque, Union, TextIO, BinaryIO, Any | |
| 21 | from typing import Any, BinaryIO, Deque, Dict, List, TextIO, Tuple, Union | |
| 21 | 22 | except ImportError: |
| 22 | 23 | Dict = List = Tuple = Deque = Union = TextIO = BinaryIO = Any = None |
| 23 | 24 | |
| … | … | @@ -26,11 +27,17 @@ try: |
| 26 | 27 | except ImportError: |
| 27 | 28 | pathlib = None |
| 28 | 29 | |
| 29 | ENCODING_FALLBACK_TYPES = ('key', 'value') | |
| 30 | ENCODING_FALLBACK_TYPES = ("key", "value") | |
| 30 | 31 | |
| 31 | 32 | |
| 32 | class BencodeDecoder(object): | |
| 33 | def __init__(self, encoding=None, encoding_fallback=None, dict_ordered=False, dict_ordered_sort=False): | |
| 33 | class BencodeDecoder: | |
| 34 | def __init__( | |
| 35 | self, | |
| 36 | encoding=None, | |
| 37 | encoding_fallback=None, | |
| 38 | dict_ordered=False, | |
| 39 | dict_ordered_sort=False, | |
| 40 | ): | |
| 34 | 41 | self.encoding = encoding |
| 35 | 42 | self.dict_ordered = dict_ordered |
| 36 | 43 | self.dict_ordered_sort = dict_ordered_sort |
| … | … | @@ -41,12 +48,14 @@ class BencodeDecoder(object): |
| 41 | 48 | ) |
| 42 | 49 | |
| 43 | 50 | # 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 | ): | |
| 45 | 54 | raise ValueError( |
| 46 | 55 | 'Invalid value for "encoding_fallback" (expected "all", "keys", "values" or None)' |
| 47 | 56 | ) |
| 48 | 57 | |
| 49 | if encoding_fallback == 'all': | |
| 58 | if encoding_fallback == "all": | |
| 50 | 59 | self.encoding_fallback = ENCODING_FALLBACK_TYPES |
| 51 | 60 | elif encoding_fallback is not None: |
| 52 | 61 | self.encoding_fallback = (encoding_fallback,) |
| … | … | @@ -55,19 +64,19 @@ class BencodeDecoder(object): |
| 55 | 64 | |
| 56 | 65 | # noinspection PyDictCreation |
| 57 | 66 | 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 | |
| 71 | 80 | |
| 72 | 81 | def decode(self, value): |
| 73 | 82 | # type: (bytes) -> Union[Tuple, List, OrderedDict, bool, int, str, bytes] |
| … | … | @@ -94,28 +103,28 @@ class BencodeDecoder(object): |
| 94 | 103 | def decode_int(self, x, f): |
| 95 | 104 | # type: (bytes, int) -> Tuple[int, int] |
| 96 | 105 | f += 1 |
| 97 | newf = x.index(b'e', f) | |
| 106 | newf = x.index(b"e", f) | |
| 98 | 107 | n = int(x[f:newf]) |
| 99 | 108 | |
| 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": | |
| 102 | 111 | 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: | |
| 104 | 113 | raise ValueError |
| 105 | 114 | |
| 106 | 115 | return n, newf + 1 |
| 107 | 116 | |
| 108 | def decode_string(self, x, f, kind='value'): | |
| 117 | def decode_string(self, x, f, kind="value"): | |
| 109 | 118 | # type: (bytes, int) -> Tuple[bytes, int] |
| 110 | 119 | """Decode torrent bencoded 'string' in x starting at f.""" |
| 111 | colon = x.index(b':', f) | |
| 120 | colon = x.index(b":", f) | |
| 112 | 121 | n = int(x[f:colon]) |
| 113 | 122 | |
| 114 | if x[f:f + 1] == b'0' and colon != f + 1: | |
| 123 | if x[f : f + 1] == b"0" and colon != f + 1: | |
| 115 | 124 | raise ValueError |
| 116 | 125 | |
| 117 | 126 | colon += 1 |
| 118 | s = x[colon:colon + n] | |
| 127 | s = x[colon : colon + n] | |
| 119 | 128 | |
| 120 | 129 | if self.encoding: |
| 121 | 130 | try: |
| … | … | @@ -130,8 +139,8 @@ class BencodeDecoder(object): |
| 130 | 139 | # type: (bytes, int) -> Tuple[List, int] |
| 131 | 140 | r, f = [], f + 1 |
| 132 | 141 | |
| 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) | |
| 135 | 144 | r.append(v) |
| 136 | 145 | |
| 137 | 146 | return r, f + 1 |
| … | … | @@ -139,7 +148,6 @@ class BencodeDecoder(object): |
| 139 | 148 | def decode_dict(self, x, f): |
| 140 | 149 | # type: (bytes, int) -> Tuple[OrderedDict[str, Any], int] |
| 141 | 150 | """Decode bencoded dictionary.""" |
| 142 | ||
| 143 | 151 | f += 1 |
| 144 | 152 | |
| 145 | 153 | if self.dict_ordered: |
| … | … | @@ -147,9 +155,9 @@ class BencodeDecoder(object): |
| 147 | 155 | else: |
| 148 | 156 | r = {} |
| 149 | 157 | |
| 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) | |
| 153 | 161 | |
| 154 | 162 | if self.dict_ordered_sort: |
| 155 | 163 | r = OrderedDict(sorted(r.items())) |
| … | … | @@ -12,12 +12,13 @@ |
| 12 | 12 | |
| 13 | 13 | """bencode.py - bencode encoder.""" |
| 14 | 14 | |
| 15 | from collections import deque | |
| 16 | ||
| 15 | 17 | from .common import Bencached |
| 16 | 18 | from .compat import PY2, to_binary |
| 17 | from collections import deque | |
| 18 | 19 | |
| 19 | 20 | try: |
| 20 | from typing import Dict, List, Tuple, Deque, Union, TextIO, BinaryIO, Any | |
| 21 | from typing import Any, BinaryIO, Deque, Dict, List, TextIO, Tuple, Union | |
| 21 | 22 | except ImportError: |
| 22 | 23 | Dict = List = Tuple = Deque = Union = TextIO = BinaryIO = Any = None |
| 23 | 24 | |
| … | … | @@ -32,14 +33,22 @@ except ImportError: |
| 32 | 33 | pathlib = None |
| 33 | 34 | |
| 34 | 35 | |
| 35 | class BencodeEncoder(object): | |
| 36 | class BencodeEncoder: | |
| 36 | 37 | def __init__(self): |
| 37 | 38 | # noinspection PyDictCreation |
| 38 | 39 | self.encode_func = {} |
| 39 | 40 | self.encode_func[Bencached] = self.encode_bencached |
| 40 | 41 | |
| 41 | 42 | 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 | ) | |
| 43 | 52 | |
| 44 | 53 | self.encode_func[DictType] = self.encode_dict |
| 45 | 54 | self.encode_func[IntType] = self.encode_int |
| … | … | @@ -85,7 +94,7 @@ class BencodeEncoder(object): |
| 85 | 94 | self.encode_func[type(value)](value, r) |
| 86 | 95 | |
| 87 | 96 | # Join parts |
| 88 | return b''.join(r) | |
| 97 | return b"".join(r) | |
| 89 | 98 | |
| 90 | 99 | def encode_bencached(self, x, r): |
| 91 | 100 | # type: (Bencached, Deque[bytes]) -> None |
| … | … | @@ -93,7 +102,7 @@ class BencodeEncoder(object): |
| 93 | 102 | |
| 94 | 103 | def encode_int(self, x, r): |
| 95 | 104 | # 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")) | |
| 97 | 106 | |
| 98 | 107 | def encode_bool(self, x, r): |
| 99 | 108 | # type: (bool, Deque[bytes]) -> None |
| … | … | @@ -104,7 +113,7 @@ class BencodeEncoder(object): |
| 104 | 113 | |
| 105 | 114 | def encode_bytes(self, x, r): |
| 106 | 115 | # 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)) | |
| 108 | 117 | |
| 109 | 118 | def encode_string(self, x, r): |
| 110 | 119 | # type: (str, Deque[bytes]) -> None |
| … | … | @@ -112,16 +121,16 @@ class BencodeEncoder(object): |
| 112 | 121 | |
| 113 | 122 | def encode_list(self, x, r): |
| 114 | 123 | # type: (List, Deque[bytes]) -> None |
| 115 | r.append(b'l') | |
| 124 | r.append(b"l") | |
| 116 | 125 | |
| 117 | 126 | for i in x: |
| 118 | 127 | self.encode_func[type(i)](i, r) |
| 119 | 128 | |
| 120 | r.append(b'e') | |
| 129 | r.append(b"e") | |
| 121 | 130 | |
| 122 | 131 | def encode_dict(self, x, r): |
| 123 | 132 | # type: (Dict, Deque[bytes]) -> None |
| 124 | r.append(b'd') | |
| 133 | r.append(b"d") | |
| 125 | 134 | |
| 126 | 135 | # force all keys to bytes, because str and bytes are incomparable |
| 127 | 136 | ilist = [(to_binary(k), v) for k, v in x.items()] |
| … | … | @@ -131,4 +140,4 @@ class BencodeEncoder(object): |
| 131 | 140 | self.encode_func[type(k)](k, r) |
| 132 | 141 | self.encode_func[type(v)](v, r) |
| 133 | 142 | |
| 134 | r.append(b'e') | |
| 143 | r.append(b"e") |
| … | … | @@ -9,27 +9,54 @@ QR Code and Micro QR Code implementation. |
| 9 | 9 | |
| 10 | 10 | "QR Code" and "Micro QR Code" are registered trademarks of DENSO WAVE INCORPORATED. |
| 11 | 11 | """ |
| 12 | import sys | |
| 13 | import io | |
| 14 | from . import encoder | |
| 15 | from .encoder import DataOverflowError | |
| 16 | from . import writers, utils | |
| 17 | ||
| 18 | __version__ = '1.6.6' | |
| 19 | ||
| 20 | __all__ = ('make', 'make_qr', 'make_micro', 'make_sequence', 'QRCode', | |
| 21 | 'QRCodeSequence', 'DataOverflowError') | |
| 22 | 12 | |
| 13 | import io | |
| 14 | import sys | |
| 23 | 15 | |
| 24 | def 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)) | |
| 16 | from . import encoder, utils, writers | |
| 17 | from .encoder import DataOverflowError | |
| 28 | 18 | |
| 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 | ||
| 32 | def 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 | ) | |
| 29 | 56 | |
| 30 | 57 | |
| 31 | 58 | class QRCode: |
| 32 | __slots__ = ('_error', '_matrix_size', '_mode', '_version', 'mask', 'matrix') | |
| 59 | __slots__ = ("_error", "_matrix_size", "_mode", "_version", "mask", "matrix") | |
| 33 | 60 | |
| 34 | 61 | def __init__(self, code): |
| 35 | 62 | matrix = code.matrix |
| … | … | @@ -59,7 +86,7 @@ class QRCode: |
| 59 | 86 | @property |
| 60 | 87 | def designator(self): |
| 61 | 88 | 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,)) | |
| 63 | 90 | |
| 64 | 91 | @property |
| 65 | 92 | def default_border_size(self): |
| … | … | @@ -81,13 +108,14 @@ class QRCode: |
| 81 | 108 | iterfn = utils.matrix_iter_verbose if verbose else utils.matrix_iter |
| 82 | 109 | return iterfn(self.matrix, self._matrix_size, scale, border) |
| 83 | 110 | |
| 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 | |
| 86 | 114 | import os |
| 87 | import time | |
| 88 | 115 | import tempfile |
| 89 | import webbrowser | |
| 90 | 116 | import threading |
| 117 | import time | |
| 118 | import webbrowser | |
| 91 | 119 | from urllib.parse import urljoin |
| 92 | 120 | from urllib.request import pathname2url |
| 93 | 121 | |
| … | … | @@ -98,7 +126,7 @@ class QRCode: |
| 98 | 126 | except OSError: |
| 99 | 127 | pass |
| 100 | 128 | |
| 101 | f = tempfile.NamedTemporaryFile('wb', suffix='.png', delete=False) | |
| 129 | f = tempfile.NamedTemporaryFile("wb", suffix=".png", delete=False) | |
| 102 | 130 | try: |
| 103 | 131 | self.save(f, scale=scale, dark=dark, light=light, border=border) |
| 104 | 132 | except: |
| … | … | @@ -106,30 +134,36 @@ class QRCode: |
| 106 | 134 | os.unlink(f.name) |
| 107 | 135 | raise |
| 108 | 136 | f.close() |
| 109 | webbrowser.open_new_tab(urljoin('file:', pathname2url(f.name))) | |
| 137 | webbrowser.open_new_tab(urljoin("file:", pathname2url(f.name))) | |
| 110 | 138 | if delete_after is not None: |
| 111 | 139 | t = threading.Thread(target=delete_file, args=(f.name,)) |
| 112 | 140 | t.start() |
| 113 | 141 | |
| 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 | ) | |
| 120 | 152 | |
| 121 | 153 | def svg_inline(self, **kw): |
| 122 | 154 | 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")) | |
| 125 | 157 | |
| 126 | 158 | def png_data_uri(self, **kw): |
| 127 | 159 | return writers.as_png_data_uri(self.matrix, self._matrix_size, **kw) |
| 128 | 160 | |
| 129 | 161 | def terminal(self, out=None, border=None, compact=False): |
| 130 | 162 | 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 | |
| 133 | 167 | try: |
| 134 | 168 | writers.write_terminal_win(self.matrix, self._matrix_size, border) |
| 135 | 169 | except OSError: |
| … | … | @@ -141,17 +175,17 @@ class QRCode: |
| 141 | 175 | writers.save(self.matrix, self._matrix_size, out, kind, **kw) |
| 142 | 176 | |
| 143 | 177 | def __getattr__(self, name): |
| 144 | if name.startswith('to_'): | |
| 178 | if name.startswith("to_"): | |
| 145 | 179 | try: |
| 146 | 180 | import importlib_metadata as metadata |
| 147 | 181 | except ImportError: |
| 148 | 182 | from importlib import metadata |
| 149 | 183 | 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:]): | |
| 152 | 186 | plugin = ep.load() |
| 153 | 187 | 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}") | |
| 155 | 189 | |
| 156 | 190 | |
| 157 | 191 | class QRCodeSequence(tuple): |
| … | … | @@ -168,9 +202,9 @@ class QRCodeSequence(tuple): |
| 168 | 202 | filename = lambda o, n: o # noqa: E731 |
| 169 | 203 | m = len(self) |
| 170 | 204 | if m > 1 and isinstance(out, str): |
| 171 | dot_idx = out.rfind('.') | |
| 205 | dot_idx = out.rfind(".") | |
| 172 | 206 | 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:] | |
| 174 | 208 | filename = lambda o, n: o.format(m, n) # noqa: E731 |
| 175 | 209 | for n, qrcode in enumerate(self, start=1): |
| 176 | 210 | qrcode.save(filename(out, n), kind=kind, **kw) |
| … | … | @@ -9,9 +9,10 @@ Constants. |
| 9 | 9 | |
| 10 | 10 | Internal module. May change without further warning. |
| 11 | 11 | """ |
| 12 | ||
| 12 | 13 | from collections import namedtuple |
| 13 | 14 | |
| 14 | ALPHANUMERIC_CHARS = br'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:' | |
| 15 | ALPHANUMERIC_CHARS = rb"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" | |
| 15 | 16 | |
| 16 | 17 | # ISO/IEC 18004:2015(E) -- Table 2 — Mode indicators for QR Code (page 23) |
| 17 | 18 | MODE_NUMERIC = 0x1 |
| … | … | @@ -38,7 +39,7 @@ MODE_TO_RECT_MICRO_MODE_MAPPING = { |
| 38 | 39 | MODE_NUMERIC: 0x1, |
| 39 | 40 | MODE_ALPHANUMERIC: 0x2, |
| 40 | 41 | MODE_BYTE: 0x3, |
| 41 | MODE_KANJI: 0x64 | |
| 42 | MODE_KANJI: 0x64, | |
| 42 | 43 | } |
| 43 | 44 | |
| 44 | 45 | # Note: These versions must be comparable: Version 1 > M4 > M3 > M2 > M1 |
| … | … | @@ -48,10 +49,10 @@ VERSION_M2 = -2 |
| 48 | 49 | VERSION_M1 = -3 |
| 49 | 50 | |
| 50 | 51 | MICRO_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, | |
| 55 | 56 | } |
| 56 | 57 | |
| 57 | 58 | MICRO_VERSIONS = tuple(sorted(MICRO_VERSION_MAPPING.values())) |
| … | … | @@ -65,32 +66,28 @@ ERROR_LEVEL_H = 2 |
| 65 | 66 | |
| 66 | 67 | ERROR_LEVEL_TO_MICRO_MAPPING = { |
| 67 | 68 | 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}, | |
| 75 | 72 | } |
| 76 | 73 | |
| 77 | DEFAULT_BYTE_ENCODING = 'iso-8859-1' | |
| 78 | KANJI_ENCODING = 'shift_jis' | |
| 79 | HANZI_ENCODING = 'gb2312' | |
| 74 | DEFAULT_BYTE_ENCODING = "iso-8859-1" | |
| 75 | KANJI_ENCODING = "shift_jis" | |
| 76 | HANZI_ENCODING = "gb2312" | |
| 80 | 77 | |
| 81 | 78 | MODE_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, | |
| 87 | 84 | } |
| 88 | 85 | |
| 89 | 86 | ERROR_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, | |
| 94 | 91 | } |
| 95 | 92 | |
| 96 | 93 | # |
| … | … | @@ -138,33 +135,34 @@ ERROR_MAPPING = { |
| 138 | 135 | # 000030 Korean Character Set |
| 139 | 136 | ECI_ASSIGNMENT_NUM = { |
| 140 | 137 | # 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, | |
| 168 | 166 | } |
| 169 | 167 | |
| 170 | 168 | |
| … | … | @@ -184,7 +182,7 @@ TERMINATOR_LENGTH = { |
| 184 | 182 | VERSION_M1: 3, |
| 185 | 183 | VERSION_M2: 5, |
| 186 | 184 | VERSION_M3: 7, |
| 187 | VERSION_M4: 9 | |
| 185 | VERSION_M4: 9, | |
| 188 | 186 | } |
| 189 | 187 | |
| 190 | 188 | VERSION_RANGE_01_09 = 1 # Version 1 .. 9 |
| … | … | @@ -197,348 +195,590 @@ CHAR_COUNT_INDICATOR_LENGTH = { |
| 197 | 195 | MODE_NUMERIC: { |
| 198 | 196 | VERSION_RANGE_01_09: 10, |
| 199 | 197 | 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 | }, | |
| 201 | 204 | MODE_ALPHANUMERIC: { |
| 202 | 205 | VERSION_RANGE_01_09: 9, |
| 203 | 206 | 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 | }, | |
| 205 | 212 | MODE_BYTE: { |
| 206 | 213 | VERSION_RANGE_01_09: 8, |
| 207 | 214 | 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 | }, | |
| 209 | 219 | MODE_KANJI: { |
| 210 | 220 | VERSION_RANGE_01_09: 8, |
| 211 | 221 | 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 | }, | |
| 213 | 226 | MODE_HANZI: { |
| 214 | 227 | VERSION_RANGE_01_09: 8, |
| 215 | 228 | VERSION_RANGE_10_26: 10, |
| 216 | VERSION_RANGE_27_40: 12}, | |
| 229 | VERSION_RANGE_27_40: 12, | |
| 230 | }, | |
| 217 | 231 | } |
| 218 | 232 | |
| 219 | 233 | |
| 220 | 234 | # ISO/IEC 18004:2015(E) - 6.4.10 Bit stream to codeword conversion (page 33) |
| 221 | 235 | # Table 7 — Number of symbol characters and input data capacity for QR Code |
| 222 | 236 | SYMBOL_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 | }, | |
| 271 | 456 | } |
| 272 | 457 | |
| 273 | 458 | |
| 274 | 459 | # ISO/IEC 23941:2022(E) - 7.4.10 Bit stream to codeword conversion (page 25) |
| 275 | 460 | # Table 6 — Number of symbol characters and input data capacity for rMQR |
| 276 | 461 | RSYMBOL_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}, | |
| 309 | 494 | } |
| 310 | 495 | |
| 311 | 496 | |
| 312 | 497 | # ISO/IEC 18004:2015(E) -- Table 9 — Error correction characteristics for QR Code (page 38) |
| 313 | 498 | # ISO/IEC 23941:2022(E) -- Table 8 — Error correction characteristics for rMQR (page 29) |
| 314 | EC = namedtuple('EC', 'num_blocks num_total num_data') | |
| 499 | EC = namedtuple("EC", "num_blocks num_total num_data") | |
| 315 | 500 | |
| 316 | 501 | ECC = { |
| 317 | 502 | VERSION_M1: {None: (EC(1, 5, 3),)}, |
| 318 | 503 | VERSION_M2: {ERROR_LEVEL_L: (EC(1, 10, 5),), ERROR_LEVEL_M: (EC(1, 10, 4),)}, |
| 319 | 504 | 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 | }, | |
| 322 | 510 | 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 | }, | |
| 325 | 516 | 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 | }, | |
| 328 | 522 | 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 | }, | |
| 331 | 528 | 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 | }, | |
| 334 | 534 | 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),), | |
| 336 | 537 | 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 | }, | |
| 338 | 540 | 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 | }, | |
| 341 | 546 | 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),), | |
| 343 | 549 | 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 | }, | |
| 345 | 552 | 8: { |
| 346 | 553 | ERROR_LEVEL_L: (EC(2, 121, 97),), |
| 347 | 554 | ERROR_LEVEL_M: (EC(2, 60, 38), EC(2, 61, 39)), |
| 348 | 555 | 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 | }, | |
| 350 | 558 | 9: { |
| 351 | 559 | ERROR_LEVEL_L: (EC(2, 146, 116),), |
| 352 | 560 | ERROR_LEVEL_M: (EC(3, 58, 36), EC(2, 59, 37)), |
| 353 | 561 | 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 | }, | |
| 355 | 564 | 10: { |
| 356 | 565 | ERROR_LEVEL_L: (EC(2, 86, 68), EC(2, 87, 69)), |
| 357 | 566 | ERROR_LEVEL_M: (EC(4, 69, 43), EC(1, 70, 44)), |
| 358 | 567 | 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 | }, | |
| 360 | 570 | 11: { |
| 361 | 571 | ERROR_LEVEL_L: (EC(4, 101, 81),), |
| 362 | 572 | ERROR_LEVEL_M: (EC(1, 80, 50), EC(4, 81, 51)), |
| 363 | 573 | 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 | }, | |
| 365 | 576 | 12: { |
| 366 | 577 | ERROR_LEVEL_L: (EC(2, 116, 92), EC(2, 117, 93)), |
| 367 | 578 | ERROR_LEVEL_M: (EC(6, 58, 36), EC(2, 59, 37)), |
| 368 | 579 | 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 | }, | |
| 370 | 582 | 13: { |
| 371 | 583 | ERROR_LEVEL_L: (EC(4, 133, 107),), |
| 372 | 584 | ERROR_LEVEL_M: (EC(8, 59, 37), EC(1, 60, 38)), |
| 373 | 585 | 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 | }, | |
| 375 | 588 | 14: { |
| 376 | 589 | ERROR_LEVEL_L: (EC(3, 145, 115), EC(1, 146, 116)), |
| 377 | 590 | ERROR_LEVEL_M: (EC(4, 64, 40), EC(5, 65, 41)), |
| 378 | 591 | 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 | }, | |
| 380 | 594 | 15: { |
| 381 | 595 | ERROR_LEVEL_L: (EC(5, 109, 87), EC(1, 110, 88)), |
| 382 | 596 | ERROR_LEVEL_M: (EC(5, 65, 41), EC(5, 66, 42)), |
| 383 | 597 | 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 | }, | |
| 385 | 600 | 16: { |
| 386 | 601 | ERROR_LEVEL_L: (EC(5, 122, 98), EC(1, 123, 99)), |
| 387 | 602 | ERROR_LEVEL_M: (EC(7, 73, 45), EC(3, 74, 46)), |
| 388 | 603 | 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 | }, | |
| 390 | 606 | 17: { |
| 391 | 607 | ERROR_LEVEL_L: (EC(1, 135, 107), EC(5, 136, 108)), |
| 392 | 608 | ERROR_LEVEL_M: (EC(10, 74, 46), EC(1, 75, 47)), |
| 393 | 609 | 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 | }, | |
| 395 | 612 | 18: { |
| 396 | 613 | ERROR_LEVEL_L: (EC(5, 150, 120), EC(1, 151, 121)), |
| 397 | 614 | ERROR_LEVEL_M: (EC(9, 69, 43), EC(4, 70, 44)), |
| 398 | 615 | 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 | }, | |
| 400 | 618 | 19: { |
| 401 | 619 | ERROR_LEVEL_L: (EC(3, 141, 113), EC(4, 142, 114)), |
| 402 | 620 | ERROR_LEVEL_M: (EC(3, 70, 44), EC(11, 71, 45)), |
| 403 | 621 | 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 | }, | |
| 405 | 624 | 20: { |
| 406 | 625 | ERROR_LEVEL_L: (EC(3, 135, 107), EC(5, 136, 108)), |
| 407 | 626 | ERROR_LEVEL_M: (EC(3, 67, 41), EC(13, 68, 42)), |
| 408 | 627 | 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 | }, | |
| 410 | 630 | 21: { |
| 411 | 631 | ERROR_LEVEL_L: (EC(4, 144, 116), EC(4, 145, 117)), |
| 412 | 632 | ERROR_LEVEL_M: (EC(17, 68, 42),), |
| 413 | 633 | 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 | }, | |
| 415 | 636 | 22: { |
| 416 | 637 | ERROR_LEVEL_L: (EC(2, 139, 111), EC(7, 140, 112)), |
| 417 | 638 | ERROR_LEVEL_M: (EC(17, 74, 46),), |
| 418 | 639 | 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 | }, | |
| 420 | 642 | 23: { |
| 421 | 643 | ERROR_LEVEL_L: (EC(4, 151, 121), EC(5, 152, 122)), |
| 422 | 644 | ERROR_LEVEL_M: (EC(4, 75, 47), EC(14, 76, 48)), |
| 423 | 645 | 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 | }, | |
| 425 | 648 | 24: { |
| 426 | 649 | ERROR_LEVEL_L: (EC(6, 147, 117), EC(4, 148, 118)), |
| 427 | 650 | ERROR_LEVEL_M: (EC(6, 73, 45), EC(14, 74, 46)), |
| 428 | 651 | 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 | }, | |
| 430 | 654 | 25: { |
| 431 | 655 | ERROR_LEVEL_L: (EC(8, 132, 106), EC(4, 133, 107)), |
| 432 | 656 | ERROR_LEVEL_M: (EC(8, 75, 47), EC(13, 76, 48)), |
| 433 | 657 | 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 | }, | |
| 435 | 660 | 26: { |
| 436 | 661 | ERROR_LEVEL_L: (EC(10, 142, 114), EC(2, 143, 115)), |
| 437 | 662 | ERROR_LEVEL_M: (EC(19, 74, 46), EC(4, 75, 47)), |
| 438 | 663 | 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 | }, | |
| 440 | 666 | 27: { |
| 441 | 667 | ERROR_LEVEL_L: (EC(8, 152, 122), EC(4, 153, 123)), |
| 442 | 668 | ERROR_LEVEL_M: (EC(22, 73, 45), EC(3, 74, 46)), |
| 443 | 669 | 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 | }, | |
| 445 | 672 | 28: { |
| 446 | 673 | ERROR_LEVEL_L: (EC(3, 147, 117), EC(10, 148, 118)), |
| 447 | 674 | ERROR_LEVEL_M: (EC(3, 73, 45), EC(23, 74, 46)), |
| 448 | 675 | 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 | }, | |
| 450 | 678 | 29: { |
| 451 | 679 | ERROR_LEVEL_L: (EC(7, 146, 116), EC(7, 147, 117)), |
| 452 | 680 | ERROR_LEVEL_M: (EC(21, 73, 45), EC(7, 74, 46)), |
| 453 | 681 | 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 | }, | |
| 455 | 684 | 30: { |
| 456 | 685 | ERROR_LEVEL_L: (EC(5, 145, 115), EC(10, 146, 116)), |
| 457 | 686 | ERROR_LEVEL_M: (EC(19, 75, 47), EC(10, 76, 48)), |
| 458 | 687 | 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 | }, | |
| 460 | 690 | 31: { |
| 461 | 691 | ERROR_LEVEL_L: (EC(13, 145, 115), EC(3, 146, 116)), |
| 462 | 692 | ERROR_LEVEL_M: (EC(2, 74, 46), EC(29, 75, 47)), |
| 463 | 693 | 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 | }, | |
| 465 | 696 | 32: { |
| 466 | 697 | ERROR_LEVEL_L: (EC(17, 145, 115),), |
| 467 | 698 | ERROR_LEVEL_M: (EC(10, 74, 46), EC(23, 75, 47)), |
| 468 | 699 | 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 | }, | |
| 470 | 702 | 33: { |
| 471 | 703 | ERROR_LEVEL_L: (EC(17, 145, 115), EC(1, 146, 116)), |
| 472 | 704 | ERROR_LEVEL_M: (EC(14, 74, 46), EC(21, 75, 47)), |
| 473 | 705 | 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 | }, | |
| 475 | 708 | 34: { |
| 476 | 709 | ERROR_LEVEL_L: (EC(13, 145, 115), EC(6, 146, 116)), |
| 477 | 710 | ERROR_LEVEL_M: (EC(14, 74, 46), EC(23, 75, 47)), |
| 478 | 711 | 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 | }, | |
| 480 | 714 | 35: { |
| 481 | 715 | ERROR_LEVEL_L: (EC(12, 151, 121), EC(7, 152, 122)), |
| 482 | 716 | ERROR_LEVEL_M: (EC(12, 75, 47), EC(26, 76, 48)), |
| 483 | 717 | 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 | }, | |
| 485 | 720 | 36: { |
| 486 | 721 | ERROR_LEVEL_L: (EC(6, 151, 121), EC(14, 152, 122)), |
| 487 | 722 | ERROR_LEVEL_M: (EC(6, 75, 47), EC(34, 76, 48)), |
| 488 | 723 | 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 | }, | |
| 490 | 726 | 37: { |
| 491 | 727 | ERROR_LEVEL_L: (EC(17, 152, 122), EC(4, 153, 123)), |
| 492 | 728 | ERROR_LEVEL_M: (EC(29, 74, 46), EC(14, 75, 47)), |
| 493 | 729 | 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 | }, | |
| 495 | 732 | 38: { |
| 496 | 733 | ERROR_LEVEL_L: (EC(4, 152, 122), EC(18, 153, 123)), |
| 497 | 734 | ERROR_LEVEL_M: (EC(13, 74, 46), EC(32, 75, 47)), |
| 498 | 735 | 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 | }, | |
| 500 | 738 | 39: { |
| 501 | 739 | ERROR_LEVEL_L: (EC(20, 147, 117), EC(4, 148, 118)), |
| 502 | 740 | ERROR_LEVEL_M: (EC(40, 75, 47), EC(7, 76, 48)), |
| 503 | 741 | 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 | }, | |
| 505 | 744 | 40: { |
| 506 | 745 | ERROR_LEVEL_L: (EC(19, 148, 118), EC(6, 149, 119)), |
| 507 | 746 | ERROR_LEVEL_M: (EC(18, 75, 47), EC(31, 76, 48)), |
| 508 | 747 | 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}, | |
| 542 | 782 | } |
| 543 | 783 | |
| 544 | 784 | |
| … | … | @@ -546,40 +786,210 @@ ECC = { |
| 546 | 786 | # Table C.1 — Valid format information bit sequences (page 80) |
| 547 | 787 | FORMAT_INFO = ( |
| 548 | 788 | # 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, | |
| 550 | 797 | # 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, | |
| 552 | 806 | # 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, | |
| 554 | 815 | # 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, | |
| 556 | 824 | ) |
| 557 | 825 | |
| 558 | 826 | FORMAT_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, | |
| 563 | 859 | ) |
| 564 | 860 | |
| 565 | 861 | FORMAT_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, | |
| 573 | 926 | ) |
| 574 | 927 | |
| 575 | 928 | FORMAT_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, | |
| 583 | 993 | ) |
| 584 | 994 | |
| 585 | 995 | |
| … | … | @@ -587,11 +997,40 @@ FORMAT_INFO_RECT_MICRO_RIGHT = ( |
| 587 | 997 | # Table D.1 — Version information bit stream for each version (page 82) |
| 588 | 998 | VERSION_INFO = ( |
| 589 | 999 | # 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, | |
| 595 | 1034 | ) |
| 596 | 1035 | |
| 597 | 1036 | |
| … | … | @@ -664,13 +1103,188 @@ GEN_POLY = { |
| 664 | 1103 | 15: (8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105), |
| 665 | 1104 | 16: (120, 104, 107, 109, 102, 161, 76, 3, 91, 191, 147, 169, 182, 194, 225, 120), |
| 666 | 1105 | 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 | ), | |
| 674 | 1288 | } |
| 675 | 1289 | |
| 676 | 1290 | # Precomputed Galios Log tables |
| … | … | @@ -679,45 +1293,524 @@ GEN_POLY = { |
| 679 | 1293 | |
| 680 | 1294 | # GF(256) log |
| 681 | 1295 | GALIOS_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, | |
| 698 | 1552 | ) |
| 699 | 1553 | |
| 700 | 1554 | # GF(256) antilog |
| 701 | 1555 | # Inverse of the logarithm table. Maps integer logarithms to members |
| 702 | 1556 | # of the field. |
| 703 | GALIOS_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 | ) | |
| 1557 | GALIOS_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 | |
| 721 | 1814 | |
| 722 | 1815 | |
| 723 | 1816 | # Constants for module types |
| … | … | @@ -11,20 +11,22 @@ DOES NOT belong to the public API. |
| 11 | 11 | |
| 12 | 12 | "QR Code" and "Micro QR Code" are registered trademarks of DENSO WAVE INCORPORATED. |
| 13 | 13 | """ |
| 14 | from operator import itemgetter, gt, lt, xor | |
| 15 | from functools import partial, reduce | |
| 16 | from itertools import islice, chain, product | |
| 17 | import re | |
| 18 | import math | |
| 14 | ||
| 19 | 15 | import codecs |
| 16 | import math | |
| 17 | import re | |
| 18 | import sys | |
| 20 | 19 | from collections import namedtuple |
| 20 | from functools import partial, reduce | |
| 21 | from itertools import chain, islice, product, zip_longest | |
| 22 | from operator import gt, itemgetter, lt, xor | |
| 23 | ||
| 21 | 24 | from . import consts |
| 22 | from itertools import zip_longest | |
| 23 | import sys | |
| 25 | ||
| 24 | 26 | _MAX_PENALTY_SCORE = sys.maxsize |
| 25 | 27 | del sys |
| 26 | 28 | |
| 27 | __all__ = ('encode', 'encode_sequence', 'DataOverflowError') | |
| 29 | __all__ = ("DataOverflowError", "encode", "encode_sequence") | |
| 28 | 30 | |
| 29 | 31 | |
| 30 | 32 | class DataOverflowError(ValueError): |
| … | … | @@ -39,33 +41,47 @@ class DataOverflowError(ValueError): |
| 39 | 41 | """ |
| 40 | 42 | |
| 41 | 43 | |
| 42 | Code = namedtuple('Code', 'matrix version error mask segments') | |
| 44 | Code = namedtuple("Code", "matrix version error mask segments") | |
| 43 | 45 | |
| 44 | 46 | |
| 45 | def encode(content, error=None, version=None, mode=None, mask=None, | |
| 46 | encoding=None, eci=False, micro=None, boost_error=True): | |
| 47 | def 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 | ): | |
| 47 | 58 | version = normalize_version(version) |
| 48 | 59 | 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 | ) | |
| 51 | 64 | if micro and version is not None and version not in consts.MICRO_VERSIONS: |
| 52 | 65 | raise ValueError(f'Illegal Micro QR Code version "{get_version_name(version)}"') |
| 53 | 66 | error = normalize_errorlevel(error, accept_none=True) |
| 54 | 67 | 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 | ) | |
| 58 | 72 | if error == consts.ERROR_LEVEL_H and (micro or version in consts.MICRO_VERSIONS): |
| 59 | 73 | raise ValueError('Error correction level "H" is not available for Micro QR Codes') |
| 60 | 74 | 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") | |
| 62 | 76 | segments = prepare_data(content, mode, encoding) |
| 63 | 77 | guessed_version = find_version(segments, error, eci=eci, micro=micro) |
| 64 | 78 | if version is None: |
| 65 | 79 | version = guessed_version |
| 66 | 80 | 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 | ) | |
| 69 | 85 | if error is None and version != consts.VERSION_M1: |
| 70 | 86 | error = consts.ERROR_LEVEL_L |
| 71 | 87 | is_micro = version < 1 |
| … | … | @@ -73,8 +89,17 @@ def encode(content, error=None, version=None, mode=None, mask=None, |
| 73 | 89 | return _encode(segments, error, version, mask, eci, boost_error) |
| 74 | 90 | |
| 75 | 91 | |
| 76 | def encode_sequence(content, error=None, version=None, mode=None, mask=None, | |
| 77 | encoding=None, eci=False, boost_error=True, symbol_count=None): | |
| 92 | def 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 | ): | |
| 78 | 103 | def one_item_segments(chunk, mode): |
| 79 | 104 | segs = Segments() |
| 80 | 105 | 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, |
| 82 | 107 | |
| 83 | 108 | def divide_into_chunks(data, num): |
| 84 | 109 | 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)] | |
| 86 | 111 | |
| 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 | ): | |
| 89 | 115 | overhead = 4 # Mode indicator for QR Codes, only |
| 90 | 116 | # Number of bits in character count indicator |
| 91 | 117 | 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, |
| 112 | 138 | def number_of_symbols_by_version(content, version, error, mode): |
| 113 | 139 | length = len(content) |
| 114 | 140 | 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 | ) | |
| 117 | 144 | capacity = consts.SYMBOL_CAPACITY[version][error] |
| 118 | 145 | # Initial result does not contain the overhead of SA mode for all QR Codes |
| 119 | 146 | cnt = int(math.ceil(bit_length / capacity)) |
| … | … | @@ -124,12 +151,14 @@ def encode_sequence(content, error=None, version=None, mode=None, mask=None, |
| 124 | 151 | version = normalize_version(version) |
| 125 | 152 | if version is not None: |
| 126 | 153 | 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 | ) | |
| 129 | 158 | 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") | |
| 131 | 160 | 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") | |
| 133 | 162 | error = normalize_errorlevel(error, accept_none=True) |
| 134 | 163 | if error is None: |
| 135 | 164 | error = consts.ERROR_LEVEL_L |
| … | … | @@ -147,31 +176,49 @@ def encode_sequence(content, error=None, version=None, mode=None, mask=None, |
| 147 | 176 | pass |
| 148 | 177 | if guessed_version and guessed_version <= (version or guessed_version): |
| 149 | 178 | # 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 | ] | |
| 152 | 189 | 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.") | |
| 154 | 191 | mode = segments.modes[0] # CHANGE iff more than one mode is supported! |
| 155 | 192 | # Creating one QR code failed or max_no is not None |
| 156 | 193 | if mode == consts.MODE_NUMERIC: |
| 157 | 194 | content = str(content) |
| 158 | 195 | 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 | ) | |
| 160 | 199 | sa_parity_data = calc_structured_append_parity(content) |
| 161 | 200 | num_symbols = symbol_count or 16 |
| 162 | 201 | if version is not None: |
| 163 | 202 | num_symbols = number_of_symbols_by_version(content, version, error, mode) |
| 164 | 203 | 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}") | |
| 166 | 205 | chunks = divide_into_chunks(content, num_symbols) |
| 167 | 206 | if symbol_count is not None: |
| 168 | 207 | segments = one_item_segments(max(chunks, key=len), mode) |
| 169 | 208 | 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 | ] | |
| 175 | 222 | |
| 176 | 223 | |
| 177 | 224 | def _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): |
| 224 | 271 | |
| 225 | 272 | def boost_error_level(version, error, segments, eci, is_sa=False): |
| 226 | 273 | 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 | ] | |
| 229 | 280 | if version < 1: |
| 230 | 281 | levels.pop() # H isn't support by Micro QR Codes |
| 231 | 282 | if version < consts.VERSION_M4: |
| 232 | 283 | levels.pop() # Error level Q isn't supported by M2 and M3 |
| 233 | 284 | 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 :]: | |
| 235 | 286 | if consts.SYMBOL_CAPACITY[version][error_level] >= data_length: |
| 236 | 287 | error = error_level |
| 237 | 288 | else: |
| … | … | @@ -243,8 +294,7 @@ def write_segment(buff, segment, ver, ver_range, eci=False): |
| 243 | 294 | mode = segment.mode |
| 244 | 295 | append_bits = buff.append_bits |
| 245 | 296 | # 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: | |
| 248 | 298 | append_bits(consts.MODE_ECI, 4) |
| 249 | 299 | append_bits(get_eci_assignment_number(segment.encoding), 8) |
| 250 | 300 | if ver is None: # QR Code |
| … | … | @@ -255,8 +305,7 @@ def write_segment(buff, segment, ver, ver_range, eci=False): |
| 255 | 305 | elif ver > consts.VERSION_M1: # Micro QR Code (M1 has no mode indicator) |
| 256 | 306 | append_bits(consts.MODE_TO_MICRO_MODE_MAPPING[mode], ver + 3) |
| 257 | 307 | # 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]) | |
| 260 | 309 | buff.extend(segment.bits) |
| 261 | 310 | |
| 262 | 311 | |
| … | … | @@ -280,20 +329,26 @@ def write_pad_codewords(buff, version, capacity, length): |
| 280 | 329 | |
| 281 | 330 | |
| 282 | 331 | # 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 | ) | |
| 292 | 343 | |
| 293 | 344 | |
| 294 | 345 | def add_finder_patterns(matrix, width, height): |
| 295 | 346 | 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 | |
| 297 | 352 | if is_square and width < 21: |
| 298 | 353 | corners = ((0, 0),) |
| 299 | 354 | finder_range = range(8) |
| … | … | @@ -301,7 +356,7 @@ def add_finder_patterns(matrix, width, height): |
| 301 | 356 | offset = 1 if i == 0 else 0 |
| 302 | 357 | sepoffset = 0 if j != 0 else 1 |
| 303 | 358 | 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] | |
| 305 | 360 | |
| 306 | 361 | |
| 307 | 362 | def add_timing_pattern(matrix, is_micro): |
| … | … | @@ -316,14 +371,38 @@ def add_timing_pattern(matrix, is_micro): |
| 316 | 371 | |
| 317 | 372 | def add_alignment_patterns(matrix, width, height): |
| 318 | 373 | 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 | |
| 320 | 377 | if is_square and version < 2: # QR Codes version < 2 don't have alignment patterns |
| 321 | 378 | 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 | ) | |
| 327 | 406 | positions = consts.ALIGNMENT_POS[version - 2] |
| 328 | 407 | alignment_range = range(5) |
| 329 | 408 | min_pos = positions[0] |
| … | … | @@ -335,7 +414,7 @@ def add_alignment_patterns(matrix, width, height): |
| 335 | 414 | # The x and y values represent the center of the alignment pattern |
| 336 | 415 | i, j = x - 2, y - 2 |
| 337 | 416 | 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] | |
| 339 | 418 | |
| 340 | 419 | |
| 341 | 420 | def add_codewords(matrix, codewords, version): |
| … | … | @@ -362,8 +441,10 @@ def add_codewords(matrix, codewords, version): |
| 362 | 441 | row[j] = codewords[idx] |
| 363 | 442 | idx += 1 |
| 364 | 443 | 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 | ) | |
| 367 | 448 | |
| 368 | 449 | |
| 369 | 450 | def make_final_message(version, error, buff): |
| … | … | @@ -377,11 +458,25 @@ def make_final_message(version, error, buff): |
| 377 | 458 | cw_four = to_binary(data_blocks[0].pop(-1) >> 4, 4) |
| 378 | 459 | res = Buffer() |
| 379 | 460 | # 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 | ) | |
| 381 | 469 | if cw_four is not None: |
| 382 | 470 | res.extend(cw_four) |
| 383 | 471 | # 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 | ) | |
| 385 | 480 | remainder = 0 |
| 386 | 481 | if version in (2, 3, 4, 5, 6): |
| 387 | 482 | remainder = 7 |
| … | … | @@ -389,7 +484,7 @@ def make_final_message(version, error, buff): |
| 389 | 484 | remainder = 3 |
| 390 | 485 | elif version in (21, 22, 23, 24, 25, 26, 27): |
| 391 | 486 | remainder = 4 |
| 392 | res.extend(b'\0' * remainder) | |
| 487 | res.extend(b"\0" * remainder) | |
| 393 | 488 | return res |
| 394 | 489 | |
| 395 | 490 | |
| … | … | @@ -444,8 +539,7 @@ def find_and_apply_best_mask(matrix, width, height, proposed_mask=None): |
| 444 | 539 | mask_patterns = get_data_mask_functions(is_micro) |
| 445 | 540 | # If the user supplied a mask pattern, the evaluation step is skipped |
| 446 | 541 | 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) | |
| 449 | 543 | return proposed_mask, matrix |
| 450 | 544 | |
| 451 | 545 | best_matrix = None |
| … | … | @@ -483,9 +577,11 @@ def mask_scores(matrix, width, height): |
| 483 | 577 | idx = seq.find(n3_pattern) |
| 484 | 578 | while idx != -1: |
| 485 | 579 | 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 | ): | |
| 489 | 585 | count += 40 # N3 = 40 |
| 490 | 586 | else: |
| 491 | 587 | offset = idx + 4 |
| … | … | @@ -543,7 +639,7 @@ def mask_scores(matrix, width, height): |
| 543 | 639 | if n1_col_counter >= 5: |
| 544 | 640 | score_n1 += n1_col_counter - 2 |
| 545 | 641 | # N4 |
| 546 | percent = float(dark_module_counter) / (qr_size ** 2) | |
| 642 | percent = float(dark_module_counter) / (qr_size**2) | |
| 547 | 643 | score_n4 = 10 * int(abs(percent * 100 - 50) / 5) # N4 = 10 |
| 548 | 644 | return score_n1, score_n2, score_n3, score_n4 |
| 549 | 645 | |
| … | … | @@ -654,7 +750,7 @@ def data_to_bytes(data, encoding): |
| 654 | 750 | data = data.encode(encoding) |
| 655 | 751 | except UnicodeError: |
| 656 | 752 | # Use UTF-8 |
| 657 | encoding = 'utf-8' | |
| 753 | encoding = "utf-8" | |
| 658 | 754 | data = data.encode(encoding) |
| 659 | 755 | return data, len(data), encoding |
| 660 | 756 | |
| … | … | @@ -668,25 +764,31 @@ def make_segment(data, mode, encoding=None): |
| 668 | 764 | if segment_mode is not None: |
| 669 | 765 | # Check if user provided mode is applicable for the given segment_data |
| 670 | 766 | 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 | ) | |
| 674 | 772 | else: |
| 675 | 773 | segment_mode = guessed_mode |
| 676 | 774 | if segment_mode != consts.MODE_BYTE: |
| 677 | 775 | 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 | ) | |
| 679 | 781 | buff = Buffer() |
| 680 | 782 | append_bits = buff.append_bits |
| 681 | 783 | if segment_mode == consts.MODE_NUMERIC: |
| 682 | 784 | for i in range(0, segment_length, 3): |
| 683 | chunk = segment_data[i:i + 3] | |
| 785 | chunk = segment_data[i : i + 3] | |
| 684 | 786 | append_bits(int(chunk), len(chunk) * 3 + 1) |
| 685 | 787 | elif segment_mode == consts.MODE_ALPHANUMERIC: |
| 686 | 788 | # ISO/IEC 18004:2015(E) -- 7.4.4 Alphanumeric mode (page 26) |
| 687 | 789 | to_byte = consts.ALPHANUMERIC_CHARS.find |
| 688 | 790 | for i in range(0, segment_length, 2): |
| 689 | chunk = segment_data[i:i + 2] | |
| 791 | chunk = segment_data[i : i + 2] | |
| 690 | 792 | if len(chunk) > 1: |
| 691 | 793 | append_bits(to_byte(chunk[0]) * 45 + to_byte(chunk[1]), 11) |
| 692 | 794 | else: |
| … | … | @@ -700,32 +802,32 @@ def make_segment(data, mode, encoding=None): |
| 700 | 802 | # Note: len(segment.data)! segment.data_length = len(segment.data) / 2!! |
| 701 | 803 | for i in range(0, segment_length, 2): |
| 702 | 804 | code = (segment_data[i] << 8) | segment_data[i + 1] |
| 703 | if 0xa1a1 <= code <= 0xaafe: | |
| 805 | if 0xA1A1 <= code <= 0xAAFE: | |
| 704 | 806 | # For characters with GB2312 values from A1A1HEX to AAFEHEX: |
| 705 | 807 | # a) Subtract A1A1HEX from GB2312 value; |
| 706 | diff = code - 0xa1a1 | |
| 707 | elif 0xb0a1 <= code <= 0xfafe: | |
| 808 | diff = code - 0xA1A1 | |
| 809 | elif 0xB0A1 <= code <= 0xFAFE: | |
| 708 | 810 | # For characters with GB2312 values from B0A1HEX to FAFEHEX: |
| 709 | 811 | # a) Subtract A6A1HEX from GB2312 value; |
| 710 | diff = code - 0xa6a1 | |
| 812 | diff = code - 0xA6A1 | |
| 711 | 813 | 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) | |
| 714 | 816 | else: |
| 715 | 817 | # ISO/IEC 18004:2015(E) -- 7.4.6 Kanji mode (page 29) |
| 716 | 818 | for i in range(0, segment_length, 2): |
| 717 | 819 | code = (segment_data[i] << 8) | segment_data[i + 1] |
| 718 | if 0x8140 <= code <= 0x9ffc: | |
| 820 | if 0x8140 <= code <= 0x9FFC: | |
| 719 | 821 | # 1. a) For characters with Shift JIS values from 8140HEX to 9FFCHEX: |
| 720 | 822 | # Subtract 8140HEX from Shift JIS value; |
| 721 | 823 | diff = code - 0x8140 |
| 722 | elif 0xe040 <= code <= 0xebbf: | |
| 824 | elif 0xE040 <= code <= 0xEBBF: | |
| 723 | 825 | # 2. a) For characters with Shift JIS values from E040HEX to EBBFHEX: |
| 724 | 826 | # Subtract C140HEX from Shift JIS value; |
| 725 | diff = code - 0xc140 | |
| 827 | diff = code - 0xC140 | |
| 726 | 828 | 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) | |
| 729 | 831 | return _Segment(buff.getbits(), char_count, segment_mode, segment_encoding) |
| 730 | 832 | |
| 731 | 833 | |
| … | … | @@ -775,8 +877,10 @@ def normalize_version(version): |
| 775 | 877 | except (KeyError, AttributeError): |
| 776 | 878 | error = True |
| 777 | 879 | 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 | ) | |
| 780 | 884 | return version |
| 781 | 885 | |
| 782 | 886 | |
| … | … | @@ -786,8 +890,10 @@ def normalize_mode(mode): |
| 786 | 890 | try: |
| 787 | 891 | return consts.MODE_MAPPING[mode.lower()] |
| 788 | 892 | 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 | ) | |
| 791 | 897 | |
| 792 | 898 | |
| 793 | 899 | def normalize_mask(mask, is_micro): |
| … | … | @@ -796,11 +902,15 @@ def normalize_mask(mask, is_micro): |
| 796 | 902 | try: |
| 797 | 903 | mask = int(mask) |
| 798 | 904 | 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 | ) | |
| 801 | 909 | if is_micro: |
| 802 | 910 | 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 | ) | |
| 804 | 914 | else: |
| 805 | 915 | if not 0 <= mask < 8: |
| 806 | 916 | raise ValueError(f'Invalid data mask "{mask}". Must be in range 0 .. 7') |
| … | … | @@ -810,7 +920,7 @@ def normalize_mask(mask, is_micro): |
| 810 | 920 | def normalize_errorlevel(error, accept_none=False): |
| 811 | 921 | if error is None: |
| 812 | 922 | if not accept_none: |
| 813 | raise ValueError('The error level must be provided') | |
| 923 | raise ValueError("The error level must be provided") | |
| 814 | 924 | return error |
| 815 | 925 | try: |
| 816 | 926 | return consts.ERROR_MAPPING[error.upper()] |
| … | … | @@ -843,7 +953,7 @@ def get_version_name(version_const): |
| 843 | 953 | raise ValueError(f'Unknown version constant "{version_const}"') |
| 844 | 954 | |
| 845 | 955 | |
| 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") | |
| 847 | 957 | |
| 848 | 958 | |
| 849 | 959 | def is_alphanumeric(data): |
| … | … | @@ -857,7 +967,7 @@ def is_kanji(data): |
| 857 | 967 | data_iter = iter(data) |
| 858 | 968 | for i in range(0, data_len, 2): |
| 859 | 969 | 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): | |
| 861 | 971 | return False |
| 862 | 972 | return True |
| 863 | 973 | |
| … | … | @@ -885,16 +995,18 @@ def find_version(segments, error, eci, micro, is_sa=False): |
| 885 | 995 | if error is None and version != consts.VERSION_M1: |
| 886 | 996 | error = consts.ERROR_LEVEL_L |
| 887 | 997 | 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 | ): | |
| 889 | 1001 | return version |
| 890 | 1002 | except KeyError: |
| 891 | 1003 | pass |
| 892 | help_txt = '' | |
| 1004 | help_txt = "" | |
| 893 | 1005 | if micro is None: |
| 894 | help_txt = '(Micro) ' | |
| 1006 | help_txt = "(Micro) " | |
| 895 | 1007 | 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") | |
| 898 | 1010 | |
| 899 | 1011 | |
| 900 | 1012 | def calc_matrix_size(ver): |
| … | … | @@ -905,12 +1017,12 @@ def calc_structured_append_parity(content): |
| 905 | 1017 | if not isinstance(content, str): |
| 906 | 1018 | content = str(content) |
| 907 | 1019 | try: |
| 908 | data = content.encode('iso-8859-1') | |
| 1020 | data = content.encode("iso-8859-1") | |
| 909 | 1021 | except UnicodeError: |
| 910 | 1022 | try: |
| 911 | data = content.encode('shift-jis') | |
| 1023 | data = content.encode("shift-jis") | |
| 912 | 1024 | except (LookupError, UnicodeError): |
| 913 | data = content.encode('utf-8') | |
| 1025 | data = content.encode("utf-8") | |
| 914 | 1026 | return reduce(xor, data) |
| 915 | 1027 | |
| 916 | 1028 | |
| … | … | @@ -979,7 +1091,7 @@ def get_data_mask_functions(is_micro): |
| 979 | 1091 | |
| 980 | 1092 | |
| 981 | 1093 | class Segments: |
| 982 | __slots__ = ('bit_length', 'modes', 'segments') | |
| 1094 | __slots__ = ("bit_length", "modes", "segments") | |
| 983 | 1095 | |
| 984 | 1096 | def __init__(self): |
| 985 | 1097 | self.segments = [] |
| … | … | @@ -995,9 +1107,12 @@ class Segments: |
| 995 | 1107 | prev_seg = self.segments[-1] |
| 996 | 1108 | if prev_seg.mode == segment.mode and prev_seg.encoding == segment.encoding: |
| 997 | 1109 | # 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 | ) | |
| 1001 | 1116 | self.bit_length -= len(prev_seg.bits) |
| 1002 | 1117 | del self.segments[-1] |
| 1003 | 1118 | del self.modes[-1] |
| … | … | @@ -1018,9 +1133,12 @@ class Segments: |
| 1018 | 1133 | overhead = 0 |
| 1019 | 1134 | # ECI overhead |
| 1020 | 1135 | 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 | ) | |
| 1024 | 1142 | overhead += no_eci_indicators * 4 # ECI indicator |
| 1025 | 1143 | overhead += no_eci_indicators * 8 # ECI assignment no |
| 1026 | 1144 | if is_sa: |
| … | … | @@ -1051,7 +1169,7 @@ class _Segment(tuple): |
| 1051 | 1169 | |
| 1052 | 1170 | |
| 1053 | 1171 | class Buffer: |
| 1054 | __slots__ = ['_data'] | |
| 1172 | __slots__ = ["_data"] | |
| 1055 | 1173 | |
| 1056 | 1174 | def __init__(self, iterable=()): |
| 1057 | 1175 | self._data = bytearray(iterable) |
| … | … | @@ -1070,7 +1188,9 @@ class Buffer: |
| 1070 | 1188 | Returns an iterable of integers interpreting the content of `seq` |
| 1071 | 1189 | as sequence of binary numbers of length 8. |
| 1072 | 1190 | """ |
| 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 | ) | |
| 1074 | 1194 | |
| 1075 | 1195 | def __len__(self): |
| 1076 | 1196 | return len(self._data) |
| … | … | @@ -10,12 +10,21 @@ Utility functions useful for writers or QR Code objects. |
| 10 | 10 | |
| 11 | 11 | DOES NOT belong to the public API. |
| 12 | 12 | """ |
| 13 | ||
| 13 | 14 | from itertools import chain, repeat |
| 15 | ||
| 14 | 16 | from . import consts |
| 15 | 17 | |
| 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 | ) | |
| 19 | 28 | |
| 20 | 29 | |
| 21 | 30 | def get_default_border_size(matrix_size): |
| … | … | @@ -140,10 +149,15 @@ def matrix_iter(matrix, matrix_size, scale=1, border=None): |
| 140 | 149 | border = get_border(matrix_size, border) |
| 141 | 150 | width, height = matrix_size |
| 142 | 151 | 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 | ) | |
| 144 | 156 | for i in height_range: |
| 145 | 157 | 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 | ) | |
| 147 | 161 | for s in repeat(None, scale): |
| 148 | 162 | yield row |
| 149 | 163 | |
| … | … | @@ -169,6 +183,7 @@ def matrix_iter_verbose(matrix, matrix_size, scale=1, border=None): |
| 169 | 183 | :raises: :py:exc:`ValueError` if an illegal scale or border value is provided |
| 170 | 184 | """ |
| 171 | 185 | from segno import encoder |
| 186 | ||
| 172 | 187 | check_valid_border(border) |
| 173 | 188 | scale = int(scale) |
| 174 | 189 | check_valid_scale(scale) |
| … | … | @@ -188,37 +203,53 @@ def matrix_iter_verbose(matrix, matrix_size, scale=1, border=None): |
| 188 | 203 | # Alignment pattern |
| 189 | 204 | alignment_val = alignment_matrix[i][j] |
| 190 | 205 | 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 | ): | |
| 195 | 216 | return (consts.TYPE_VERSION_LIGHT, consts.TYPE_VERSION_DARK)[val] |
| 196 | 217 | # Dark module |
| 197 | 218 | if i == height - 8 and j == 8: |
| 198 | 219 | return consts.TYPE_DARKMODULE |
| 199 | 220 | # 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))): | |
| 202 | 224 | return (consts.TYPE_TIMING_LIGHT, consts.TYPE_TIMING_DARK)[val] |
| 203 | 225 | # 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 | ): | |
| 206 | 229 | return (consts.TYPE_FORMAT_LIGHT, consts.TYPE_FORMAT_DARK)[val] |
| 207 | 230 | # Finder pattern |
| 208 | 231 | # 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] | |
| 212 | 239 | # Separator |
| 213 | 240 | # 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 | |
| 216 | 244 | return consts.TYPE_SEPARATOR |
| 217 | 245 | return (consts.TYPE_DATA_LIGHT, consts.TYPE_DATA_DARK)[val] |
| 218 | 246 | else: |
| 219 | 247 | return consts.TYPE_QUIET_ZONE |
| 220 | 248 | |
| 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 | ) | |
| 222 | 253 | for i in height_range: |
| 223 | 254 | row = tuple(chain.from_iterable(repeat(get_bit(i, j), scale) for j in width_range)) |
| 224 | 255 | for s in repeat(None, scale): |
| … | … | @@ -5,33 +5,52 @@ |
| 5 | 5 | # License: BSD License |
| 6 | 6 | # |
| 7 | 7 | # type: ignore |
| 8 | import base64 | |
| 9 | import codecs | |
| 10 | import functools | |
| 11 | import gzip | |
| 8 | 12 | import io |
| 9 | 13 | import re |
| 14 | import time | |
| 10 | 15 | import zlib |
| 11 | import codecs | |
| 12 | import base64 | |
| 13 | import gzip | |
| 14 | from xml.sax.saxutils import quoteattr, escape | |
| 15 | from struct import pack | |
| 16 | from itertools import chain, repeat | |
| 17 | import functools | |
| 18 | from functools import partial | |
| 19 | from functools import reduce | |
| 20 | from operator import itemgetter | |
| 21 | from contextlib import contextmanager | |
| 22 | 16 | from collections import defaultdict |
| 23 | import time | |
| 24 | from . import consts | |
| 25 | from .utils import matrix_to_lines, get_symbol_size, get_border, check_valid_scale, check_valid_border, matrix_iter, matrix_iter_verbose | |
| 26 | from itertools import zip_longest | |
| 17 | from contextlib import contextmanager | |
| 18 | from functools import partial, reduce | |
| 19 | from itertools import chain, repeat, zip_longest | |
| 20 | from operator import itemgetter | |
| 21 | from struct import pack | |
| 27 | 22 | from urllib.parse import quote |
| 23 | from xml.sax.saxutils import escape, quoteattr | |
| 28 | 24 | |
| 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') | |
| 25 | from . import consts | |
| 26 | from .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 | ) | |
| 32 | 51 | |
| 33 | 52 | # Standard creator name |
| 34 | CREATOR = 'Segno <https://pypi.org/project/segno/>' | |
| 53 | CREATOR = "Segno <https://pypi.org/project/segno/>" | |
| 35 | 54 | |
| 36 | 55 | |
| 37 | 56 | @contextmanager |
| … | … | @@ -55,21 +74,53 @@ def writable(file_or_path, mode, encoding=None): |
| 55 | 74 | def colorful(dark, light): |
| 56 | 75 | def decorate(f): |
| 57 | 76 | @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 | ) | |
| 71 | 120 | return f(matrix, matrix_size, out, cm, **kw) |
| 121 | ||
| 72 | 122 | return wrapper |
| 123 | ||
| 73 | 124 | return decorate |
| 74 | 125 | |
| 75 | 126 | |
| … | … | @@ -81,16 +132,37 @@ def _valid_width_height_and_border(matrix_size, scale, border): |
| 81 | 132 | return width, height, border |
| 82 | 133 | |
| 83 | 134 | |
| 84 | @colorful(dark='#000', light=None) | |
| 85 | def 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) | |
| 136 | def 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 | ): | |
| 89 | 157 | 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 | ) | |
| 91 | 163 | |
| 92 | 164 | def matrix_to_lines_verbose(): |
| 93 | j = -.5 # stroke width / 2 | |
| 165 | j = -0.5 # stroke width / 2 | |
| 94 | 166 | invalid_color = -1 |
| 95 | 167 | for row in matrix_iter_verbose(matrix, matrix_size, scale=1, border=border): |
| 96 | 168 | last_color = invalid_color |
| … | … | @@ -105,21 +177,24 @@ def write_svg(matrix, matrix_size, out, colormap, scale=1, border=None, xmldecl= |
| 105 | 177 | yield last_color, (x1, x2, j) |
| 106 | 178 | |
| 107 | 179 | width, height, border = _valid_width_height_and_border(matrix_size, scale, border) |
| 108 | unit = unit or '' | |
| 180 | unit = unit or "" | |
| 109 | 181 | 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 | ) | |
| 112 | 185 | omit_encoding = encoding is None |
| 113 | 186 | if omit_encoding: |
| 114 | encoding = 'utf-8' | |
| 187 | encoding = "utf-8" | |
| 115 | 188 | allow_css3_colors = svgversion is not None and svgversion >= 2.0 |
| 116 | 189 | 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 | ) | |
| 118 | 193 | need_svg_group = scale != 1 and (need_background or is_multicolor) |
| 119 | 194 | if is_multicolor: |
| 120 | 195 | miter = matrix_to_lines_verbose() |
| 121 | 196 | else: |
| 122 | x, y = border, border + .5 | |
| 197 | x, y = border, border + 0.5 | |
| 123 | 198 | dark = colormap[consts.TYPE_DATA_DARK] |
| 124 | 199 | miter = ((dark, (x1, x2, y1)) for (x1, y1), (x2, y2) in matrix_to_lines(matrix, x, y)) |
| 125 | 200 | xy = defaultdict(lambda: (0, 0)) |
| … | … | @@ -136,9 +211,11 @@ def write_svg(matrix, matrix_size, out, colormap, scale=1, border=None, xmldecl= |
| 136 | 211 | except KeyError: |
| 137 | 212 | pass |
| 138 | 213 | 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 | ) | |
| 142 | 219 | for color, coord in coordinates.items(): |
| 143 | 220 | path = p |
| 144 | 221 | clr = svg_color(color) |
| … | … | @@ -146,98 +223,148 @@ def write_svg(matrix, matrix_size, out, colormap, scale=1, border=None, xmldecl= |
| 146 | 223 | opacity = None |
| 147 | 224 | if isinstance(clr, tuple): |
| 148 | 225 | clr, opacity = clr |
| 149 | path += f' stroke={quoteattr(clr)}' | |
| 226 | path += f" stroke={quoteattr(clr)}" | |
| 150 | 227 | if opacity is not None: |
| 151 | path += f' stroke-opacity={quoteattr(str(opacity))}' | |
| 228 | path += f" stroke-opacity={quoteattr(str(opacity))}" | |
| 152 | 229 | 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 | ) | |
| 157 | 239 | path += '"/>' |
| 158 | 240 | paths[color] = path |
| 159 | 241 | if need_background: |
| 160 | 242 | 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 = "" | |
| 165 | 251 | if xmldecl: |
| 166 | 252 | svg += '<?xml version="1.0"' |
| 167 | 253 | 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" | |
| 171 | 257 | if svgns: |
| 172 | 258 | svg += ' xmlns="http://www.w3.org/2000/svg"' |
| 173 | 259 | if svgversion is not None and svgversion < 2.0: |
| 174 | svg += f' version={quoteattr(str(svgversion))}' | |
| 260 | svg += f" version={quoteattr(str(svgversion))}" | |
| 175 | 261 | if not omitsize: |
| 176 | 262 | svg += f' width="{width}{unit}" height="{height}{unit}"' |
| 177 | 263 | if omitsize or unit: |
| 178 | 264 | svg += f' viewBox="0 0 {width} {height}"' |
| 179 | 265 | if svgid: |
| 180 | svg += f' id={quoteattr(svgid)}' | |
| 266 | svg += f" id={quoteattr(svgid)}" | |
| 181 | 267 | if svgclass: |
| 182 | svg += f' class={quoteattr(svgclass)}' | |
| 183 | svg += '>' | |
| 268 | svg += f" class={quoteattr(svgclass)}" | |
| 269 | svg += ">" | |
| 184 | 270 | if title is not None: |
| 185 | svg += f'<title>{escape(title)}</title>' | |
| 271 | svg += f"<title>{escape(title)}</title>" | |
| 186 | 272 | if desc is not None: |
| 187 | svg += f'<desc>{escape(desc)}</desc>' | |
| 273 | svg += f"<desc>{escape(desc)}</desc>" | |
| 188 | 274 | 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)) | |
| 191 | 277 | if need_svg_group: |
| 192 | svg += '</g>' | |
| 193 | svg += '</svg>' | |
| 278 | svg += "</g>" | |
| 279 | svg += "</svg>" | |
| 194 | 280 | 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: | |
| 197 | 283 | f.write(svg) |
| 198 | 284 | |
| 199 | 285 | |
| 200 | _replace_quotes = partial(re.compile(br'(=)"([^"]+)"').sub, br"\1'\2'") | |
| 201 | ||
| 202 | ||
| 203 | def 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 | ||
| 289 | def 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 | ): | |
| 209 | 310 | encode = partial(quote, safe=b"") if not encode_minimal else partial(quote, safe=b" :/='") |
| 210 | 311 | 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 | ||
| 219 | def 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 | ||
| 337 | def 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 | |
| 222 | 347 | 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", | |
| 229 | 354 | } |
| 230 | 355 | if colormap is not None: |
| 231 | 356 | clr_mapping.update(colormap) |
| 232 | 357 | width, height, border = _valid_width_height_and_border(matrix_size, scale, border) |
| 233 | 358 | 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: | |
| 235 | 360 | legend = [] |
| 236 | 361 | write = f.write |
| 237 | 362 | write('<?xml version="1.0" encoding="utf-8"?>\n') |
| 238 | 363 | 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 | ) | |
| 241 | 368 | write(f'<g transform="scale({scale})">') |
| 242 | 369 | for i in range(matrix_height): |
| 243 | 370 | y = i + border |
| … | … | @@ -249,17 +376,17 @@ def write_svg_debug(matrix, matrix_size, out, scale=15, border=None, |
| 249 | 376 | fill = clr_mapping.get(bit, fallback_color) |
| 250 | 377 | write(f'<rect x="{x}" y="{y}" width="1" height="1" fill="{fill}"/>') |
| 251 | 378 | 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") | |
| 254 | 381 | |
| 255 | 382 | |
| 256 | def write_eps(matrix, matrix_size, out, scale=1, border=None, dark='#000', light=None): | |
| 383 | def write_eps(matrix, matrix_size, out, scale=1, border=None, dark="#000", light=None): | |
| 257 | 384 | import textwrap |
| 258 | 385 | |
| 259 | 386 | def write_line(writemeth, content): |
| 260 | 387 | for line in textwrap.wrap(content, 254): |
| 261 | 388 | writemeth(line) |
| 262 | writemeth('\n') | |
| 389 | writemeth("\n") | |
| 263 | 390 | |
| 264 | 391 | def rgb_to_floats(clr): |
| 265 | 392 | def to_float(c): |
| … | … | @@ -274,45 +401,53 @@ def write_eps(matrix, matrix_size, out, scale=1, border=None, dark='#000', light |
| 274 | 401 | width, height, border = _valid_width_height_and_border(matrix_size, scale, border) |
| 275 | 402 | stroke_color_is_black = _color_is_black(dark) |
| 276 | 403 | 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: | |
| 278 | 405 | 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") | |
| 286 | 413 | 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))) | |
| 288 | 415 | if stroke_color_is_black: |
| 289 | writeline('0 0 0 setrgbcolor') | |
| 416 | writeline("0 0 0 setrgbcolor") | |
| 290 | 417 | 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 | |
| 292 | 419 | 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 | |
| 296 | 423 | line_iter = matrix_to_lines(matrix, border, y, incby=-1) |
| 297 | 424 | (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"] | |
| 299 | 426 | append_coord = coord.append |
| 300 | 427 | x = x2 |
| 301 | 428 | 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") | |
| 303 | 430 | x, y = x2, y2 |
| 304 | writeline(''.join(coord)) | |
| 305 | writeline('stroke') | |
| 306 | writeline('%%EOF') | |
| 431 | writeline("".join(coord)) | |
| 432 | writeline("stroke") | |
| 433 | writeline("%%EOF") | |
| 307 | 434 | |
| 308 | 435 | |
| 309 | 436 | def as_png_data_uri(matrix, matrix_size, scale=1, border=None, compresslevel=9, **kw): |
| 310 | 437 | 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") | |
| 316 | 451 | def write_png(matrix, matrix_size, out, colormap, scale=1, border=None, compresslevel=9, dpi=None): |
| 317 | 452 | |
| 318 | 453 | def png_color(clr): |
| … | … | @@ -320,19 +455,25 @@ def write_png(matrix, matrix_size, out, colormap, scale=1, border=None, compress |
| 320 | 455 | |
| 321 | 456 | def chunk(name, data): |
| 322 | 457 | 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 | ) | |
| 329 | 470 | |
| 330 | 471 | scale = int(scale) |
| 331 | 472 | width, height, border = _valid_width_height_and_border(matrix_size, scale, border) |
| 332 | 473 | if dpi: |
| 333 | 474 | dpi = int(dpi) |
| 334 | 475 | if dpi < 0: |
| 335 | raise ValueError('DPI value must not be negative') | |
| 476 | raise ValueError("DPI value must not be negative") | |
| 336 | 477 | dpi = int(dpi // 0.0254) |
| 337 | 478 | |
| 338 | 479 | black = (0, 0, 0) |
| … | … | @@ -346,7 +487,9 @@ def write_png(matrix, matrix_size, out, colormap, scale=1, border=None, compress |
| 346 | 487 | is_transparent = transparent in palette |
| 347 | 488 | number_of_colors = len(palette) |
| 348 | 489 | # 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 | ) | |
| 350 | 493 | png_color_type = 0 if is_greyscale else 3 |
| 351 | 494 | png_bit_depth = 1 # Assume a bit depth of 1 (may change if PLTE is used) |
| 352 | 495 | png_trans_idx = None |
| … | … | @@ -356,13 +499,22 @@ def write_png(matrix, matrix_size, out, colormap, scale=1, border=None, compress |
| 356 | 499 | palette.sort(key=len, reverse=True) # RGBA colors first |
| 357 | 500 | if is_transparent: |
| 358 | 501 | 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 | ) | |
| 360 | 507 | # Choose a random color which becomes transparent. |
| 361 | 508 | transparent_color = next(clr for clr in rgb_values if clr not in palette) |
| 362 | 509 | palette[0] = transparent_color |
| 363 | 510 | # 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 | ) | |
| 366 | 518 | elif is_transparent: # Greyscale and transparent |
| 367 | 519 | if black in palette: |
| 368 | 520 | # 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 |
| 377 | 529 | miter = iter(matrix) |
| 378 | 530 | # The code to create the image requires that TYPE_QUIET_ZONE is available |
| 379 | 531 | 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])}) | |
| 382 | 533 | 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"" | |
| 385 | 536 | if border > 0: |
| 386 | 537 | # Calculate horizontal and vertical border |
| 387 | 538 | qz_value = color_index[qz_idx] |
| 388 | 539 | horizontal_border = scanline(repeat(qz_value, width)) * border * scale |
| 389 | 540 | vertical_border = [qz_value] * border * scale |
| 390 | same_as_above = b'' | |
| 541 | same_as_above = b"" | |
| 391 | 542 | if scale > 1: |
| 392 | 543 | # 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) | |
| 394 | 545 | miter = (chain(*(repeat(b, scale) for b in row)) for row in miter) |
| 395 | 546 | idat = bytearray(horizontal_border) |
| 396 | 547 | for row in miter: |
| … | … | @@ -398,32 +549,50 @@ def write_png(matrix, matrix_size, out, colormap, scale=1, border=None, compress |
| 398 | 549 | idat += scanline(chain(vertical_border, row, vertical_border)) |
| 399 | 550 | idat += same_as_above # This is b'' if no scaling factor was provided |
| 400 | 551 | idat += horizontal_border |
| 401 | with writable(out, 'wb') as f: | |
| 552 | with writable(out, "wb") as f: | |
| 402 | 553 | write = f.write |
| 403 | write(b'\211PNG\r\n\032\n') # Magic number | |
| 554 | write(b"\211PNG\r\n\032\n") # Magic number | |
| 404 | 555 | # Header: |
| 405 | 556 | # 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 | ) | |
| 407 | 563 | if dpi: |
| 408 | write(chunk(b'pHYs', pack(b'>LLB', dpi, dpi, 1))) | |
| 564 | write(chunk(b"pHYs", pack(b">LLB", dpi, dpi, 1))) | |
| 409 | 565 | 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))) | |
| 411 | 567 | # <https://www.w3.org/TR/PNG/#11tRNS> |
| 412 | 568 | 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 | ) | |
| 414 | 575 | 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))) | |
| 416 | 577 | 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 | ||
| 422 | def 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 | ||
| 583 | def 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 | ): | |
| 424 | 593 | |
| 425 | 594 | def write_string(writemeth, s): |
| 426 | writemeth(s.encode('ascii')) | |
| 595 | writemeth(s.encode("ascii")) | |
| 427 | 596 | |
| 428 | 597 | def to_pdf_color(clr): |
| 429 | 598 | def to_float(c): |
| … | … | @@ -432,6 +601,7 @@ def write_pdf(matrix, matrix_size, out, scale=1, border=None, dark='#000', |
| 432 | 601 | raise ValueError(f'Invalid color "{c}". Not in range 0 .. 1') |
| 433 | 602 | return c |
| 434 | 603 | return 1 / 255.0 * c if c != 1 else c |
| 604 | ||
| 435 | 605 | return tuple([to_float(i) for i in _color_to_rgb(clr)]) |
| 436 | 606 | |
| 437 | 607 | 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', |
| 440 | 610 | append_cmd = cmds.append |
| 441 | 611 | if light is not None: |
| 442 | 612 | # 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") | |
| 446 | 616 | # Set the stroke color only iff it is not black (default) |
| 447 | 617 | if not _color_is_black(dark): |
| 448 | append_cmd('{} {} {} RG'.format(*to_pdf_color(dark))) | |
| 618 | append_cmd("{} {} {} RG".format(*to_pdf_color(dark))) | |
| 449 | 619 | 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 | |
| 452 | 622 | # 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") | |
| 454 | 624 | miter = matrix_to_lines(matrix, 0, 0, incby=-1) |
| 455 | 625 | # 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: | |
| 460 | 630 | write = f.write |
| 461 | 631 | writestr = partial(write_string, write) |
| 462 | 632 | 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 | ): | |
| 468 | 640 | object_pos.append(f.tell()) |
| 469 | writestr(f'{len(object_pos)} 0 {obj}') | |
| 641 | writestr(f"{len(object_pos)} 0 {obj}") | |
| 470 | 642 | write(graphic) |
| 471 | write(b'\r\nendstream\r\nendobj\r\n') | |
| 643 | write(b"\r\nendstream\r\nendobj\r\n") | |
| 472 | 644 | 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 | ) | |
| 475 | 649 | 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") | |
| 477 | 651 | 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") | |
| 480 | 654 | 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") | |
| 482 | 656 | |
| 483 | 657 | |
| 484 | def write_txt(matrix, matrix_size, out, border=None, dark='1', light='0'): | |
| 658 | def write_txt(matrix, matrix_size, out, border=None, dark="1", light="0"): | |
| 485 | 659 | row_iter = matrix_iter(matrix, matrix_size, scale=1, border=border) |
| 486 | 660 | colours = (str(light), str(dark)) |
| 487 | with writable(out, 'wt') as f: | |
| 661 | with writable(out, "wt") as f: | |
| 488 | 662 | write = f.write |
| 489 | 663 | 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") | |
| 492 | 666 | |
| 493 | 667 | |
| 494 | 668 | def write_pbm(matrix, matrix_size, out, scale=1, border=None, plain=False): |
| 495 | 669 | 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 | ) | |
| 498 | 674 | |
| 499 | 675 | width, height, border = _valid_width_height_and_border(matrix_size, scale, border) |
| 500 | 676 | row_iter = matrix_iter(matrix, matrix_size, scale, border) |
| 501 | with writable(out, 'wb') as f: | |
| 677 | with writable(out, "wb") as f: | |
| 502 | 678 | 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 | ) | |
| 506 | 684 | if not plain: |
| 507 | 685 | for row in row_iter: |
| 508 | 686 | write(bytearray(pack_row(row))) |
| 509 | 687 | else: |
| 510 | 688 | 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") | |
| 513 | 691 | |
| 514 | 692 | |
| 515 | def write_pam(matrix, matrix_size, out, scale=1, border=None, dark='#000', light='#fff'): | |
| 693 | def write_pam(matrix, matrix_size, out, scale=1, border=None, dark="#000", light="#fff"): | |
| 516 | 694 | def invert_row_bits(row): |
| 517 | 695 | return bytearray([b ^ 0x1 for b in row]) |
| 518 | 696 | |
| 519 | 697 | 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) | |
| 521 | 699 | |
| 522 | 700 | if not dark: |
| 523 | 701 | raise ValueError(f'Invalid stroke color "{dark}"') |
| 524 | 702 | width, height, border = _valid_width_height_and_border(matrix_size, scale, border) |
| 525 | 703 | 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" | |
| 527 | 705 | transparency = False |
| 528 | 706 | stroke_color = _color_to_rgb_or_rgba(dark, alpha_float=False) |
| 529 | 707 | bg_color = _color_to_rgb_or_rgba(light, alpha_float=False) if light is not None else None |
| 530 | 708 | colored_stroke = not (_color_is_black(stroke_color) or _color_is_white(stroke_color)) |
| 531 | 709 | 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" | |
| 533 | 711 | transparency = True |
| 534 | 712 | bg_color = _invert_color(stroke_color[:3]) |
| 535 | 713 | bg_color += (0,) |
| 536 | 714 | if len(stroke_color) != 4: |
| 537 | 715 | stroke_color += (255,) |
| 538 | 716 | 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") | |
| 541 | 719 | colours = None |
| 542 | 720 | if not is_rgb and transparency: |
| 543 | 721 | depth = 2 |
| 544 | colours = (b'\x01\x00', b'\x00\x01') | |
| 722 | colours = (b"\x01\x00", b"\x00\x01") | |
| 545 | 723 | elif is_rgb: |
| 546 | 724 | maxval = max(chain(stroke_color, bg_color)) |
| 547 | 725 | depth = 3 if not transparency else 4 |
| 548 | fmt = f'>{depth}B'.encode('ascii') | |
| 726 | fmt = f">{depth}B".encode("ascii") | |
| 549 | 727 | 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: | |
| 552 | 732 | 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 | ) | |
| 561 | 743 | for row in row_iter: |
| 562 | 744 | write(row_filter(row)) |
| 563 | 745 | |
| 564 | 746 | |
| 565 | @colorful(dark='#000', light='#fff') | |
| 747 | @colorful(dark="#000", light="#fff") | |
| 566 | 748 | def write_ppm(matrix, matrix_size, out, colormap, scale=1, border=None): |
| 567 | 749 | scale = int(scale) |
| 568 | 750 | width, height, border = _valid_width_height_and_border(matrix_size, scale, border) |
| 569 | 751 | if None in colormap.values(): |
| 570 | raise ValueError('Transparency is not supported') | |
| 752 | raise ValueError("Transparency is not supported") | |
| 571 | 753 | for mt, clr in colormap.items(): |
| 572 | 754 | colormap[mt] = _color_to_rgb(clr) |
| 573 | 755 | row_iter = matrix_iter_verbose(matrix, matrix_size, scale, border) |
| 574 | with writable(out, 'wb') as f: | |
| 756 | with writable(out, "wb") as f: | |
| 575 | 757 | 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")) | |
| 577 | 759 | for row in row_iter: |
| 578 | write(b''.join(pack(b'>3B', *colormap[mt]) for mt in row)) | |
| 579 | ||
| 580 | ||
| 581 | def 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 | ||
| 763 | def 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 | ): | |
| 583 | 773 | width, height, border = _valid_width_height_and_border(matrix_size, scale, border) |
| 584 | 774 | 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: | |
| 588 | 778 | 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 | ) | |
| 594 | 786 | 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 | ||
| 600 | def 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 | ||
| 799 | def write_xbm(matrix, matrix_size, out, scale=1, border=None, name="img"): | |
| 601 | 800 | width, height, border = _valid_width_height_and_border(matrix_size, scale, border) |
| 602 | 801 | row_iter = matrix_iter(matrix, matrix_size, scale, border) |
| 603 | with writable(out, 'wt') as f: | |
| 802 | with writable(out, "wt") as f: | |
| 604 | 803 | 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 | ) | |
| 608 | 809 | for i, row in enumerate(row_iter, start=1): |
| 609 | 810 | iter_ = zip_longest(*[iter(row)] * 8, fillvalue=0x0) |
| 610 | 811 | # 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") | |
| 616 | 817 | |
| 617 | 818 | |
| 618 | def write_tex(matrix, matrix_size, out, scale=1, border=None, dark='black', unit='pt', url=None): | |
| 819 | def write_tex(matrix, matrix_size, out, scale=1, border=None, dark="black", unit="pt", url=None): | |
| 619 | 820 | def point(x, y): |
| 620 | return f'\\pgfqpoint{{{x}{unit}}}{{{y}{unit}}}' | |
| 821 | return f"\\pgfqpoint{{{x}{unit}}}{{{y}{unit}}}" | |
| 621 | 822 | |
| 622 | 823 | check_valid_scale(scale) |
| 623 | 824 | check_valid_border(border) |
| 624 | 825 | 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: | |
| 627 | 828 | 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") | |
| 630 | 831 | 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") | |
| 637 | 838 | x, y = border, -border |
| 638 | 839 | 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") | |
| 643 | 844 | |
| 644 | 845 | |
| 645 | 846 | def write_terminal(matrix, matrix_size, out, border=None): |
| 646 | with writable(out, 'wt') as f: | |
| 847 | with writable(out, "wt") as f: | |
| 647 | 848 | 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)] | |
| 649 | 850 | for row in matrix_iter(matrix, matrix_size, scale=1, border=border): |
| 650 | 851 | prev_bit = -1 |
| 651 | 852 | cnt = 0 |
| … | … | @@ -655,29 +856,29 @@ def write_terminal(matrix, matrix_size, out, border=None): |
| 655 | 856 | else: |
| 656 | 857 | if cnt: |
| 657 | 858 | write(colours[prev_bit]) |
| 658 | write(' ' * cnt) | |
| 659 | write('\033[0m') # reset color | |
| 859 | write(" " * cnt) | |
| 860 | write("\033[0m") # reset color | |
| 660 | 861 | prev_bit = bit |
| 661 | 862 | cnt = 1 |
| 662 | 863 | if cnt: |
| 663 | 864 | 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") | |
| 667 | 868 | |
| 668 | 869 | |
| 669 | 870 | def write_terminal_win(matrix, matrix_size, border=None): # pragma: no cover |
| 670 | import sys | |
| 671 | import struct | |
| 672 | 871 | import ctypes |
| 872 | import struct | |
| 873 | import sys | |
| 874 | ||
| 673 | 875 | write = sys.stdout.write |
| 674 | 876 | std_out = ctypes.windll.kernel32.GetStdHandle(-11) |
| 675 | 877 | csbi = ctypes.create_string_buffer(22) |
| 676 | 878 | res = ctypes.windll.kernel32.GetConsoleScreenBufferInfo(std_out, csbi) |
| 677 | 879 | 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] | |
| 681 | 882 | set_color = partial(ctypes.windll.kernel32.SetConsoleTextAttribute, std_out) |
| 682 | 883 | colours = (240, default_color) |
| 683 | 884 | 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 |
| 689 | 890 | else: |
| 690 | 891 | if cnt: |
| 691 | 892 | set_color(colours[prev_bit]) |
| 692 | write(' ' * cnt) | |
| 893 | write(" " * cnt) | |
| 693 | 894 | prev_bit = bit |
| 694 | 895 | cnt = 1 |
| 695 | 896 | if cnt: |
| 696 | 897 | set_color(colours[prev_bit]) |
| 697 | write(' ' * cnt) | |
| 898 | write(" " * cnt) | |
| 698 | 899 | set_color(default_color) # reset color |
| 699 | write('\n') | |
| 900 | write("\n") | |
| 700 | 901 | |
| 701 | 902 | |
| 702 | 903 | def 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 | } | |
| 708 | 910 | 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: | |
| 710 | 912 | write = f.write |
| 711 | 913 | 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") | |
| 714 | 916 | |
| 715 | 917 | |
| 716 | 918 | def _color_to_rgb_or_rgba(color, alpha_float=True): |
| … | … | @@ -722,29 +924,29 @@ def _color_to_rgb_or_rgba(color, alpha_float=True): |
| 722 | 924 | |
| 723 | 925 | def _color_to_webcolor(color, allow_css3_colors=True, optimize=True): |
| 724 | 926 | if _color_is_black(color): |
| 725 | return '#000' | |
| 927 | return "#000" | |
| 726 | 928 | elif _color_is_white(color): |
| 727 | return '#fff' | |
| 929 | return "#fff" | |
| 728 | 930 | clr = _color_to_rgb_or_rgba(color) |
| 729 | 931 | alpha_channel = None |
| 730 | 932 | if len(clr) == 4: |
| 731 | 933 | 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 | |
| 733 | 935 | alpha_channel = clr[3] |
| 734 | 936 | 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 | |
| 736 | 938 | 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 | |
| 741 | 943 | 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]}" | |
| 743 | 945 | return hx if alpha_channel is None else (hx, alpha_channel) |
| 744 | 946 | |
| 745 | 947 | |
| 746 | 948 | def 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 | |
| 748 | 950 | |
| 749 | 951 | |
| 750 | 952 | def _color_is_black(color): |
| … | … | @@ -752,8 +954,14 @@ def _color_is_black(color): |
| 752 | 954 | color = color.lower() |
| 753 | 955 | except AttributeError: |
| 754 | 956 | 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 | ) | |
| 757 | 965 | |
| 758 | 966 | |
| 759 | 967 | def _color_is_white(color): |
| … | … | @@ -761,15 +969,22 @@ def _color_is_white(color): |
| 761 | 969 | color = color.lower() |
| 762 | 970 | except AttributeError: |
| 763 | 971 | 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 | ) | |
| 766 | 980 | |
| 767 | 981 | |
| 768 | 982 | def _color_to_rgb(color): |
| 769 | 983 | rgb = _color_to_rgb_or_rgba(color) |
| 770 | 984 | 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 | ) | |
| 773 | 988 | return rgb |
| 774 | 989 | |
| 775 | 990 | |
| … | … | @@ -803,33 +1018,35 @@ def _color_to_rgba(color, alpha_float=True): |
| 803 | 1018 | else: |
| 804 | 1019 | return clr + alpha_channel |
| 805 | 1020 | 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 | ) | |
| 808 | 1025 | |
| 809 | 1026 | |
| 810 | 1027 | def _hex_to_rgb_or_rgba(color, alpha_float=True): |
| 811 | if color[0] == '#': | |
| 1028 | if color[0] == "#": | |
| 812 | 1029 | color = color[1:] |
| 813 | 1030 | if 2 < len(color) < 5: |
| 814 | 1031 | # 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))]) | |
| 816 | 1033 | color_len = len(color) |
| 817 | 1034 | 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)]) | |
| 820 | 1037 | if alpha_float and color_len == 8: |
| 821 | 1038 | res = res[:3] + (_alpha_value(res[3], alpha_float),) |
| 822 | 1039 | return res |
| 823 | 1040 | |
| 824 | 1041 | |
| 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} | |
| 826 | 1043 | |
| 827 | 1044 | |
| 828 | 1045 | def _alpha_value(color, alpha_float): |
| 829 | 1046 | if alpha_float: |
| 830 | 1047 | if not isinstance(color, float): |
| 831 | 1048 | 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))) | |
| 833 | 1050 | else: |
| 834 | 1051 | if 0 <= color <= 1.0: |
| 835 | 1052 | return color |
| … | … | @@ -840,7 +1057,7 @@ def _alpha_value(color, alpha_float): |
| 840 | 1057 | else: |
| 841 | 1058 | if 0 <= color <= 1.0: |
| 842 | 1059 | return color * 255.0 |
| 843 | raise ValueError(f'Invalid alpha channel value: {color}') | |
| 1060 | raise ValueError(f"Invalid alpha channel value: {color}") | |
| 844 | 1061 | |
| 845 | 1062 | |
| 846 | 1063 | def _invert_color(rgb_or_rgba): |
| … | … | @@ -849,177 +1066,202 @@ def _invert_color(rgb_or_rgba): |
| 849 | 1066 | |
| 850 | 1067 | # <https://www.w3.org/TR/css-color-3/#svg-color> |
| 851 | 1068 | _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), | |
| 999 | 1216 | } |
| 1000 | 1217 | |
| 1001 | 1218 | |
| 1002 | def _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): | |
| 1219 | def _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 | ): | |
| 1011 | 1240 | unsupported = () |
| 1012 | 1241 | is_square = matrix_width == matrix_height |
| 1013 | 1242 | 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 | ] | |
| 1015 | 1248 | 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 | ) | |
| 1017 | 1255 | elif matrix_width < 45: # QR Code version 7 |
| 1018 | 1256 | unsupported = [consts.TYPE_VERSION_DARK, consts.TYPE_VERSION_LIGHT] |
| 1019 | 1257 | 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 | ) | |
| 1023 | 1265 | mt2color = { |
| 1024 | 1266 | consts.TYPE_FINDER_PATTERN_DARK: finder_dark if finder_dark is not False else dark, |
| 1025 | 1267 | 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, |
| 1028 | 1270 | consts.TYPE_VERSION_DARK: version_dark if version_dark is not False else dark, |
| 1029 | 1271 | consts.TYPE_VERSION_LIGHT: version_light if version_light is not False else light, |
| 1030 | 1272 | 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, | |
| 1032 | 1276 | consts.TYPE_TIMING_DARK: timing_dark if timing_dark is not False else dark, |
| 1033 | 1277 | consts.TYPE_TIMING_LIGHT: timing_light if timing_light is not False else light, |
| 1034 | 1278 | 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, |
| 1041 | 1285 | |
| 1042 | 1286 | |
| 1043 | 1287 | _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, | |
| 1056 | 1300 | } |
| 1057 | 1301 | |
| 1058 | 1302 | |
| … | … | @@ -1064,16 +1308,16 @@ def save(matrix, matrix_size, out, kind=None, **kw): |
| 1064 | 1308 | is_stream = True |
| 1065 | 1309 | except AttributeError: |
| 1066 | 1310 | fname = out |
| 1067 | ext = fname[fname.rfind('.') + 1:].lower() | |
| 1311 | ext = fname[fname.rfind(".") + 1 :].lower() | |
| 1068 | 1312 | else: |
| 1069 | 1313 | ext = kind.lower() |
| 1070 | is_svgz = not is_stream and ext == 'svgz' | |
| 1314 | is_svgz = not is_stream and ext == "svgz" | |
| 1071 | 1315 | try: |
| 1072 | serializer = _VALID_SERIALIZERS[ext if not is_svgz else 'svg'] | |
| 1316 | serializer = _VALID_SERIALIZERS[ext if not is_svgz else "svg"] | |
| 1073 | 1317 | except KeyError: |
| 1074 | 1318 | raise ValueError(f'Unknown file extension ".{ext}"') |
| 1075 | 1319 | 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: | |
| 1077 | 1321 | serializer(matrix, matrix_size, f, **kw) |
| 1078 | 1322 | else: |
| 1079 | 1323 | serializer(matrix, matrix_size, out, **kw) |
| … | … | @@ -18,7 +18,7 @@ |
| 18 | 18 | Create and parse torrent files and magnet URIs |
| 19 | 19 | """ |
| 20 | 20 | |
| 21 | __version__ = '4.2.0' | |
| 21 | __version__ = "4.2.0" | |
| 22 | 22 | |
| 23 | 23 | from ._errors import * |
| 24 | 24 | from ._magnet import Magnet |
| … | … | @@ -17,6 +17,7 @@ import os |
| 17 | 17 | |
| 18 | 18 | class TorfError(Exception): |
| 19 | 19 | """Base exception for all exceptions raised by torf""" |
| 20 | ||
| 20 | 21 | def __init__(self, msg, *posargs, **kwargs): |
| 21 | 22 | super().__init__(msg) |
| 22 | 23 | self.posargs = posargs |
| … | … | @@ -25,9 +26,10 @@ class TorfError(Exception): |
| 25 | 26 | |
| 26 | 27 | class URLError(TorfError): |
| 27 | 28 | """Invalid URL""" |
| 29 | ||
| 28 | 30 | def __init__(self, url): |
| 29 | 31 | self._url = url |
| 30 | super().__init__(f'{url}: Invalid URL', url) | |
| 32 | super().__init__(f"{url}: Invalid URL", url) | |
| 31 | 33 | |
| 32 | 34 | @property |
| 33 | 35 | def url(self): |
| … | … | @@ -37,16 +39,20 @@ class URLError(TorfError): |
| 37 | 39 | |
| 38 | 40 | class PieceSizeError(TorfError): |
| 39 | 41 | """Invalid piece size""" |
| 42 | ||
| 40 | 43 | def __init__(self, size, min=None, max=None): |
| 41 | 44 | self._size = size |
| 42 | 45 | self._min = min |
| 43 | 46 | self._max = max |
| 44 | 47 | 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 | ) | |
| 47 | 54 | 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) | |
| 50 | 56 | |
| 51 | 57 | @property |
| 52 | 58 | def size(self): |
| … | … | @@ -66,18 +72,20 @@ class PieceSizeError(TorfError): |
| 66 | 72 | |
| 67 | 73 | class MetainfoError(TorfError): |
| 68 | 74 | """Invalid torrent metainfo""" |
| 75 | ||
| 69 | 76 | def __init__(self, msg): |
| 70 | super().__init__(f'Invalid metainfo: {msg}', msg) | |
| 77 | super().__init__(f"Invalid metainfo: {msg}", msg) | |
| 71 | 78 | |
| 72 | 79 | |
| 73 | 80 | class BdecodeError(TorfError): |
| 74 | 81 | """Failed to decode bencoded byte sequence""" |
| 82 | ||
| 75 | 83 | def __init__(self, filepath=None): |
| 76 | 84 | self._filepath = filepath |
| 77 | 85 | if filepath is None: |
| 78 | super().__init__('Invalid metainfo format') | |
| 86 | super().__init__("Invalid metainfo format") | |
| 79 | 87 | else: |
| 80 | super().__init__(f'{filepath}: Invalid torrent file format', filepath) | |
| 88 | super().__init__(f"{filepath}: Invalid torrent file format", filepath) | |
| 81 | 89 | |
| 82 | 90 | @property |
| 83 | 91 | def filepath(self): |
| … | … | @@ -87,13 +95,14 @@ class BdecodeError(TorfError): |
| 87 | 95 | |
| 88 | 96 | class MagnetError(TorfError): |
| 89 | 97 | """Invalid magnet URI or value""" |
| 98 | ||
| 90 | 99 | def __init__(self, uri, reason=None): |
| 91 | 100 | self._uri = uri |
| 92 | 101 | self._reason = reason |
| 93 | 102 | if reason is not None: |
| 94 | super().__init__(f'{uri}: {reason}', uri, reason=reason) | |
| 103 | super().__init__(f"{uri}: {reason}", uri, reason=reason) | |
| 95 | 104 | else: |
| 96 | super().__init__(f'{uri}: Invalid magnet URI', uri) | |
| 105 | super().__init__(f"{uri}: Invalid magnet URI", uri) | |
| 97 | 106 | |
| 98 | 107 | @property |
| 99 | 108 | def uri(self): |
| … | … | @@ -108,9 +117,10 @@ class MagnetError(TorfError): |
| 108 | 117 | |
| 109 | 118 | class PathError(TorfError): |
| 110 | 119 | """General invalid or unexpected path""" |
| 120 | ||
| 111 | 121 | def __init__(self, path, msg): |
| 112 | 122 | self._path = path |
| 113 | super().__init__(f'{path}: {msg}', path, msg) | |
| 123 | super().__init__(f"{path}: {msg}", path, msg) | |
| 114 | 124 | |
| 115 | 125 | @property |
| 116 | 126 | def path(self): |
| … | … | @@ -120,10 +130,11 @@ class PathError(TorfError): |
| 120 | 130 | |
| 121 | 131 | class CommonPathError(TorfError): |
| 122 | 132 | """Files don't share parent directory""" |
| 133 | ||
| 123 | 134 | def __init__(self, filepaths): |
| 124 | 135 | 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) | |
| 127 | 138 | |
| 128 | 139 | @property |
| 129 | 140 | def filepaths(self): |
| … | … | @@ -133,9 +144,10 @@ class CommonPathError(TorfError): |
| 133 | 144 | |
| 134 | 145 | class VerifyIsDirectoryError(TorfError): |
| 135 | 146 | """Expected file but found directory""" |
| 147 | ||
| 136 | 148 | def __init__(self, path): |
| 137 | 149 | self._path = path |
| 138 | super().__init__(f'{path}: Is a directory', path) | |
| 150 | super().__init__(f"{path}: Is a directory", path) | |
| 139 | 151 | |
| 140 | 152 | @property |
| 141 | 153 | def path(self): |
| … | … | @@ -145,9 +157,10 @@ class VerifyIsDirectoryError(TorfError): |
| 145 | 157 | |
| 146 | 158 | class VerifyNotDirectoryError(TorfError): |
| 147 | 159 | """Expected (link to) directory, but found something else""" |
| 160 | ||
| 148 | 161 | def __init__(self, path): |
| 149 | 162 | self._path = path |
| 150 | super().__init__(f'{path}: Not a directory', path) | |
| 163 | super().__init__(f"{path}: Not a directory", path) | |
| 151 | 164 | |
| 152 | 165 | @property |
| 153 | 166 | def path(self): |
| … | … | @@ -157,18 +170,29 @@ class VerifyNotDirectoryError(TorfError): |
| 157 | 170 | |
| 158 | 171 | class VerifyFileSizeError(TorfError): |
| 159 | 172 | """Unexpected file size""" |
| 173 | ||
| 160 | 174 | def __init__(self, filepath, actual_size, expected_size): |
| 161 | 175 | self._filepath = filepath |
| 162 | 176 | self._actual_size = actual_size |
| 163 | 177 | self._expected_size = expected_size |
| 164 | 178 | 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 | ) | |
| 167 | 185 | 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 | ) | |
| 170 | 192 | 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 | ) | |
| 172 | 196 | |
| 173 | 197 | @property |
| 174 | 198 | def filepath(self): |
| … | … | @@ -188,14 +212,15 @@ class VerifyFileSizeError(TorfError): |
| 188 | 212 | |
| 189 | 213 | class VerifyContentError(TorfError): |
| 190 | 214 | """On-disk data does not match hashes in metainfo""" |
| 215 | ||
| 191 | 216 | def __init__(self, filepath, piece_index, piece_size, file_sizes): |
| 192 | 217 | self._filepath = filepath |
| 193 | 218 | self._piece_index = piece_index |
| 194 | 219 | self._piece_size = piece_size |
| 195 | msg = f'Corruption in piece {piece_index+1}' | |
| 220 | msg = f"Corruption in piece {piece_index + 1}" | |
| 196 | 221 | |
| 197 | 222 | 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}") | |
| 199 | 224 | elif len(file_sizes) == 1: |
| 200 | 225 | corrupt_files = (file_sizes[0][0],) |
| 201 | 226 | else: |
| … | … | @@ -208,7 +233,7 @@ class VerifyContentError(TorfError): |
| 208 | 233 | |
| 209 | 234 | # Find the files that are covered by the corrupt piece |
| 210 | 235 | cur_pos = 0 |
| 211 | for filepath,filesize in file_sizes: | |
| 236 | for filepath, filesize in file_sizes: | |
| 212 | 237 | # `file` is possibly corrupt if: |
| 213 | 238 | # 1. The corrupt piece STARTS between the beginning and the end |
| 214 | 239 | # of the file in the stream. |
| … | … | @@ -218,17 +243,20 @@ class VerifyContentError(TorfError): |
| 218 | 243 | # and end of the corrupt piece (i.e. file fits in one piece). |
| 219 | 244 | file_i_beg = cur_pos |
| 220 | 245 | 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 | ): | |
| 224 | 251 | corrupt_files.append(filepath) |
| 225 | 252 | cur_pos += filesize |
| 226 | 253 | |
| 227 | 254 | if len(corrupt_files) == 1: |
| 228 | msg += f' in {corrupt_files[0]}' | |
| 255 | msg += f" in {corrupt_files[0]}" | |
| 229 | 256 | 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 | ) | |
| 232 | 260 | |
| 233 | 261 | self._files = tuple(corrupt_files) |
| 234 | 262 | super().__init__(msg, filepath, piece_index, piece_size, file_sizes) |
| … | … | @@ -256,14 +284,15 @@ class VerifyContentError(TorfError): |
| 256 | 284 | |
| 257 | 285 | class ReadError(TorfError): |
| 258 | 286 | """Unreadable file or stream""" |
| 287 | ||
| 259 | 288 | def __init__(self, errno, path=None): |
| 260 | 289 | self._errno = errno |
| 261 | 290 | 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" | |
| 263 | 292 | if path is None: |
| 264 | super().__init__(f'{msg}', errno) | |
| 293 | super().__init__(f"{msg}", errno) | |
| 265 | 294 | else: |
| 266 | super().__init__(f'{path}: {msg}', errno, path) | |
| 295 | super().__init__(f"{path}: {msg}", errno, path) | |
| 267 | 296 | |
| 268 | 297 | @property |
| 269 | 298 | def path(self): |
| … | … | @@ -286,14 +315,15 @@ class MemoryError(TorfError, MemoryError): |
| 286 | 315 | |
| 287 | 316 | class WriteError(TorfError): |
| 288 | 317 | """Unwritable file or stream""" |
| 318 | ||
| 289 | 319 | def __init__(self, errno, path=None): |
| 290 | 320 | self._errno = errno |
| 291 | 321 | 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" | |
| 293 | 323 | if path is None: |
| 294 | super().__init__(f'{msg}', path) | |
| 324 | super().__init__(f"{msg}", path) | |
| 295 | 325 | else: |
| 296 | super().__init__(f'{path}: {msg}', errno, path) | |
| 326 | super().__init__(f"{path}: {msg}", errno, path) | |
| 297 | 327 | |
| 298 | 328 | @property |
| 299 | 329 | def path(self): |
| … | … | @@ -308,10 +338,11 @@ class WriteError(TorfError): |
| 308 | 338 | |
| 309 | 339 | class ConnectionError(TorfError): |
| 310 | 340 | """Unwritable file or stream""" |
| 311 | def __init__(self, url, msg='Failed'): | |
| 341 | ||
| 342 | def __init__(self, url, msg="Failed"): | |
| 312 | 343 | self._url = url |
| 313 | 344 | self._msg = str(msg) |
| 314 | super().__init__(f'{url}: {msg}', url, msg) | |
| 345 | super().__init__(f"{url}: {msg}", url, msg) | |
| 315 | 346 | |
| 316 | 347 | @property |
| 317 | 348 | def url(self): |
| … | … | @@ -25,15 +25,17 @@ from ._stream import TorrentFileStream |
| 25 | 25 | |
| 26 | 26 | QUEUE_CLOSED = object() |
| 27 | 27 | |
| 28 | _debug = logging.getLogger('torf').debug | |
| 28 | _debug = logging.getLogger("torf").debug | |
| 29 | ||
| 29 | 30 | |
| 30 | 31 | def _thread_name(): |
| 31 | 32 | return threading.current_thread().name |
| 32 | 33 | |
| 34 | ||
| 33 | 35 | def _pretty_bytes(b): |
| 34 | 36 | if isinstance(b, (bytes, bytearray)) and len(b) > 8: |
| 35 | 37 | # return b[:8].hex() + '...' + b[-8:].hex() |
| 36 | return b[:8] + b'...' + b[-8:] | |
| 38 | return b[:8] + b"..." + b[-8:] | |
| 37 | 39 | else: |
| 38 | 40 | return b |
| 39 | 41 | |
| … | … | @@ -76,12 +78,12 @@ class Worker: |
| 76 | 78 | self._thread.start() |
| 77 | 79 | except RuntimeError as e: |
| 78 | 80 | 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") | |
| 80 | 82 | else: |
| 81 | _debug(f'{self.name}: Failed to start thread: {e!r}') | |
| 83 | _debug(f"{self.name}: Failed to start thread: {e!r}") | |
| 82 | 84 | raise |
| 83 | 85 | else: |
| 84 | _debug(f'{self.name}: Started') | |
| 86 | _debug(f"{self.name}: Started") | |
| 85 | 87 | |
| 86 | 88 | def join(self, *args, **kwargs): |
| 87 | 89 | if self.is_running: |
| … | … | @@ -102,7 +104,7 @@ class Reader(Worker): |
| 102 | 104 | self._piece_queue = queue.Queue(maxsize=queue_size) |
| 103 | 105 | self._stop = False |
| 104 | 106 | self._memory_error_timestamp = -1 |
| 105 | super().__init__(name='reader', worker=self._push_pieces) | |
| 107 | super().__init__(name="reader", worker=self._push_pieces) | |
| 106 | 108 | |
| 107 | 109 | def _push_pieces(self): |
| 108 | 110 | stream = TorrentFileStream(self._torrent) |
| … | … | @@ -111,10 +113,14 @@ class Reader(Worker): |
| 111 | 113 | for piece_index, (piece, filepath, exceptions) in enumerate(iter_pieces): |
| 112 | 114 | # _debug(f'{_thread_name()}: Read #{piece_index}') |
| 113 | 115 | if self._stop: |
| 114 | _debug(f'{_thread_name()}: Stopped reading') | |
| 116 | _debug(f"{_thread_name()}: Stopped reading") | |
| 115 | 117 | break |
| 116 | 118 | 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 | ) | |
| 118 | 124 | elif piece: |
| 119 | 125 | self._push_piece(piece_index=piece_index, filepath=filepath, piece=piece) |
| 120 | 126 | else: |
| … | … | @@ -125,12 +131,12 @@ class Reader(Worker): |
| 125 | 131 | # _debug(f'{_thread_name()}: {self._piece_queue.qsize()} pieces queued') |
| 126 | 132 | |
| 127 | 133 | except BaseException as e: |
| 128 | _debug(f'{_thread_name()}: Exception while reading: {e!r}') | |
| 134 | _debug(f"{_thread_name()}: Exception while reading: {e!r}") | |
| 129 | 135 | raise |
| 130 | 136 | |
| 131 | 137 | finally: |
| 132 | 138 | 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") | |
| 134 | 140 | stream.close() |
| 135 | 141 | |
| 136 | 142 | def _push_piece(self, *, piece_index, filepath, piece=None, exceptions=()): |
| … | … | @@ -145,7 +151,7 @@ class Reader(Worker): |
| 145 | 151 | old_maxsize = self._piece_queue.maxsize |
| 146 | 152 | new_maxsize = max(1, int(old_maxsize * 0.9)) |
| 147 | 153 | 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}") | |
| 149 | 155 | self._piece_queue.maxsize = new_maxsize |
| 150 | 156 | self._memory_error_timestamp = now |
| 151 | 157 | else: |
| … | … | @@ -154,7 +160,7 @@ class Reader(Worker): |
| 154 | 160 | def stop(self): |
| 155 | 161 | """Stop reading and close the piece queue""" |
| 156 | 162 | 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") | |
| 158 | 164 | self._stop = True |
| 159 | 165 | |
| 160 | 166 | @property |
| … | … | @@ -180,7 +186,7 @@ class HasherPool: |
| 180 | 186 | |
| 181 | 187 | # Janitor takes care of closing the hash queue, removing idle hashers, etc |
| 182 | 188 | self._janitor = Worker( |
| 183 | name='janitor', | |
| 189 | name="janitor", | |
| 184 | 190 | worker=self._janitor_thread, |
| 185 | 191 | start=False, |
| 186 | 192 | ) |
| … | … | @@ -188,7 +194,7 @@ class HasherPool: |
| 188 | 194 | # Hashers read from piece_queue and push to hash_queue |
| 189 | 195 | self._hashers = [ |
| 190 | 196 | Worker( |
| 191 | name='hasher1', | |
| 197 | name="hasher1", | |
| 192 | 198 | # One hasher is vital an may not die from boredom |
| 193 | 199 | worker=lambda: self._hasher_thread(is_vital=True), |
| 194 | 200 | start=False, |
| … | … | @@ -197,7 +203,7 @@ class HasherPool: |
| 197 | 203 | for i in range(2, hasher_threads + 1): |
| 198 | 204 | self._hashers.append( |
| 199 | 205 | Worker( |
| 200 | name=f'hasher{i}', | |
| 206 | name=f"hasher{i}", | |
| 201 | 207 | # All other hashers should die if they are bored |
| 202 | 208 | worker=lambda: self._hasher_thread(is_vital=False), |
| 203 | 209 | start=False, |
| … | … | @@ -223,13 +229,13 @@ class HasherPool: |
| 223 | 229 | task = piece_queue.get(timeout=0.5) |
| 224 | 230 | except queue.Empty: |
| 225 | 231 | if not is_vital: |
| 226 | _debug(f'{_thread_name()}: I am bored, byeee!') | |
| 232 | _debug(f"{_thread_name()}: I am bored, byeee!") | |
| 227 | 233 | break |
| 228 | 234 | else: |
| 229 | _debug(f'{_thread_name()}: I am bored, but needed.') | |
| 235 | _debug(f"{_thread_name()}: I am bored, but needed.") | |
| 230 | 236 | else: |
| 231 | 237 | if task is QUEUE_CLOSED: |
| 232 | _debug(f'{_thread_name()}: piece_queue is closed') | |
| 238 | _debug(f"{_thread_name()}: piece_queue is closed") | |
| 233 | 239 | # Repeat QUEUE_CLOSED to the next sibling. This ensures |
| 234 | 240 | # there is always one more QUEUE_CLOSED queued than running |
| 235 | 241 | # threads. Otherwise, one thread might consume multiple |
| … | … | @@ -257,11 +263,11 @@ class HasherPool: |
| 257 | 263 | |
| 258 | 264 | def _janitor_thread(self): |
| 259 | 265 | while True: |
| 260 | _debug(f'{_thread_name()}: Waiting for finalize event') | |
| 266 | _debug(f"{_thread_name()}: Waiting for finalize event") | |
| 261 | 267 | finalization_initiated = self._finalize_event.wait(timeout=1.0) |
| 262 | 268 | if finalization_initiated: |
| 263 | 269 | self._wait_for_hashers() |
| 264 | _debug(f'{_thread_name()}: Closing hash queue') | |
| 270 | _debug(f"{_thread_name()}: Closing hash queue") | |
| 265 | 271 | self._hash_queue.put(QUEUE_CLOSED) |
| 266 | 272 | break |
| 267 | 273 | |
| … | … | @@ -269,28 +275,28 @@ class HasherPool: |
| 269 | 275 | # Remove terminated idle hashers |
| 270 | 276 | for hasher in tuple(self._hashers): |
| 271 | 277 | if not hasher.is_running: |
| 272 | _debug(f'{_thread_name()}: Pruning {hasher.name}') | |
| 278 | _debug(f"{_thread_name()}: Pruning {hasher.name}") | |
| 273 | 279 | self._hashers.remove(hasher) |
| 274 | 280 | |
| 275 | _debug(f'{_thread_name()}: Terminating') | |
| 281 | _debug(f"{_thread_name()}: Terminating") | |
| 276 | 282 | |
| 277 | 283 | def _wait_for_hashers(self): |
| 278 | 284 | while True: |
| 279 | 285 | # _debug(f'{_thread_name()}: Hashers running: {[h.name for h in self._hashers if h.is_running]}') |
| 280 | 286 | 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") | |
| 282 | 288 | break |
| 283 | 289 | |
| 284 | 290 | def join(self): |
| 285 | 291 | """Block until all threads have terminated""" |
| 286 | 292 | for hasher in self._hashers: |
| 287 | _debug(f'{_thread_name()}: Joining {hasher.name}') | |
| 293 | _debug(f"{_thread_name()}: Joining {hasher.name}") | |
| 288 | 294 | hasher.join() |
| 289 | _debug(f'{_thread_name()}: Joined all hashers') | |
| 295 | _debug(f"{_thread_name()}: Joined all hashers") | |
| 290 | 296 | |
| 291 | _debug(f'{_thread_name()}: Joining {self._janitor.name}') | |
| 297 | _debug(f"{_thread_name()}: Joining {self._janitor.name}") | |
| 292 | 298 | self._janitor.join() |
| 293 | _debug(f'{_thread_name()}: Joined {self._janitor.name}') | |
| 299 | _debug(f"{_thread_name()}: Joined {self._janitor.name}") | |
| 294 | 300 | |
| 295 | 301 | @property |
| 296 | 302 | def hash_queue(self): |
| … | … | @@ -337,7 +343,7 @@ class Collector: |
| 337 | 343 | self._collect(*task) |
| 338 | 344 | |
| 339 | 345 | 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}") | |
| 341 | 347 | self._cancel() |
| 342 | 348 | raise |
| 343 | 349 | |
| … | … | @@ -365,8 +371,12 @@ class Collector: |
| 365 | 371 | if self._callback: |
| 366 | 372 | # _debug(f'{_thread_name()}: Collector callback: {self._callback}') |
| 367 | 373 | 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, | |
| 370 | 380 | ) |
| 371 | 381 | # _debug(f'{_thread_name()}: Collector callback return value: {maybe_cancel}') |
| 372 | 382 | if maybe_cancel is not None: |
| … | … | @@ -380,11 +390,11 @@ class Collector: |
| 380 | 390 | self._reader.stop() |
| 381 | 391 | |
| 382 | 392 | def _finalize(self): |
| 383 | _debug(f'{_thread_name()}: Joining {self._reader}') | |
| 393 | _debug(f"{_thread_name()}: Joining {self._reader}") | |
| 384 | 394 | self._reader.join() |
| 385 | _debug(f'{_thread_name()}: Joining {self._hashers}') | |
| 395 | _debug(f"{_thread_name()}: Joining {self._hashers}") | |
| 386 | 396 | 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") | |
| 388 | 398 | |
| 389 | 399 | @property |
| 390 | 400 | def hashes(self): |
| … | … | @@ -397,6 +407,7 @@ class _IntervaledCallback: |
| 397 | 407 | Callable that calls `callback`, but only if at least `interval` seconds |
| 398 | 408 | elapsed since the previous call |
| 399 | 409 | """ |
| 410 | ||
| 400 | 411 | def __init__(self, callback, interval=0): |
| 401 | 412 | self._callback = callback |
| 402 | 413 | self._interval = interval |
| … | … | @@ -422,22 +433,33 @@ class _TranslatingCallback: |
| 422 | 433 | ) |
| 423 | 434 | |
| 424 | 435 | 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 | ) | |
| 430 | 448 | |
| 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 | ): | |
| 432 | 452 | # Figure out if we must ignore the interval for this call. This method |
| 433 | 453 | # is called for every hashed piece and should be as efficient as |
| 434 | 454 | # possible. |
| 435 | raise NotImplementedError('You must implement this method!') | |
| 455 | raise NotImplementedError("You must implement this method!") | |
| 436 | 456 | |
| 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 | ): | |
| 438 | 460 | # Translate arguments for the actual callback. This method is only |
| 439 | 461 | # called at intervals (e.g. once per second). |
| 440 | raise NotImplementedError('You must implement this method!') | |
| 462 | raise NotImplementedError("You must implement this method!") | |
| 441 | 463 | |
| 442 | 464 | |
| 443 | 465 | class GenerateCallback(_TranslatingCallback): |
| … | … | @@ -446,10 +468,14 @@ class GenerateCallback(_TranslatingCallback): |
| 446 | 468 | :meth:`~.Torrent.generate` |
| 447 | 469 | """ |
| 448 | 470 | |
| 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 | ): | |
| 450 | 474 | return exceptions or pieces_done >= pieces_total |
| 451 | 475 | |
| 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 | ): | |
| 453 | 479 | if exceptions: |
| 454 | 480 | # Torrent creation errors are always fatal and must be raised |
| 455 | 481 | raise exceptions[0] |
| … | … | @@ -463,6 +489,7 @@ class VerifyCallback(_TranslatingCallback): |
| 463 | 489 | Translate arguments from :class:`Collector` to what's specified by |
| 464 | 490 | :meth:`~.Torrent.verify` |
| 465 | 491 | """ |
| 492 | ||
| 466 | 493 | def __init__(self, *args, path, **kwargs): |
| 467 | 494 | super().__init__(*args, **kwargs) |
| 468 | 495 | |
| … | … | @@ -482,34 +509,48 @@ class VerifyCallback(_TranslatingCallback): |
| 482 | 509 | for file in self._torrent.files |
| 483 | 510 | ) |
| 484 | 511 | |
| 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 | ): | |
| 486 | 515 | return ( |
| 487 | 516 | # Always report exceptions |
| 488 | 517 | exceptions |
| 489 | 518 | # Always report completion |
| 490 | 519 | or pieces_done >= pieces_total |
| 491 | 520 | # 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]) | |
| 493 | 522 | ) |
| 494 | 523 | |
| 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 | ): | |
| 496 | 527 | if ( |
| 497 | 528 | # Don't add verification error if there are other errors |
| 498 | 529 | not exceptions |
| 499 | 530 | # 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] | |
| 501 | 533 | ): |
| 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 | ) | |
| 505 | 542 | |
| 506 | 543 | if self._callback: |
| 507 | 544 | # Callback can raise exception or handle it otherwise |
| 508 | 545 | def call_callback(fpath, exception): |
| 509 | 546 | 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, | |
| 513 | 554 | ) |
| 514 | 555 | |
| 515 | 556 | if exceptions: |
| … | … | @@ -530,10 +571,10 @@ class VerifyCallback(_TranslatingCallback): |
| 530 | 571 | |
| 531 | 572 | @staticmethod |
| 532 | 573 | def _get_path_from_exception(exception): |
| 533 | for attr in ('filepath', 'path'): | |
| 574 | for attr in ("filepath", "path"): | |
| 534 | 575 | try: |
| 535 | 576 | return getattr(exception, attr) |
| 536 | 577 | except AttributeError: |
| 537 | 578 | pass |
| 538 | 579 | |
| 539 | raise RuntimeError(f'Failed to get path from {exception!r}') | |
| 580 | raise RuntimeError(f"Failed to get path from {exception!r}") |
| … | … | @@ -24,7 +24,7 @@ from . import _errors as error |
| 24 | 24 | from . import _utils as utils |
| 25 | 25 | |
| 26 | 26 | |
| 27 | class Magnet(): | |
| 27 | class Magnet: | |
| 28 | 28 | """ |
| 29 | 29 | BTIH Magnet URI |
| 30 | 30 | |
| … | … | @@ -49,10 +49,22 @@ class Magnet(): |
| 49 | 49 | | http://shareaza.sourceforge.net/mediawiki/index.php/Magnet_URI_scheme |
| 50 | 50 | """ |
| 51 | 51 | |
| 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 | ): | |
| 56 | 68 | self._tr = utils.MonitoredList(type=utils.URL) |
| 57 | 69 | self._ws = utils.MonitoredList(type=utils.URL) |
| 58 | 70 | self.xt = xt |
| … | … | @@ -66,12 +78,12 @@ class Magnet(): |
| 66 | 78 | |
| 67 | 79 | self._x = defaultdict(lambda: None) |
| 68 | 80 | for key in tuple(kwargs): |
| 69 | if key.startswith('x_'): | |
| 81 | if key.startswith("x_"): | |
| 70 | 82 | self._x[key[2:]] = kwargs.pop(key) |
| 71 | 83 | |
| 72 | 84 | if kwargs: |
| 73 | 85 | key, value = next(iter(kwargs.items())) |
| 74 | raise TypeError(f'Unrecognized argument: {key}={value!r}') | |
| 86 | raise TypeError(f"Unrecognized argument: {key}={value!r}") | |
| 75 | 87 | |
| 76 | 88 | @property |
| 77 | 89 | def dn(self): |
| … | … | @@ -80,15 +92,14 @@ class Magnet(): |
| 80 | 92 | |
| 81 | 93 | @dn.setter |
| 82 | 94 | 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 | |
| 84 | 96 | |
| 85 | 97 | @property |
| 86 | 98 | def xt(self): |
| 87 | 99 | """ |
| 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 | |
| 89 | 101 | |
| 90 | 102 | Example: |
| 91 | ||
| 92 | 103 | urn:btih:3bb9561e35b06175bb6d2c2330578dc83846cc5d |
| 93 | 104 | |
| 94 | 105 | For convenience, this property may be set to the info hash without the |
| … | … | @@ -96,7 +107,7 @@ class Magnet(): |
| 96 | 107 | |
| 97 | 108 | :raises MagnetError: if set to an invalid value |
| 98 | 109 | """ |
| 99 | return f'urn:btih:{self._infohash}' | |
| 110 | return f"urn:btih:{self._infohash}" | |
| 100 | 111 | |
| 101 | 112 | @xt.setter |
| 102 | 113 | def xt(self, value): |
| … | … | @@ -107,7 +118,7 @@ class Magnet(): |
| 107 | 118 | match = self._XT_REGEX.match(value) |
| 108 | 119 | if match: |
| 109 | 120 | self._infohash = match.group(1) |
| 110 | if not hasattr(self, '_infohash'): | |
| 121 | if not hasattr(self, "_infohash"): | |
| 111 | 122 | raise error.MagnetError(value, 'Invalid exact topic ("xt")') |
| 112 | 123 | |
| 113 | 124 | @property |
| … | … | @@ -126,12 +137,12 @@ class Magnet(): |
| 126 | 137 | if match: |
| 127 | 138 | self._infohash = value |
| 128 | 139 | else: |
| 129 | raise error.MagnetError(value, 'Invalid info hash') | |
| 140 | raise error.MagnetError(value, "Invalid info hash") | |
| 130 | 141 | |
| 131 | 142 | @property |
| 132 | 143 | def xl(self): |
| 133 | 144 | """ |
| 134 | eXact Length: Size in bytes or ``None`` | |
| 145 | EXact Length: Size in bytes or ``None`` | |
| 135 | 146 | |
| 136 | 147 | :raises MagnetError: if set to an invalid value |
| 137 | 148 | """ |
| … | … | @@ -146,7 +157,7 @@ class Magnet(): |
| 146 | 157 | raise error.MagnetError(value, 'Invalid exact length ("xl")') |
| 147 | 158 | else: |
| 148 | 159 | if value < 1: |
| 149 | raise error.MagnetError(value, 'Must be 1 or larger') | |
| 160 | raise error.MagnetError(value, "Must be 1 or larger") | |
| 150 | 161 | else: |
| 151 | 162 | self._xl = value |
| 152 | 163 | else: |
| … | … | @@ -173,7 +184,7 @@ class Magnet(): |
| 173 | 184 | @property |
| 174 | 185 | def xs(self): |
| 175 | 186 | """ |
| 176 | eXact Source: Torrent file URL or ``None`` | |
| 187 | EXact Source: Torrent file URL or ``None`` | |
| 177 | 188 | |
| 178 | 189 | :raises URLError: if the URL is invalid |
| 179 | 190 | """ |
| … | … | @@ -248,6 +259,7 @@ class Magnet(): |
| 248 | 259 | """:class:`Torrent` instance""" |
| 249 | 260 | # Prevent circular import issues |
| 250 | 261 | from ._torrent import Torrent |
| 262 | ||
| 251 | 263 | torrent = Torrent() |
| 252 | 264 | torrent.name = self.dn |
| 253 | 265 | if self.tr: |
| … | … | @@ -255,15 +267,16 @@ class Magnet(): |
| 255 | 267 | if self.ws: |
| 256 | 268 | torrent.webseeds = self.ws |
| 257 | 269 | 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 | |
| 261 | 273 | elif len(self.infohash) == 40: |
| 262 | 274 | torrent._infohash = self.infohash |
| 263 | 275 | else: |
| 264 | 276 | # 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 | ) | |
| 267 | 280 | return torrent |
| 268 | 281 | |
| 269 | 282 | def get_info(self, validate=True, timeout=60, callback=None): |
| … | … | @@ -286,20 +299,23 @@ class Magnet(): |
| 286 | 299 | :return: ``True`` if the "info" section was successfully downloaded, |
| 287 | 300 | ``False`` otherwise |
| 288 | 301 | """ |
| 302 | ||
| 289 | 303 | def success(): |
| 290 | return hasattr(self, '_info') | |
| 304 | return hasattr(self, "_info") | |
| 291 | 305 | |
| 292 | 306 | 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) | |
| 296 | 312 | # I couldn't find any documentation for the "/file?info_hash=..." GET request, but |
| 297 | 313 | # it seems to work for HTTP trackers. |
| 298 | 314 | # https://stackoverflow.com/a/1019588 |
| 299 | 315 | for url in self.tr: |
| 300 | if url.scheme in ('http', 'https'): | |
| 316 | if url.scheme in ("http", "https"): | |
| 301 | 317 | 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}") | |
| 303 | 319 | |
| 304 | 320 | start = time.monotonic() |
| 305 | 321 | for url in torrent_urls: |
| … | … | @@ -320,6 +336,7 @@ class Magnet(): |
| 320 | 336 | """Extract "info" section from `torrent_data` for :meth:`torrent`""" |
| 321 | 337 | # Prevent circular import issues |
| 322 | 338 | from ._torrent import Torrent |
| 339 | ||
| 323 | 340 | stream = io.BytesIO(torrent_data) |
| 324 | 341 | try: |
| 325 | 342 | torrent = Torrent.read_stream(stream, validate=validate) |
| … | … | @@ -328,11 +345,13 @@ class Magnet(): |
| 328 | 345 | callback(e) |
| 329 | 346 | else: |
| 330 | 347 | 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"] | |
| 334 | 353 | |
| 335 | _KNOWN_PARAMETERS = ('xt', 'dn', 'xl', 'tr', 'xs', 'as', 'ws', 'kt') | |
| 354 | _KNOWN_PARAMETERS = ("xt", "dn", "xl", "tr", "xs", "as", "ws", "kt") | |
| 336 | 355 | |
| 337 | 356 | @classmethod |
| 338 | 357 | def from_string(cls, uri): |
| … | … | @@ -342,30 +361,32 @@ class Magnet(): |
| 342 | 361 | :raises URLError: if `uri` contains an invalid URL (e.g. :attr:`tr`) |
| 343 | 362 | :raises MagnetError: if `uri` is not a valid magnet URI |
| 344 | 363 | """ |
| 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") | |
| 348 | 367 | else: |
| 349 | 368 | query = urllib.parse.parse_qs(info.query) |
| 350 | 369 | |
| 351 | 370 | # Check for unknown parameters |
| 352 | 371 | 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") | |
| 355 | 374 | |
| 356 | if 'xt' not in query: | |
| 375 | if "xt" not in query: | |
| 357 | 376 | raise error.MagnetError(uri, 'Missing exact topic ("xt")') |
| 358 | elif len(query['xt']) > 1: | |
| 377 | elif len(query["xt"]) > 1: | |
| 359 | 378 | raise error.MagnetError(uri, 'Multiple exact topics ("xt")') |
| 360 | 379 | else: |
| 361 | self = cls(xt=query['xt'][0]) | |
| 380 | self = cls(xt=query["xt"][0]) | |
| 362 | 381 | |
| 363 | 382 | # 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 | ): | |
| 369 | 390 | if param in query: |
| 370 | 391 | if len(query[param]) > 1: |
| 371 | 392 | raise error.MagnetError(uri, f'Multiple {name}s ("{param}")') |
| … | … | @@ -373,48 +394,47 @@ class Magnet(): |
| 373 | 394 | setattr(self, attr, parse(query[param][0])) |
| 374 | 395 | |
| 375 | 396 | # Parameters that accept multiple values |
| 376 | for param,name in (('tr', 'tracker'), | |
| 377 | ('ws', 'webseed')): | |
| 397 | for param, name in (("tr", "tracker"), ("ws", "webseed")): | |
| 378 | 398 | if param in query: |
| 379 | 399 | setattr(self, param, query[param]) |
| 380 | 400 | |
| 381 | 401 | return self |
| 382 | 402 | |
| 383 | 403 | def __str__(self): |
| 384 | uri = [f'magnet:?xt={self.xt}'] | |
| 404 | uri = [f"magnet:?xt={self.xt}"] | |
| 385 | 405 | |
| 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}") | |
| 388 | 408 | if value is not None: |
| 389 | 409 | if isinstance(value, str): |
| 390 | uri.append(f'{key}={utils.urlquote(value)}') | |
| 410 | uri.append(f"{key}={utils.urlquote(value)}") | |
| 391 | 411 | else: |
| 392 | uri.append(f'{key}={value}') | |
| 412 | uri.append(f"{key}={value}") | |
| 393 | 413 | |
| 394 | 414 | 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)}") | |
| 396 | 416 | |
| 397 | for key in ('tr', 'ws'): | |
| 398 | seq = getattr(self, f'{key}') | |
| 417 | for key in ("tr", "ws"): | |
| 418 | seq = getattr(self, f"{key}") | |
| 399 | 419 | if seq is not None: |
| 400 | 420 | for item in seq: |
| 401 | uri.append(f'{key}={utils.urlquote(item)}') | |
| 421 | uri.append(f"{key}={utils.urlquote(item)}") | |
| 402 | 422 | |
| 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)}") | |
| 405 | 425 | |
| 406 | return '&'.join(uri) | |
| 426 | return "&".join(uri) | |
| 407 | 427 | |
| 408 | 428 | def __repr__(self): |
| 409 | 429 | clsname = type(self).__name__ |
| 410 | 430 | kwargs = {} |
| 411 | 431 | for param in self._KNOWN_PARAMETERS: |
| 412 | if param == 'as': | |
| 413 | param = 'as_' | |
| 432 | if param == "as": | |
| 433 | param = "as_" | |
| 414 | 434 | value = getattr(self, param) |
| 415 | 435 | if value: |
| 416 | 436 | 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})" |
| … | … | @@ -9,7 +9,7 @@ from . import _stream as stream |
| 9 | 9 | class find_torrent_files: |
| 10 | 10 | """Iterator over ``(torrent_file, torrent_file_counter, exception)`` tuples""" |
| 11 | 11 | |
| 12 | def __init__(self, *paths, max_file_size=float('inf')): | |
| 12 | def __init__(self, *paths, max_file_size=float("inf")): | |
| 13 | 13 | self._paths = paths |
| 14 | 14 | self._counter = 0 |
| 15 | 15 | self._max_file_size = max_file_size |
| … | … | @@ -34,7 +34,7 @@ class find_torrent_files: |
| 34 | 34 | except OSError as e: |
| 35 | 35 | yield None, self._counter, error.ReadError(e.errno, str(path)) |
| 36 | 36 | |
| 37 | elif os.path.basename(path).lower().endswith('.torrent'): | |
| 37 | elif os.path.basename(path).lower().endswith(".torrent"): | |
| 38 | 38 | try: |
| 39 | 39 | file_size = os.path.getsize(path) |
| 40 | 40 | except OSError: |
| … | … | @@ -75,10 +75,10 @@ def is_file_match(torrent, candidate): |
| 75 | 75 | """ |
| 76 | 76 | # Compare relative file paths and file sizes. |
| 77 | 77 | # 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"] | |
| 79 | 79 | |
| 80 | 80 | # 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"]: | |
| 82 | 82 | return False |
| 83 | 83 | |
| 84 | 84 | torrent_id = _get_filepaths_and_sizes(torrent_info) |
| … | … | @@ -89,27 +89,30 @@ def is_file_match(torrent, candidate): |
| 89 | 89 | |
| 90 | 90 | return False |
| 91 | 91 | |
| 92 | ||
| 92 | 93 | def _get_filepaths_and_sizes(info): |
| 93 | name = info['name'] | |
| 94 | name = info["name"] | |
| 94 | 95 | |
| 95 | 96 | # Singlefile torrent |
| 96 | length = info.get('length', None) | |
| 97 | length = info.get("length", None) | |
| 97 | 98 | if length: |
| 98 | 99 | return [(name, length)] |
| 99 | 100 | |
| 100 | 101 | # Multifile torrent |
| 101 | files = info.get('files', None) | |
| 102 | files = info.get("files", None) | |
| 102 | 103 | if files: |
| 103 | 104 | files_and_sizes = [] |
| 104 | 105 | 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 | ) | |
| 109 | 112 | return sorted(files_and_sizes) |
| 110 | 113 | |
| 111 | 114 | else: |
| 112 | raise RuntimeError(f'Unable to find files: {info!r}') | |
| 115 | raise RuntimeError(f"Unable to find files: {info!r}") | |
| 113 | 116 | |
| 114 | 117 | |
| 115 | 118 | def is_content_match(torrent, candidate): |
| … | … | @@ -126,7 +129,7 @@ def is_content_match(torrent, candidate): |
| 126 | 129 | returned `True`. |
| 127 | 130 | """ |
| 128 | 131 | 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}") | |
| 130 | 133 | |
| 131 | 134 | # Compare some piece hashes for each file |
| 132 | 135 | with stream.TorrentFileStream(candidate, content_path=torrent.path) as tfs: |
| … | … | @@ -135,9 +138,7 @@ def is_content_match(torrent, candidate): |
| 135 | 138 | all_file_piece_indexes = tfs.get_piece_indexes_of_file(file) |
| 136 | 139 | middle_piece_index = int(len(all_file_piece_indexes) / 2) |
| 137 | 140 | 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:] | |
| 141 | 142 | ) |
| 142 | 143 | check_piece_indexes.update(some_file_piece_indexes) |
| 143 | 144 | |
| … | … | @@ -152,25 +153,25 @@ def copy(from_torrent, to_torrent): |
| 152 | 153 | Copy ``pieces``, ``piece length`` and ``files`` from `from_torrent` to |
| 153 | 154 | `to_torrent` |
| 154 | 155 | """ |
| 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"]: | |
| 159 | 160 | # Confirm both file lists are identical while ignoring order |
| 160 | 161 | def make_sortable(files): |
| 161 | 162 | return [tuple(f.items()) for f in files] |
| 162 | 163 | |
| 163 | 164 | # Only include "length" and "files" fields |
| 164 | 165 | 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"] | |
| 167 | 167 | ] |
| 168 | 168 | |
| 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 | ) | |
| 171 | 172 | |
| 172 | 173 | # Copy file order from `source_info` |
| 173 | to_torrent.metainfo['info']['files'] = source_files | |
| 174 | to_torrent.metainfo["info"]["files"] = source_files | |
| 174 | 175 | |
| 175 | 176 | |
| 176 | 177 | class ReuseCallback(generate._IntervaledCallback): |
| … | … | @@ -1,5 +1,4 @@ |
| 1 | 1 | import errno |
| 2 | import functools | |
| 3 | 2 | import hashlib |
| 4 | 3 | import itertools |
| 5 | 4 | import math |
| … | … | @@ -19,7 +18,6 @@ class TorrentFileStream: |
| 19 | 18 | closed when no longer needed. |
| 20 | 19 | |
| 21 | 20 | Example: |
| 22 | ||
| 23 | 21 | >>> torrent = torf.Torrent(...) |
| 24 | 22 | >>> with TorrentFileStream(torrent) as tfs: |
| 25 | 23 | >>> # Get the 29th piece of the concatenated file stream |
| … | … | @@ -43,9 +41,9 @@ class TorrentFileStream: |
| 43 | 41 | elif none_ok: |
| 44 | 42 | content_path = None |
| 45 | 43 | 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") | |
| 47 | 45 | |
| 48 | if self._torrent.mode == 'singlefile': | |
| 46 | if self._torrent.mode == "singlefile": | |
| 49 | 47 | # Torrent contains no directory, just a file |
| 50 | 48 | return content_path or file |
| 51 | 49 | |
| … | … | @@ -102,7 +100,7 @@ class TorrentFileStream: |
| 102 | 100 | try: |
| 103 | 101 | file_index = self._torrent.files.index(file) |
| 104 | 102 | except ValueError: |
| 105 | raise ValueError(f'File not specified: {file}') | |
| 103 | raise ValueError(f"File not specified: {file}") | |
| 106 | 104 | else: |
| 107 | 105 | stream_pos = sum(f.size for f in self._torrent.files[:file_index]) |
| 108 | 106 | return stream_pos |
| … | … | @@ -127,7 +125,7 @@ class TorrentFileStream: |
| 127 | 125 | else: |
| 128 | 126 | pos += 1 |
| 129 | 127 | |
| 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}") | |
| 131 | 129 | |
| 132 | 130 | def get_piece_indexes_of_file(self, file, exclusive=False): |
| 133 | 131 | """ |
| … | … | @@ -176,11 +174,16 @@ class TorrentFileStream: |
| 176 | 174 | file_last_byte_index = pos + file.size - 1 |
| 177 | 175 | if ( |
| 178 | 176 | # 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 | |
| 180 | 179 | # 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 | |
| 182 | 182 | # 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 | ) | |
| 184 | 187 | ): |
| 185 | 188 | content_file_path = self._get_content_path(content_path, none_ok=True, file=file) |
| 186 | 189 | files.append(content_file_path) |
| … | … | @@ -217,7 +220,9 @@ class TorrentFileStream: |
| 217 | 220 | if files: |
| 218 | 221 | return files |
| 219 | 222 | |
| 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 | ) | |
| 221 | 226 | |
| 222 | 227 | def get_absolute_piece_indexes(self, file, relative_piece_indexes): |
| 223 | 228 | """ |
| … | … | @@ -229,7 +234,6 @@ class TorrentFileStream: |
| 229 | 234 | -1, -2] |
| 230 | 235 | |
| 231 | 236 | Example: |
| 232 | ||
| 233 | 237 | >>> # Assume `file` starts in the 50th piece in the stream of |
| 234 | 238 | >>> # concatenated files and is 100 pieces long. `1000` and `-1000` are |
| 235 | 239 | >>> # ignored because they are out of bounds. |
| … | … | @@ -269,7 +273,6 @@ class TorrentFileStream: |
| 269 | 273 | -1, -2] |
| 270 | 274 | |
| 271 | 275 | Example: |
| 272 | ||
| 273 | 276 | >>> # Assume `file` starts in the 50th piece in the stream of |
| 274 | 277 | >>> # concatenated files and is 100 pieces long. `1000` and `-1000` are |
| 275 | 278 | >>> # ignored because they are out of bounds. |
| … | … | @@ -307,8 +310,7 @@ class TorrentFileStream: |
| 307 | 310 | max_piece_index = math.floor((torrent_size - 1) / piece_size) |
| 308 | 311 | if not min_piece_index <= piece_index <= max_piece_index: |
| 309 | 312 | 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}" | |
| 312 | 314 | ) |
| 313 | 315 | |
| 314 | 316 | # Find out which files we need to read from |
| … | … | @@ -321,7 +323,7 @@ class TorrentFileStream: |
| 321 | 323 | first_byte_index_of_piece, |
| 322 | 324 | last_byte_index_of_piece, |
| 323 | 325 | # Ensure we get the torrent path, not the file system path |
| 324 | content_path='', | |
| 326 | content_path="", | |
| 325 | 327 | ) |
| 326 | 328 | |
| 327 | 329 | # Find out where to start reading in the first relevant file |
| … | … | @@ -331,7 +333,7 @@ class TorrentFileStream: |
| 331 | 333 | seek_to = first_byte_index_of_piece - file_pos |
| 332 | 334 | else: |
| 333 | 335 | # 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="") | |
| 335 | 337 | file_pos = self.get_file_position(file) |
| 336 | 338 | seek_to = file.size - ((file_pos + file.size) % piece_size) |
| 337 | 339 | |
| … | … | @@ -389,7 +391,7 @@ class TorrentFileStream: |
| 389 | 391 | del self._open_files[old_filepath] |
| 390 | 392 | |
| 391 | 393 | try: |
| 392 | self._open_files[filepath] = open(filepath, 'rb') | |
| 394 | self._open_files[filepath] = open(filepath, "rb") | |
| 393 | 395 | except OSError as e: |
| 394 | 396 | raise error.ReadError(e.errno, filepath) |
| 395 | 397 | |
| … | … | @@ -435,7 +437,7 @@ class TorrentFileStream: |
| 435 | 437 | :raise ReadError: if file exists but is not readable |
| 436 | 438 | :raise VerifyFileSizeError: if file has unexpected size |
| 437 | 439 | """ |
| 438 | trailing_bytes = b'' | |
| 440 | trailing_bytes = b"" | |
| 439 | 441 | missing_pieces = _MissingPieces(torrent=self._torrent, stream=self) |
| 440 | 442 | skip_bytes = 0 |
| 441 | 443 | |
| … | … | @@ -467,7 +469,7 @@ class TorrentFileStream: |
| 467 | 469 | skip_bytes=skip_bytes, |
| 468 | 470 | oom_callback=oom_callback, |
| 469 | 471 | ) |
| 470 | trailing_bytes = b'' | |
| 472 | trailing_bytes = b"" | |
| 471 | 473 | piece_size = self._torrent.piece_size |
| 472 | 474 | for piece in pieces: |
| 473 | 475 | if len(piece) == piece_size: |
| … | … | @@ -478,7 +480,7 @@ class TorrentFileStream: |
| 478 | 480 | else: |
| 479 | 481 | # _debug(f'{file}: Faking {filepath}') |
| 480 | 482 | # We can't complete the current piece |
| 481 | trailing_bytes = b'' | |
| 483 | trailing_bytes = b"" | |
| 482 | 484 | # Opening file failed |
| 483 | 485 | items, skip_bytes = missing_pieces(file, content_path, reason=exception) |
| 484 | 486 | for item in items: |
| … | … | @@ -502,15 +504,15 @@ class TorrentFileStream: |
| 502 | 504 | |
| 503 | 505 | def iter_pieces(fh, prepend): |
| 504 | 506 | piece_size = self._torrent.piece_size |
| 505 | piece = b'' | |
| 507 | piece = b"" | |
| 506 | 508 | |
| 507 | 509 | # Iterate over pieces in `prepend`ed bytes, store incomplete piece |
| 508 | 510 | # in `piece` |
| 509 | 511 | for pos in range(0, len(prepend), piece_size): |
| 510 | piece = prepend[pos:pos + piece_size] | |
| 512 | piece = prepend[pos : pos + piece_size] | |
| 511 | 513 | if len(piece) == piece_size: |
| 512 | 514 | yield piece |
| 513 | piece = b'' | |
| 515 | piece = b"" | |
| 514 | 516 | |
| 515 | 517 | try: |
| 516 | 518 | # Fill incomplete piece with first bytes from `fh` |
| … | … | @@ -543,8 +545,10 @@ class TorrentFileStream: |
| 543 | 545 | while True: |
| 544 | 546 | try: |
| 545 | 547 | 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 | ) | |
| 548 | 552 | if oom_callback is None: |
| 549 | 553 | raise e |
| 550 | 554 | else: |
| … | … | @@ -597,7 +601,9 @@ class TorrentFileStream: |
| 597 | 601 | try: |
| 598 | 602 | stored_piece_hash = self._torrent.hashes[piece_index] |
| 599 | 603 | 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 | ) | |
| 601 | 607 | |
| 602 | 608 | generated_piece_hash = self.get_piece_hash(piece_index, content_path=content_path) |
| 603 | 609 | if generated_piece_hash is not None: |
| … | … | @@ -624,7 +630,7 @@ class _MissingPieces: |
| 624 | 630 | |
| 625 | 631 | # Figure out which subsequent files are affected by the missing last |
| 626 | 632 | # 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="") | |
| 628 | 634 | affected_files.remove(file) |
| 629 | 635 | # _debug(f'{affected_files=}') |
| 630 | 636 | |
| … | … | @@ -647,8 +653,7 @@ class _MissingPieces: |
| 647 | 653 | |
| 648 | 654 | # Stream index of the last byte of the last missing piece of `file` |
| 649 | 655 | 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 | |
| 652 | 657 | ) |
| 653 | 658 | |
| 654 | 659 | if next_file_end > next_piece_boundary_index: |
| … | … | @@ -725,7 +730,9 @@ class _MissingPieces: |
| 725 | 730 | # No such file |
| 726 | 731 | exceptions.append(error.ReadError(errno.ENOENT, bc_filepath)) |
| 727 | 732 | 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 | ) | |
| 729 | 736 | # if exceptions: |
| 730 | 737 | # _debug(f'bycatch: {exceptions[-1]!r}') |
| 731 | 738 | return exceptions |
| … | … | @@ -33,18 +33,20 @@ from . import _generate as generate |
| 33 | 33 | from . import _reuse as reuse |
| 34 | 34 | from . import _utils as utils |
| 35 | 35 | |
| 36 | _PACKAGE_NAME = __name__.split('.')[0] | |
| 36 | _PACKAGE_NAME = __name__.split(".")[0] | |
| 37 | 37 | |
| 38 | 38 | # os.sched_getaffinity() is only available on some Unix platforms. |
| 39 | 39 | try: |
| 40 | 40 | NCORES = len(os.sched_getaffinity(0)) |
| 41 | 41 | except AttributeError: |
| 42 | 42 | import multiprocessing |
| 43 | ||
| 43 | 44 | NCORES = multiprocessing.cpu_count() |
| 44 | 45 | |
| 45 | DEFAULT_TORRENT_NAME = 'UNNAMED TORRENT' | |
| 46 | DEFAULT_TORRENT_NAME = "UNNAMED TORRENT" | |
| 47 | ||
| 46 | 48 | |
| 47 | class Torrent(): | |
| 49 | class Torrent: | |
| 48 | 50 | """ |
| 49 | 51 | Torrent metainfo representation |
| 50 | 52 | |
| … | … | @@ -97,20 +99,37 @@ class Torrent(): |
| 97 | 99 | {'bar': ['x', 'y', 'z'], 'foo': 12} |
| 98 | 100 | """ |
| 99 | 101 | |
| 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 | ): | |
| 108 | 123 | self._path = None |
| 109 | 124 | 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 | } | |
| 114 | 133 | |
| 115 | 134 | self.piece_size_min = piece_size_min |
| 116 | 135 | self.piece_size_max = piece_size_max |
| … | … | @@ -150,8 +169,8 @@ class Torrent(): |
| 150 | 169 | |
| 151 | 170 | The ``info`` key is guaranteed to exist. |
| 152 | 171 | """ |
| 153 | if 'info' not in self._metainfo: | |
| 154 | self._metainfo['info'] = {} | |
| 172 | if "info" not in self._metainfo: | |
| 173 | self._metainfo["info"] = {} | |
| 155 | 174 | return self._metainfo |
| 156 | 175 | |
| 157 | 176 | @property |
| … | … | @@ -179,11 +198,12 @@ class Torrent(): |
| 179 | 198 | if value is None: |
| 180 | 199 | # Keep info about name and files, but forget where they are stored |
| 181 | 200 | self._path = None |
| 182 | self.metainfo['info'].pop('pieces', None) | |
| 201 | self.metainfo["info"].pop("pieces", None) | |
| 183 | 202 | else: |
| 184 | 203 | 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 | ) | |
| 187 | 207 | self._set_files(filepaths, basepath) |
| 188 | 208 | |
| 189 | 209 | @property |
| … | … | @@ -215,14 +235,15 @@ class Torrent(): |
| 215 | 235 | directory |
| 216 | 236 | :raises ValueError: if any file is not a :class:`File` object |
| 217 | 237 | """ |
| 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 | ) | |
| 226 | 247 | else: |
| 227 | 248 | files = () |
| 228 | 249 | return utils.Files(files, callback=self._files_changed) |
| … | … | @@ -233,12 +254,12 @@ class Torrent(): |
| 233 | 254 | @files.setter |
| 234 | 255 | def files(self, files): |
| 235 | 256 | if not isinstance(files, utils.Iterable): |
| 236 | raise ValueError(f'Not an Iterable: {files}') | |
| 257 | raise ValueError(f"Not an Iterable: {files}") | |
| 237 | 258 | for f in files: |
| 238 | 259 | if not isinstance(f, utils.File): |
| 239 | raise ValueError(f'Not a File object: {f}') | |
| 260 | raise ValueError(f"Not a File object: {f}") | |
| 240 | 261 | elif f.is_absolute(): |
| 241 | raise error.PathError(f, msg='Not a relative path') | |
| 262 | raise error.PathError(f, msg="Not a relative path") | |
| 242 | 263 | |
| 243 | 264 | if not files: |
| 244 | 265 | self._set_files(files=()) |
| … | … | @@ -248,8 +269,8 @@ class Torrent(): |
| 248 | 269 | try: |
| 249 | 270 | basepath = os.path.commonpath(files) |
| 250 | 271 | except ValueError: |
| 251 | basepath = '' | |
| 252 | if basepath == '': | |
| 272 | basepath = "" | |
| 273 | if basepath == "": | |
| 253 | 274 | raise error.CommonPathError(files) |
| 254 | 275 | self._set_files(files, pathlib.Path(basepath)) |
| 255 | 276 | |
| … | … | @@ -271,12 +292,14 @@ class Torrent(): |
| 271 | 292 | """ |
| 272 | 293 | filepaths = () |
| 273 | 294 | if self.path is not None: |
| 274 | if self.mode == 'singlefile': | |
| 295 | if self.mode == "singlefile": | |
| 275 | 296 | filepaths = (self.path,) |
| 276 | elif self.mode == 'multifile': | |
| 297 | elif self.mode == "multifile": | |
| 277 | 298 | 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 | ) | |
| 280 | 303 | return utils.Filepaths(filepaths, callback=self._filepaths_changed) |
| 281 | 304 | |
| 282 | 305 | def _filepaths_changed(self, filepaths): |
| … | … | @@ -285,7 +308,7 @@ class Torrent(): |
| 285 | 308 | @filepaths.setter |
| 286 | 309 | def filepaths(self, filepaths): |
| 287 | 310 | if not isinstance(filepaths, utils.Iterable): |
| 288 | raise ValueError(f'Not an Iterable: {filepaths}') | |
| 311 | raise ValueError(f"Not an Iterable: {filepaths}") | |
| 289 | 312 | |
| 290 | 313 | filepaths = utils.Filepaths(filepaths) # Resolve directories |
| 291 | 314 | if not filepaths: |
| … | … | @@ -295,14 +318,12 @@ class Torrent(): |
| 295 | 318 | # resolve symlinks so the user isn't confronted with unexpected |
| 296 | 319 | # paths in case of an error. |
| 297 | 320 | 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) | |
| 300 | 322 | try: |
| 301 | 323 | basepath = pathlib.Path(os.path.commonpath(filepaths_abs)) |
| 302 | 324 | except ValueError: |
| 303 | 325 | 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) | |
| 306 | 327 | self._set_files(filepaths, basepath) |
| 307 | 328 | |
| 308 | 329 | def _set_files(self, files, basepath=None): |
| … | … | @@ -314,6 +335,7 @@ class Torrent(): |
| 314 | 335 | :param basepath: path-like that all paths in `files` start with; may be |
| 315 | 336 | ``None`` if ``files`` is empty |
| 316 | 337 | """ |
| 338 | ||
| 317 | 339 | def abspath(p): |
| 318 | 340 | # Absolute path without resolved symlinks |
| 319 | 341 | if p.is_absolute(): |
| … | … | @@ -330,30 +352,35 @@ class Torrent(): |
| 330 | 352 | return pathlib.Path(abspath(p)).relative_to(abspath(basepath).parent) |
| 331 | 353 | |
| 332 | 354 | # 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"]) | |
| 335 | 357 | 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"]) | |
| 338 | 360 | 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 | ) | |
| 342 | 369 | |
| 343 | info = self.metainfo['info'] | |
| 370 | info = self.metainfo["info"] | |
| 344 | 371 | 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) | |
| 349 | 376 | elif len(files) == 1 and files[0] == basepath: |
| 350 | 377 | # There is only one file and it is not in a directory. |
| 351 | 378 | # 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) | |
| 357 | 384 | else: |
| 358 | 385 | if str(basepath) == os.curdir: |
| 359 | 386 | # Name of current working directory |
| … | … | @@ -371,13 +398,12 @@ class Torrent(): |
| 371 | 398 | |
| 372 | 399 | files_info = [] |
| 373 | 400 | 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) | |
| 381 | 407 | |
| 382 | 408 | # Set new path attribute if basepath exists |
| 383 | 409 | if basepath is not None and os.path.exists(basepath): |
| … | … | @@ -407,13 +433,13 @@ class Torrent(): |
| 407 | 433 | ``[!SEQ]`` matches any char not in ``SEQ`` |
| 408 | 434 | ========== ================================ |
| 409 | 435 | """ |
| 410 | return self._exclude['globs'] | |
| 436 | return self._exclude["globs"] | |
| 411 | 437 | |
| 412 | 438 | @exclude_globs.setter |
| 413 | 439 | def exclude_globs(self, value): |
| 414 | 440 | 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 | |
| 417 | 443 | |
| 418 | 444 | @property |
| 419 | 445 | def include_globs(self): |
| … | … | @@ -422,13 +448,13 @@ class Torrent(): |
| 422 | 448 | |
| 423 | 449 | See :attr:`exclude_globs`. |
| 424 | 450 | """ |
| 425 | return self._include['globs'] | |
| 451 | return self._include["globs"] | |
| 426 | 452 | |
| 427 | 453 | @include_globs.setter |
| 428 | 454 | def include_globs(self, value): |
| 429 | 455 | 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 | |
| 432 | 458 | |
| 433 | 459 | @property |
| 434 | 460 | def exclude_regexs(self): |
| … | … | @@ -442,13 +468,13 @@ class Torrent(): |
| 442 | 468 | |
| 443 | 469 | :raises re.error: if any regular expression is invalid |
| 444 | 470 | """ |
| 445 | return self._exclude['regexs'] | |
| 471 | return self._exclude["regexs"] | |
| 446 | 472 | |
| 447 | 473 | @exclude_regexs.setter |
| 448 | 474 | def exclude_regexs(self, value): |
| 449 | 475 | 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 | |
| 452 | 478 | |
| 453 | 479 | @property |
| 454 | 480 | def include_regexs(self): |
| … | … | @@ -457,13 +483,13 @@ class Torrent(): |
| 457 | 483 | |
| 458 | 484 | See :attr:`exclude_regexs`. |
| 459 | 485 | """ |
| 460 | return self._include['regexs'] | |
| 486 | return self._include["regexs"] | |
| 461 | 487 | |
| 462 | 488 | @include_regexs.setter |
| 463 | 489 | def include_regexs(self, value): |
| 464 | 490 | 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 | |
| 467 | 493 | |
| 468 | 494 | def _filters_changed(self, _): |
| 469 | 495 | """Callback for MonitoredLists in Torrent._exclude""" |
| … | … | @@ -494,7 +520,7 @@ class Torrent(): |
| 494 | 520 | 'foo.txt': File('Torrent/foo.txt', |
| 495 | 521 | size=123456)}} |
| 496 | 522 | """ |
| 497 | tree = {} # Complete directory tree | |
| 523 | tree = {} # Complete directory tree | |
| 498 | 524 | paths = (tuple(f.parts) for f in self.files) |
| 499 | 525 | for path in paths: |
| 500 | 526 | dirpath = path[:-1] # Path without filename |
| … | … | @@ -521,17 +547,17 @@ class Torrent(): |
| 521 | 547 | Setting this property sets or removes ``name`` in |
| 522 | 548 | :attr:`metainfo`\\ ``['info']``. |
| 523 | 549 | """ |
| 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) | |
| 527 | 553 | |
| 528 | 554 | @name.setter |
| 529 | 555 | def name(self, value): |
| 530 | 556 | if value is None: |
| 531 | self.metainfo['info'].pop('name', None) | |
| 557 | self.metainfo["info"].pop("name", None) | |
| 532 | 558 | self.name # Set default name |
| 533 | 559 | else: |
| 534 | self.metainfo['info']['name'] = str(value) | |
| 560 | self.metainfo["info"]["name"] = str(value) | |
| 535 | 561 | |
| 536 | 562 | @property |
| 537 | 563 | def mode(self): |
| … | … | @@ -540,19 +566,18 @@ class Torrent(): |
| 540 | 566 | ``multifile`` if it contains one or more files in a directory, or |
| 541 | 567 | ``None`` if no content is specified (i.e. :attr:`files` is empty). |
| 542 | 568 | """ |
| 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" | |
| 547 | 573 | |
| 548 | 574 | @property |
| 549 | 575 | def size(self): |
| 550 | 576 | """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"]) | |
| 556 | 581 | else: |
| 557 | 582 | return 0 |
| 558 | 583 | |
| … | … | @@ -573,22 +598,22 @@ class Torrent(): |
| 573 | 598 | elif isinstance(path, abc.Iterable): |
| 574 | 599 | path = tuple(str(part) for part in path) |
| 575 | 600 | 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": | |
| 580 | 605 | 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) | |
| 583 | 608 | if this_path == path: |
| 584 | 609 | # path points to file |
| 585 | return info['length'] | |
| 610 | return info["length"] | |
| 586 | 611 | elif utils.iterable_startswith(this_path, path): |
| 587 | 612 | # path points to directory |
| 588 | file_sizes.append(info['length']) | |
| 613 | file_sizes.append(info["length"]) | |
| 589 | 614 | if file_sizes: |
| 590 | 615 | 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") | |
| 592 | 617 | |
| 593 | 618 | @property |
| 594 | 619 | def piece_size(self): |
| … | … | @@ -603,13 +628,13 @@ class Torrent(): |
| 603 | 628 | Setting this property sets or removes ``piece length`` in |
| 604 | 629 | :attr:`metainfo`\\ ``['info']``. |
| 605 | 630 | """ |
| 606 | return self.metainfo['info'].get('piece length', 0) | |
| 631 | return self.metainfo["info"].get("piece length", 0) | |
| 607 | 632 | |
| 608 | 633 | @piece_size.setter |
| 609 | 634 | def piece_size(self, value): |
| 610 | 635 | if value is None: |
| 611 | 636 | if self.size <= 0: |
| 612 | self.metainfo['info'].pop('piece length', None) | |
| 637 | self.metainfo["info"].pop("piece length", None) | |
| 613 | 638 | return |
| 614 | 639 | else: |
| 615 | 640 | value = self.calculate_piece_size( |
| … | … | @@ -621,15 +646,15 @@ class Torrent(): |
| 621 | 646 | try: |
| 622 | 647 | piece_length = int(value) |
| 623 | 648 | 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}") | |
| 625 | 650 | else: |
| 626 | 651 | if not utils.is_divisible_by_16_kib(piece_length): |
| 627 | 652 | raise error.PieceSizeError(piece_length) |
| 628 | 653 | 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 | |
| 633 | 658 | |
| 634 | 659 | @property |
| 635 | 660 | def piece_size_min(self): |
| … | … | @@ -706,19 +731,17 @@ class Torrent(): |
| 706 | 731 | |
| 707 | 732 | :return: calculated piece size |
| 708 | 733 | """ |
| 709 | if size <= 2**30: # 1 GiB / 1024 pieces = 1 MiB max | |
| 734 | if size <= 2**30: # 1 GiB / 1024 pieces = 1 MiB max | |
| 710 | 735 | 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 | |
| 712 | 737 | 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 | |
| 714 | 739 | 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 | |
| 718 | 741 | 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 | |
| 720 | 743 | 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 | |
| 722 | 745 | pieces = size / 8192 |
| 723 | 746 | else: |
| 724 | 747 | pieces = size / 10240 |
| … | … | @@ -744,11 +767,10 @@ class Torrent(): |
| 744 | 767 | @property |
| 745 | 768 | def hashes(self): |
| 746 | 769 | """Tuple of SHA1 piece hashes as :class:`bytes`""" |
| 747 | hashes = self.metainfo['info'].get('pieces') | |
| 770 | hashes = self.metainfo["info"].get("pieces") | |
| 748 | 771 | if isinstance(hashes, (bytes, bytearray)): |
| 749 | 772 | # 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)) | |
| 752 | 774 | else: |
| 753 | 775 | return () |
| 754 | 776 | |
| … | … | @@ -783,8 +805,8 @@ class Torrent(): |
| 783 | 805 | :raises ValueError: if set to anything that isn't an iterable or a |
| 784 | 806 | string |
| 785 | 807 | """ |
| 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) | |
| 788 | 810 | flat_urls = tuple(url for tier in tiers for url in tier) |
| 789 | 811 | if announce is not None and announce not in flat_urls: |
| 790 | 812 | tiers.insert(0, [announce]) |
| … | … | @@ -797,24 +819,23 @@ class Torrent(): |
| 797 | 819 | if isinstance(value, abc.Iterable): |
| 798 | 820 | self._trackers_changed(utils.Trackers(value)) |
| 799 | 821 | 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}") | |
| 801 | 823 | |
| 802 | 824 | def _trackers_changed(self, trackers): |
| 803 | 825 | # Set "announce" to first tracker of first tier |
| 804 | 826 | try: |
| 805 | self.metainfo['announce'] = str(trackers[0][0]) | |
| 827 | self.metainfo["announce"] = str(trackers[0][0]) | |
| 806 | 828 | except IndexError: |
| 807 | self.metainfo.pop('announce', None) | |
| 829 | self.metainfo.pop("announce", None) | |
| 808 | 830 | |
| 809 | 831 | # Remove "announce-list" if there's only one tracker |
| 810 | 832 | if len(trackers.flat) <= 1: |
| 811 | self.metainfo.pop('announce-list', None) | |
| 833 | self.metainfo.pop("announce-list", None) | |
| 812 | 834 | 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"] = [] | |
| 815 | 837 | # 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) | |
| 818 | 839 | |
| 819 | 840 | @property |
| 820 | 841 | def webseeds(self): |
| … | … | @@ -831,8 +852,7 @@ class Torrent(): |
| 831 | 852 | :raises ValueError: if set to anything that isn't an iterable or a |
| 832 | 853 | string |
| 833 | 854 | """ |
| 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) | |
| 836 | 856 | |
| 837 | 857 | @webseeds.setter |
| 838 | 858 | def webseeds(self, value): |
| … | … | @@ -843,14 +863,14 @@ class Torrent(): |
| 843 | 863 | elif value is None: |
| 844 | 864 | urls = utils.URLs(()) |
| 845 | 865 | 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}") | |
| 847 | 867 | self._webseeds_changed(urls) |
| 848 | 868 | |
| 849 | 869 | def _webseeds_changed(self, webseeds): |
| 850 | 870 | if webseeds: |
| 851 | self.metainfo['url-list'] = [str(url) for url in webseeds] | |
| 871 | self.metainfo["url-list"] = [str(url) for url in webseeds] | |
| 852 | 872 | else: |
| 853 | self.metainfo.pop('url-list', None) | |
| 873 | self.metainfo.pop("url-list", None) | |
| 854 | 874 | |
| 855 | 875 | @property |
| 856 | 876 | def httpseeds(self): |
| … | … | @@ -867,8 +887,7 @@ class Torrent(): |
| 867 | 887 | :raises ValueError: if set to anything that isn't an iterable or a |
| 868 | 888 | string |
| 869 | 889 | """ |
| 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) | |
| 872 | 891 | |
| 873 | 892 | @httpseeds.setter |
| 874 | 893 | def httpseeds(self, value): |
| … | … | @@ -879,14 +898,14 @@ class Torrent(): |
| 879 | 898 | elif value is None: |
| 880 | 899 | urls = utils.URLs(()) |
| 881 | 900 | 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}") | |
| 883 | 902 | self._httpseeds_changed(urls) |
| 884 | 903 | |
| 885 | 904 | def _httpseeds_changed(self, httpseeds): |
| 886 | 905 | if httpseeds: |
| 887 | self.metainfo['httpseeds'] = [str(url) for url in httpseeds] | |
| 906 | self.metainfo["httpseeds"] = [str(url) for url in httpseeds] | |
| 888 | 907 | else: |
| 889 | self.metainfo.pop('httpseeds', None) | |
| 908 | self.metainfo.pop("httpseeds", None) | |
| 890 | 909 | |
| 891 | 910 | @property |
| 892 | 911 | def private(self): |
| … | … | @@ -897,17 +916,17 @@ class Torrent(): |
| 897 | 916 | :attr:`metainfo`\\ ``['info']``\\ ``['private']`` exists, ``None`` |
| 898 | 917 | otherwise. |
| 899 | 918 | """ |
| 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"]) | |
| 902 | 921 | else: |
| 903 | 922 | return None |
| 904 | 923 | |
| 905 | 924 | @private.setter |
| 906 | 925 | def private(self, value): |
| 907 | 926 | if value is None: |
| 908 | self.metainfo['info'].pop('private', None) | |
| 927 | self.metainfo["info"].pop("private", None) | |
| 909 | 928 | else: |
| 910 | self.metainfo['info']['private'] = bool(value) | |
| 929 | self.metainfo["info"]["private"] = bool(value) | |
| 911 | 930 | |
| 912 | 931 | @property |
| 913 | 932 | def comment(self): |
| … | … | @@ -916,14 +935,14 @@ class Torrent(): |
| 916 | 935 | |
| 917 | 936 | Setting this property sets or removes :attr:`metainfo`\\ ``['comment']``. |
| 918 | 937 | """ |
| 919 | return self.metainfo.get('comment', None) | |
| 938 | return self.metainfo.get("comment", None) | |
| 920 | 939 | |
| 921 | 940 | @comment.setter |
| 922 | 941 | def comment(self, value): |
| 923 | 942 | if value is not None: |
| 924 | self.metainfo['comment'] = str(value) | |
| 943 | self.metainfo["comment"] = str(value) | |
| 925 | 944 | else: |
| 926 | self.metainfo.pop('comment', None) | |
| 945 | self.metainfo.pop("comment", None) | |
| 927 | 946 | |
| 928 | 947 | @property |
| 929 | 948 | def creation_date(self): |
| … | … | @@ -936,20 +955,19 @@ class Torrent(): |
| 936 | 955 | Setting this property sets or removes |
| 937 | 956 | :attr:`metainfo`\\ ``['creation date']``. |
| 938 | 957 | """ |
| 939 | return self.metainfo.get('creation date', None) | |
| 958 | return self.metainfo.get("creation date", None) | |
| 940 | 959 | |
| 941 | 960 | @creation_date.setter |
| 942 | 961 | def creation_date(self, value): |
| 943 | 962 | if isinstance(value, (float, int)): |
| 944 | self.metainfo['creation date'] = datetime.fromtimestamp(value) | |
| 963 | self.metainfo["creation date"] = datetime.fromtimestamp(value) | |
| 945 | 964 | elif isinstance(value, datetime): |
| 946 | self.metainfo['creation date'] = value | |
| 965 | self.metainfo["creation date"] = value | |
| 947 | 966 | elif not value: |
| 948 | self.metainfo.pop('creation date', None) | |
| 967 | self.metainfo.pop("creation date", None) | |
| 949 | 968 | else: |
| 950 | 969 | 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}" | |
| 953 | 971 | ) |
| 954 | 972 | |
| 955 | 973 | @property |
| … | … | @@ -960,14 +978,14 @@ class Torrent(): |
| 960 | 978 | Setting this property sets or removes |
| 961 | 979 | :attr:`metainfo`\\ ``['created by']``. |
| 962 | 980 | """ |
| 963 | return self.metainfo.get('created by', None) | |
| 981 | return self.metainfo.get("created by", None) | |
| 964 | 982 | |
| 965 | 983 | @created_by.setter |
| 966 | 984 | def created_by(self, value): |
| 967 | 985 | if value is not None: |
| 968 | self.metainfo['created by'] = str(value) | |
| 986 | self.metainfo["created by"] = str(value) | |
| 969 | 987 | else: |
| 970 | self.metainfo.pop('created by', None) | |
| 988 | self.metainfo.pop("created by", None) | |
| 971 | 989 | |
| 972 | 990 | @property |
| 973 | 991 | def source(self): |
| … | … | @@ -977,14 +995,14 @@ class Torrent(): |
| 977 | 995 | Setting this property sets or removes |
| 978 | 996 | :attr:`metainfo`\\ ``['info']``\\ ``['created by']``. |
| 979 | 997 | """ |
| 980 | return self.metainfo['info'].get('source', None) | |
| 998 | return self.metainfo["info"].get("source", None) | |
| 981 | 999 | |
| 982 | 1000 | @source.setter |
| 983 | 1001 | def source(self, value): |
| 984 | 1002 | if value is not None: |
| 985 | self.metainfo['info']['source'] = str(value) | |
| 1003 | self.metainfo["info"]["source"] = str(value) | |
| 986 | 1004 | else: |
| 987 | self.metainfo['info'].pop('source', None) | |
| 1005 | self.metainfo["info"].pop("source", None) | |
| 988 | 1006 | |
| 989 | 1007 | @property |
| 990 | 1008 | def infohash(self): |
| … | … | @@ -998,7 +1016,7 @@ class Torrent(): |
| 998 | 1016 | # Try to calculate infohash |
| 999 | 1017 | self.validate() |
| 1000 | 1018 | try: |
| 1001 | info = utils.encode_dict(self.metainfo['info']) | |
| 1019 | info = utils.encode_dict(self.metainfo["info"]) | |
| 1002 | 1020 | except ValueError as e: |
| 1003 | 1021 | raise error.MetainfoError(e) |
| 1004 | 1022 | else: |
| … | … | @@ -1027,7 +1045,7 @@ class Torrent(): |
| 1027 | 1045 | :attr:`metainfo`\\ ``['info']``\\ ``['entropy']`` to a random integer. |
| 1028 | 1046 | Setting it to ``False`` removes that field. |
| 1029 | 1047 | """ |
| 1030 | return bool(self.metainfo['info'].get('entropy', False)) | |
| 1048 | return bool(self.metainfo["info"].get("entropy", False)) | |
| 1031 | 1049 | |
| 1032 | 1050 | @randomize_infohash.setter |
| 1033 | 1051 | def randomize_infohash(self, value): |
| … | … | @@ -1035,9 +1053,10 @@ class Torrent(): |
| 1035 | 1053 | # According to BEP0003 "Integers have no size limitation", but some |
| 1036 | 1054 | # parsers seem to have problems with large numbers. |
| 1037 | 1055 | 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)) | |
| 1039 | 1058 | else: |
| 1040 | self.metainfo['info'].pop('entropy', None) | |
| 1059 | self.metainfo["info"].pop("entropy", None) | |
| 1041 | 1060 | |
| 1042 | 1061 | @property |
| 1043 | 1062 | def is_ready(self): |
| … | … | @@ -1081,9 +1100,9 @@ class Torrent(): |
| 1081 | 1100 | otherwise |
| 1082 | 1101 | """ |
| 1083 | 1102 | if self.path is None: |
| 1084 | raise RuntimeError('generate() called with no path specified') | |
| 1103 | raise RuntimeError("generate() called with no path specified") | |
| 1085 | 1104 | 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") | |
| 1087 | 1106 | |
| 1088 | 1107 | hasher_threads = threads or NCORES |
| 1089 | 1108 | |
| … | … | @@ -1112,17 +1131,18 @@ class Torrent(): |
| 1112 | 1131 | |
| 1113 | 1132 | # Collect piece hashes |
| 1114 | 1133 | piece_hashes = collector.collect() |
| 1115 | concatenated_piece_hashes = b''.join(piece_hashes) | |
| 1134 | concatenated_piece_hashes = b"".join(piece_hashes) | |
| 1116 | 1135 | hashes_count = len(concatenated_piece_hashes) / 20 |
| 1117 | 1136 | if hashes_count == self.pieces: |
| 1118 | self.metainfo['info']['pieces'] = concatenated_piece_hashes | |
| 1137 | self.metainfo["info"]["pieces"] = concatenated_piece_hashes | |
| 1119 | 1138 | return True |
| 1120 | 1139 | elif hashes_count < self.pieces: |
| 1121 | 1140 | # Hashing was cancelled |
| 1122 | 1141 | return False |
| 1123 | 1142 | 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 | ) | |
| 1126 | 1146 | |
| 1127 | 1147 | def verify(self, path, threads=None, callback=None, interval=0): |
| 1128 | 1148 | """ |
| … | … | @@ -1185,13 +1205,15 @@ class Torrent(): |
| 1185 | 1205 | filepath = None |
| 1186 | 1206 | piece_hash = None |
| 1187 | 1207 | 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 | ) | |
| 1189 | 1211 | |
| 1190 | if self.mode == 'singlefile' and os.path.isdir(path): | |
| 1212 | if self.mode == "singlefile" and os.path.isdir(path): | |
| 1191 | 1213 | early_exception(error.VerifyIsDirectoryError(path)) |
| 1192 | 1214 | return False |
| 1193 | 1215 | |
| 1194 | elif self.mode == 'multifile' and not os.path.isdir(path): | |
| 1216 | elif self.mode == "multifile" and not os.path.isdir(path): | |
| 1195 | 1217 | early_exception(error.VerifyNotDirectoryError(path)) |
| 1196 | 1218 | return False |
| 1197 | 1219 | |
| … | … | @@ -1284,8 +1306,14 @@ class Torrent(): |
| 1284 | 1306 | fs_filepath = filepaths[file_index][0] |
| 1285 | 1307 | torrent_filepath = filepaths[file_index][1] |
| 1286 | 1308 | 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 | ) | |
| 1289 | 1317 | if return_value is not None: |
| 1290 | 1318 | return True |
| 1291 | 1319 | elif exception: |
| … | … | @@ -1300,7 +1328,7 @@ class Torrent(): |
| 1300 | 1328 | # for a directory if we expect one because we are iterating over files |
| 1301 | 1329 | # (filepaths), so the path "foo/bar/baz" will result in a ReadError if |
| 1302 | 1330 | # "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): | |
| 1304 | 1332 | exception = error.VerifyIsDirectoryError(path) |
| 1305 | 1333 | cancel(file_index=0, exception=exception) |
| 1306 | 1334 | return False |
| … | … | @@ -1348,91 +1376,123 @@ class Torrent(): |
| 1348 | 1376 | torrent file or magnet link |
| 1349 | 1377 | """ |
| 1350 | 1378 | md = self.metainfo |
| 1351 | info = md['info'] | |
| 1379 | info = md["info"] | |
| 1352 | 1380 | |
| 1353 | 1381 | # 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: | |
| 1368 | 1401 | raise error.MetainfoError("['info']['pieces'] is empty") |
| 1369 | 1402 | |
| 1370 | elif len(info['pieces']) % 20 != 0: | |
| 1403 | elif len(info["pieces"]) % 20 != 0: | |
| 1371 | 1404 | raise error.MetainfoError("length of ['info']['pieces'] is not divisible by 20") |
| 1372 | 1405 | |
| 1373 | elif 'length' in info and 'files' in info: | |
| 1406 | elif "length" in info and "files" in info: | |
| 1374 | 1407 | raise error.MetainfoError("['info'] includes both 'length' and 'files'") |
| 1375 | 1408 | |
| 1376 | elif 'length' in info: | |
| 1409 | elif "length" in info: | |
| 1377 | 1410 | # 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 | ) | |
| 1380 | 1415 | |
| 1381 | 1416 | # 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"]) | |
| 1384 | 1419 | 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 | ) | |
| 1386 | 1423 | |
| 1387 | 1424 | if self.path is not None: |
| 1388 | 1425 | # Check if filepath actually points to a file |
| 1389 | 1426 | 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 | ) | |
| 1391 | 1430 | |
| 1392 | 1431 | # Check if size matches |
| 1393 | 1432 | 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 | ) | |
| 1397 | 1438 | |
| 1398 | elif 'files' in info: | |
| 1439 | elif "files" in info: | |
| 1399 | 1440 | # 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,)) | |
| 1408 | 1457 | |
| 1409 | 1458 | # - validate() should ensure that ['info']['pieces'] is math.ceil(self.size / |
| 1410 | 1459 | # 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 | ) | |
| 1414 | 1464 | 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 | ) | |
| 1416 | 1468 | |
| 1417 | 1469 | if self.path is not None: |
| 1418 | 1470 | # Check if filepath actually points to a directory |
| 1419 | 1471 | 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 | ) | |
| 1421 | 1475 | |
| 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"])) | |
| 1424 | 1478 | |
| 1425 | 1479 | # Check if filepath exists and is a file |
| 1426 | 1480 | 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 | ) | |
| 1428 | 1484 | 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 | ) | |
| 1430 | 1488 | |
| 1431 | 1489 | # Check if sizes match |
| 1432 | 1490 | 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 | ) | |
| 1436 | 1496 | |
| 1437 | 1497 | else: |
| 1438 | 1498 | raise error.MetainfoError("Missing 'length' or 'files' in 'info'") |
| … | … | @@ -1504,7 +1564,7 @@ class Torrent(): |
| 1504 | 1564 | self.write_stream(content, validate=validate) |
| 1505 | 1565 | content.seek(0) |
| 1506 | 1566 | try: |
| 1507 | with open(filepath, 'wb') as f: | |
| 1567 | with open(filepath, "wb") as f: | |
| 1508 | 1568 | f.write(content.read()) |
| 1509 | 1569 | except OSError as e: |
| 1510 | 1570 | raise error.WriteError(e.errno, filepath) |
| … | … | @@ -1521,24 +1581,23 @@ class Torrent(): |
| 1521 | 1581 | |
| 1522 | 1582 | :raises MetainfoError: if :attr:`metainfo` is invalid |
| 1523 | 1583 | """ |
| 1524 | kwargs = {'xt': 'urn:btih:' + self.infohash} | |
| 1584 | kwargs = {"xt": "urn:btih:" + self.infohash} | |
| 1525 | 1585 | if name: |
| 1526 | kwargs['dn'] = self.name | |
| 1586 | kwargs["dn"] = self.name | |
| 1527 | 1587 | if size: |
| 1528 | kwargs['xl'] = self.size | |
| 1588 | kwargs["xl"] = self.size | |
| 1529 | 1589 | |
| 1530 | 1590 | if tracker: |
| 1531 | kwargs['tr'] = (self.trackers[0][0],) | |
| 1591 | kwargs["tr"] = (self.trackers[0][0],) | |
| 1532 | 1592 | 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) | |
| 1536 | 1594 | |
| 1537 | 1595 | if self.webseeds is not None: |
| 1538 | kwargs['ws'] = self.webseeds | |
| 1596 | kwargs["ws"] = self.webseeds | |
| 1539 | 1597 | |
| 1540 | 1598 | # Prevent circular import issues |
| 1541 | 1599 | from ._magnet import Magnet |
| 1600 | ||
| 1542 | 1601 | return Magnet(**kwargs) |
| 1543 | 1602 | |
| 1544 | 1603 | # Maximum number of bytes that read() reads from torrent files. This limit |
| … | … | @@ -1581,29 +1640,31 @@ class Torrent(): |
| 1581 | 1640 | |
| 1582 | 1641 | # Extract 'pieces' from metainfo before decoding because it's the |
| 1583 | 1642 | # 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") | |
| 1588 | 1649 | metainfo = utils.decode_dict(metainfo_enc) |
| 1589 | metainfo['info']['pieces'] = pieces | |
| 1650 | metainfo["info"]["pieces"] = pieces | |
| 1590 | 1651 | else: |
| 1591 | 1652 | metainfo = utils.decode_dict(metainfo_enc) |
| 1592 | 1653 | |
| 1593 | 1654 | # "info" must be a dictionary. If validation is not wanted, it's OK |
| 1594 | 1655 | # if it doesn't exist because the "metainfo" property will add it |
| 1595 | 1656 | # automatically. |
| 1596 | utils.assert_type(metainfo, ('info',), (dict,), must_exist=validate) | |
| 1657 | utils.assert_type(metainfo, ("info",), (dict,), must_exist=validate) | |
| 1597 | 1658 | |
| 1598 | 1659 | torrent = cls() |
| 1599 | 1660 | torrent._metainfo = metainfo |
| 1600 | 1661 | |
| 1601 | 1662 | # Convert "creation date" to datetime.datetime and "private" to |
| 1602 | 1663 | # 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"] | |
| 1607 | 1668 | |
| 1608 | 1669 | if validate: |
| 1609 | 1670 | torrent.validate() |
| … | … | @@ -1628,7 +1689,7 @@ class Torrent(): |
| 1628 | 1689 | :return: New :class:`Torrent` instance |
| 1629 | 1690 | """ |
| 1630 | 1691 | try: |
| 1631 | with open(filepath, 'rb') as f: | |
| 1692 | with open(filepath, "rb") as f: | |
| 1632 | 1693 | return cls.read_stream(f, validate=validate) |
| 1633 | 1694 | except (OSError, error.ReadError) as e: |
| 1634 | 1695 | raise error.ReadError(e.errno, filepath) |
| … | … | @@ -1638,6 +1699,7 @@ class Torrent(): |
| 1638 | 1699 | def copy(self): |
| 1639 | 1700 | """Create a new :class:`Torrent` instance with the same metainfo""" |
| 1640 | 1701 | from copy import deepcopy |
| 1702 | ||
| 1641 | 1703 | cp = type(self)() |
| 1642 | 1704 | cp._metainfo = deepcopy(self._metainfo) |
| 1643 | 1705 | return cp |
| … | … | @@ -1697,18 +1759,20 @@ class Torrent(): |
| 1697 | 1759 | :return: `True` if a matching torrent was found, `False` otherwise |
| 1698 | 1760 | """ |
| 1699 | 1761 | if not self.path: |
| 1700 | raise RuntimeError('reuse() called with no path specified') | |
| 1762 | raise RuntimeError("reuse() called with no path specified") | |
| 1701 | 1763 | elif not self.files: |
| 1702 | raise RuntimeError('reuse() called while file list is empty') | |
| 1764 | raise RuntimeError("reuse() called while file list is empty") | |
| 1703 | 1765 | |
| 1704 | 1766 | if isinstance(path, (str, pathlib.PurePath)): |
| 1705 | 1767 | paths = [path] |
| 1706 | 1768 | elif isinstance(path, abc.Iterable): |
| 1707 | 1769 | paths = tuple(path) |
| 1708 | 1770 | else: |
| 1709 | raise ValueError(f'Invalid path argument: {path!r}') | |
| 1771 | raise ValueError(f"Invalid path argument: {path!r}") | |
| 1710 | 1772 | |
| 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 | ) | |
| 1712 | 1776 | maybe_call_callback = reuse.ReuseCallback( |
| 1713 | 1777 | callback=callback, |
| 1714 | 1778 | interval=interval, |
| … | … | @@ -1723,7 +1787,7 @@ class Torrent(): |
| 1723 | 1787 | elif exception: |
| 1724 | 1788 | raise exception |
| 1725 | 1789 | else: |
| 1726 | raise RuntimeError('Both candidate_path and exception are None?!') | |
| 1790 | raise RuntimeError("Both candidate_path and exception are None?!") | |
| 1727 | 1791 | |
| 1728 | 1792 | except (error.ReadError, error.BdecodeError, error.MetainfoError) as e: |
| 1729 | 1793 | cancelled = maybe_call_callback(candidate_path, files_done, False, e) |
| … | … | @@ -1742,7 +1806,9 @@ class Torrent(): |
| 1742 | 1806 | reuse.copy(candidate, self) |
| 1743 | 1807 | return True |
| 1744 | 1808 | 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 | ) | |
| 1746 | 1812 | if cancelled is not None: |
| 1747 | 1813 | break |
| 1748 | 1814 | |
| … | … | @@ -1758,8 +1824,8 @@ class Torrent(): |
| 1758 | 1824 | args = [] |
| 1759 | 1825 | |
| 1760 | 1826 | 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") | |
| 1763 | 1829 | elif hasattr(type(self), param.name): |
| 1764 | 1830 | return getattr(type(self), param.name) |
| 1765 | 1831 | |
| … | … | @@ -1776,8 +1842,8 @@ class Torrent(): |
| 1776 | 1842 | # Keyword argument value is different from class default |
| 1777 | 1843 | and value != get_class_default(param.name) |
| 1778 | 1844 | ): |
| 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) + ")" | |
| 1781 | 1847 | |
| 1782 | 1848 | def __eq__(self, other): |
| 1783 | 1849 | if isinstance(other, type(self)): |
| … | … | @@ -20,7 +20,6 @@ import fnmatch |
| 20 | 20 | import functools |
| 21 | 21 | import http.client |
| 22 | 22 | import itertools |
| 23 | import math | |
| 24 | 23 | import os |
| 25 | 24 | import pathlib |
| 26 | 25 | import re |
| … | … | @@ -41,6 +40,7 @@ def is_divisible_by_16_kib(num): |
| 41 | 40 | return False |
| 42 | 41 | return num % 16384 == 0 |
| 43 | 42 | |
| 43 | ||
| 44 | 44 | def iterable_startswith(a, b): |
| 45 | 45 | a_len = len(a) |
| 46 | 46 | for i, b_item in enumerate(b): |
| … | … | @@ -51,6 +51,7 @@ def iterable_startswith(a, b): |
| 51 | 51 | return False |
| 52 | 52 | return True |
| 53 | 53 | |
| 54 | ||
| 54 | 55 | def flatten(items): |
| 55 | 56 | for item in items: |
| 56 | 57 | if isinstance(item, Iterable): |
| … | … | @@ -58,7 +59,10 @@ def flatten(items): |
| 58 | 59 | else: |
| 59 | 60 | yield item |
| 60 | 61 | |
| 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 | ||
| 62 | 66 | def is_md5sum(value): |
| 63 | 67 | return bool(_md5sum_regex.match(value)) |
| 64 | 68 | |
| … | … | @@ -70,13 +74,13 @@ def real_size(path): |
| 70 | 74 | Raise ReadError on failure |
| 71 | 75 | """ |
| 72 | 76 | if os.path.isdir(os.path.realpath(path)): |
| 77 | ||
| 73 | 78 | 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)) | |
| 76 | 80 | |
| 77 | 81 | size = 0 |
| 78 | 82 | walker = os.walk(path, followlinks=True, onerror=onerror) |
| 79 | for dirpath,dirnames,filenames in walker: | |
| 83 | for dirpath, dirnames, filenames in walker: | |
| 80 | 84 | for filename in filenames: |
| 81 | 85 | filepath = os.path.join(dirpath, filename) |
| 82 | 86 | size += os.path.getsize(filepath) |
| … | … | @@ -85,8 +89,8 @@ def real_size(path): |
| 85 | 89 | try: |
| 86 | 90 | return os.path.getsize(path) |
| 87 | 91 | 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 | ||
| 90 | 94 | |
| 91 | 95 | def list_files(path): |
| 92 | 96 | """ |
| … | … | @@ -95,6 +99,7 @@ def list_files(path): |
| 95 | 99 | Raise ReadError if `path` or any file or directory underneath it is not |
| 96 | 100 | readable. |
| 97 | 101 | """ |
| 102 | ||
| 98 | 103 | def assert_readable(path): |
| 99 | 104 | os_supports_effective_ids = os.access in os.supports_effective_ids |
| 100 | 105 | if not os.access(path, os.R_OK, effective_ids=os_supports_effective_ids): |
| … | … | @@ -104,9 +109,10 @@ def list_files(path): |
| 104 | 109 | assert_readable(path) |
| 105 | 110 | return [path] |
| 106 | 111 | else: |
| 112 | ||
| 107 | 113 | 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 | ||
| 110 | 116 | filepaths = [] |
| 111 | 117 | for dirpath, dirnames, filenames in os.walk(path, onerror=onerror): |
| 112 | 118 | for filename in filenames: |
| … | … | @@ -116,8 +122,7 @@ def list_files(path): |
| 116 | 122 | return list(sorted(filepaths, key=lambda fp: str(fp).casefold())) |
| 117 | 123 | |
| 118 | 124 | |
| 119 | def filter_files(items, getter=lambda f: f, hidden=True, empty=True, | |
| 120 | exclude=(), include=()): | |
| 125 | def filter_files(items, getter=lambda f: f, hidden=True, empty=True, exclude=(), include=()): | |
| 121 | 126 | """ |
| 122 | 127 | Return reduced copy of `items` |
| 123 | 128 | |
| … | … | @@ -131,25 +136,28 @@ def filter_files(items, getter=lambda f: f, hidden=True, empty=True, |
| 131 | 136 | hidden: Whether to include hidden files |
| 132 | 137 | empty: Whether to include empty files |
| 133 | 138 | """ |
| 139 | ||
| 134 | 140 | def is_hidden(path): |
| 135 | 141 | 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] == ".": | |
| 137 | 143 | return True |
| 138 | 144 | return False |
| 139 | 145 | |
| 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 | ): | |
| 145 | 153 | # 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 | ): | |
| 147 | 157 | 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 | ): | |
| 153 | 161 | return True |
| 154 | 162 | return False |
| 155 | 163 | |
| … | … | @@ -167,13 +175,11 @@ def filter_files(items, getter=lambda f: f, hidden=True, empty=True, |
| 167 | 175 | relpath_with_base = pathlib.Path(basepath.parent, filepath) |
| 168 | 176 | # Exclude hidden files and directories, but not hidden directories in |
| 169 | 177 | # `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 | ): | |
| 177 | 183 | continue |
| 178 | 184 | else: |
| 179 | 185 | items_filtered.append(item) |
| … | … | @@ -182,6 +188,7 @@ def filter_files(items, getter=lambda f: f, hidden=True, empty=True, |
| 182 | 188 | |
| 183 | 189 | class MonitoredList(collections.abc.MutableSequence): |
| 184 | 190 | """List with change callback""" |
| 191 | ||
| 185 | 192 | def __init__(self, items=(), callback=None, type=None): |
| 186 | 193 | self._items = [] |
| 187 | 194 | self._type = type |
| … | … | @@ -232,7 +239,7 @@ class MonitoredList(collections.abc.MutableSequence): |
| 232 | 239 | |
| 233 | 240 | def replace(self, items): |
| 234 | 241 | if not isinstance(items, Iterable): |
| 235 | raise ValueError(f'Not an iterable: {items!r}') | |
| 242 | raise ValueError(f"Not an iterable: {items!r}") | |
| 236 | 243 | # Don't clear list before we know all new values are valid |
| 237 | 244 | items = tuple(map(self._coerce, items)) |
| 238 | 245 | self._items.clear() |
| … | … | @@ -253,8 +260,7 @@ class MonitoredList(collections.abc.MutableSequence): |
| 253 | 260 | if isinstance(other, type(self)): |
| 254 | 261 | return frozenset(other._items) == frozenset(self._items) |
| 255 | 262 | 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) | |
| 258 | 264 | else: |
| 259 | 265 | return False |
| 260 | 266 | |
| … | … | @@ -302,12 +308,14 @@ class File(os.PathLike): |
| 302 | 308 | elif isinstance(path, collections.abc.Iterable): |
| 303 | 309 | self._path = pathlib.Path(*path) |
| 304 | 310 | 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 | ) | |
| 306 | 314 | |
| 307 | 315 | try: |
| 308 | 316 | self._size = int(size) |
| 309 | 317 | 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}") | |
| 311 | 319 | |
| 312 | 320 | @property |
| 313 | 321 | def size(self): |
| … | … | @@ -351,11 +359,12 @@ class File(os.PathLike): |
| 351 | 359 | return self._path <= other._path |
| 352 | 360 | |
| 353 | 361 | 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})" | |
| 355 | 363 | |
| 356 | 364 | |
| 357 | 365 | class Files(MonitoredList): |
| 358 | 366 | """Deduplicated list of :class:`Files` objects""" |
| 367 | ||
| 359 | 368 | def __init__(self, files, callback=None): |
| 360 | 369 | if isinstance(files, str): |
| 361 | 370 | files = (files,) |
| … | … | @@ -365,13 +374,14 @@ class Files(MonitoredList): |
| 365 | 374 | |
| 366 | 375 | def _coerce(self, value): |
| 367 | 376 | 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__})") | |
| 369 | 378 | else: |
| 370 | 379 | return value |
| 371 | 380 | |
| 372 | 381 | |
| 373 | 382 | class Filepath(type(pathlib.Path())): |
| 374 | 383 | """Path-like that makes relative paths equal to their absolute versions""" |
| 384 | ||
| 375 | 385 | @classmethod |
| 376 | 386 | def _realpath(cls, path): |
| 377 | 387 | if os.path.islink(path): |
| … | … | @@ -398,6 +408,7 @@ class Filepath(type(pathlib.Path())): |
| 398 | 408 | |
| 399 | 409 | class Filepaths(MonitoredList): |
| 400 | 410 | """Deduplicated list of :class:`Filepath` objects with change callback""" |
| 411 | ||
| 401 | 412 | def __init__(self, filepaths, callback=None): |
| 402 | 413 | if isinstance(filepaths, str): |
| 403 | 414 | filepaths = (filepaths,) |
| … | … | @@ -428,12 +439,11 @@ class Filepaths(MonitoredList): |
| 428 | 439 | try: |
| 429 | 440 | path_is_dir = path.is_dir() |
| 430 | 441 | 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)) | |
| 433 | 443 | if path_is_dir: |
| 434 | 444 | # Add files in directory recursively |
| 435 | 445 | with self._callback_disabled(): |
| 436 | for i,child in enumerate(sorted(path.iterdir())): | |
| 446 | for i, child in enumerate(sorted(path.iterdir())): | |
| 437 | 447 | self.insert(index + i, child) |
| 438 | 448 | if self._callback is not None: |
| 439 | 449 | self._callback(self) |
| … | … | @@ -453,9 +463,10 @@ def is_url(url): |
| 453 | 463 | return False |
| 454 | 464 | return True |
| 455 | 465 | |
| 466 | ||
| 456 | 467 | class URL(str): |
| 457 | 468 | def __new__(cls, s): |
| 458 | return super().__new__(cls, str(s).replace(' ', '+')) | |
| 469 | return super().__new__(cls, str(s).replace(" ", "+")) | |
| 459 | 470 | |
| 460 | 471 | def __init__(self, url): |
| 461 | 472 | if not is_url(url): |
| … | … | @@ -464,24 +475,41 @@ class URL(str): |
| 464 | 475 | self._parsed = urllib.parse.urlparse(url) |
| 465 | 476 | |
| 466 | 477 | @property |
| 467 | def scheme(self): return self._parsed.scheme | |
| 478 | def scheme(self): | |
| 479 | return self._parsed.scheme | |
| 480 | ||
| 468 | 481 | @property |
| 469 | def netloc(self): return self._parsed.netloc | |
| 482 | def netloc(self): | |
| 483 | return self._parsed.netloc | |
| 484 | ||
| 470 | 485 | @property |
| 471 | def hostname(self): return self._parsed.hostname | |
| 486 | def hostname(self): | |
| 487 | return self._parsed.hostname | |
| 488 | ||
| 472 | 489 | @property |
| 473 | def port(self): return self._parsed.port | |
| 490 | def port(self): | |
| 491 | return self._parsed.port | |
| 492 | ||
| 474 | 493 | @property |
| 475 | def path(self): return self._parsed.path | |
| 494 | def path(self): | |
| 495 | return self._parsed.path | |
| 496 | ||
| 476 | 497 | @property |
| 477 | def params(self): return self._parsed.params | |
| 498 | def params(self): | |
| 499 | return self._parsed.params | |
| 500 | ||
| 478 | 501 | @property |
| 479 | def query(self): return self._parsed.query | |
| 502 | def query(self): | |
| 503 | return self._parsed.query | |
| 504 | ||
| 480 | 505 | @property |
| 481 | def fragment(self): return self._parsed.fragment | |
| 506 | def fragment(self): | |
| 507 | return self._parsed.fragment | |
| 508 | ||
| 482 | 509 | |
| 483 | 510 | class URLs(MonitoredList): |
| 484 | 511 | """Auto-flattening list of `:class:URL` objects with change callback""" |
| 512 | ||
| 485 | 513 | def __init__(self, urls, callback=None, _get_known_urls=lambda: ()): |
| 486 | 514 | self._get_known_urls = _get_known_urls |
| 487 | 515 | if isinstance(urls, str): |
| … | … | @@ -502,6 +530,7 @@ class URLs(MonitoredList): |
| 502 | 530 | |
| 503 | 531 | class Trackers(collections.abc.MutableSequence): |
| 504 | 532 | """List of :class:`URLs` instances with change callback""" |
| 533 | ||
| 505 | 534 | def __init__(self, tiers, callback=None): |
| 506 | 535 | self._callback = None |
| 507 | 536 | self._tiers = [] |
| … | … | @@ -511,7 +540,7 @@ class Trackers(collections.abc.MutableSequence): |
| 511 | 540 | for urls in tiers: |
| 512 | 541 | self.append(urls) |
| 513 | 542 | 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}") | |
| 515 | 544 | self._callback = callback |
| 516 | 545 | |
| 517 | 546 | @property |
| … | … | @@ -537,8 +566,11 @@ class Trackers(collections.abc.MutableSequence): |
| 537 | 566 | return self._tiers[index] |
| 538 | 567 | |
| 539 | 568 | 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 | ) | |
| 542 | 574 | if len(tier) > 0 and tier not in self._tiers: |
| 543 | 575 | self._tiers[index] = tier |
| 544 | 576 | if self._callback is not None: |
| … | … | @@ -550,8 +582,11 @@ class Trackers(collections.abc.MutableSequence): |
| 550 | 582 | self._callback(self) |
| 551 | 583 | |
| 552 | 584 | 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 | ) | |
| 555 | 590 | if len(tier) > 0 and tier not in self._tiers: |
| 556 | 591 | self._tiers.insert(index, tier) |
| 557 | 592 | if self._callback is not None: |
| … | … | @@ -559,7 +594,7 @@ class Trackers(collections.abc.MutableSequence): |
| 559 | 594 | |
| 560 | 595 | def replace(self, tiers): |
| 561 | 596 | if not isinstance(tiers, Iterable): |
| 562 | raise ValueError(f'Not an iterable: {tiers!r}') | |
| 597 | raise ValueError(f"Not an iterable: {tiers!r}") | |
| 563 | 598 | with self._callback_disabled(): |
| 564 | 599 | self._tiers.clear() |
| 565 | 600 | for urls in tiers: |
| … | … | @@ -592,7 +627,7 @@ class Trackers(collections.abc.MutableSequence): |
| 592 | 627 | elif isinstance(other, collections.abc.Iterable): |
| 593 | 628 | other_tiers = other |
| 594 | 629 | 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): | |
| 596 | 631 | if tier1 is None: |
| 597 | 632 | tier1 = [] |
| 598 | 633 | if isinstance(x, str) and len(x) > 1: |
| … | … | @@ -620,11 +655,12 @@ def download(url, timeout=60): |
| 620 | 655 | :return: the downloaded data |
| 621 | 656 | """ |
| 622 | 657 | 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://"): | |
| 625 | 660 | return download_http(url, timeout=timeout) |
| 626 | 661 | else: |
| 627 | raise error.ConnectionError(url, 'Unsupported protocol') | |
| 662 | raise error.ConnectionError(url, "Unsupported protocol") | |
| 663 | ||
| 628 | 664 | |
| 629 | 665 | def download_http(url, timeout=60): |
| 630 | 666 | try: |
| … | … | @@ -633,16 +669,14 @@ def download_http(url, timeout=60): |
| 633 | 669 | try: |
| 634 | 670 | msg = e.args[0].strerror |
| 635 | 671 | 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" | |
| 639 | 673 | raise error.ConnectionError(url, msg) |
| 640 | 674 | except socket.timeout: |
| 641 | raise error.ConnectionError(url, 'Timed out') | |
| 675 | raise error.ConnectionError(url, "Timed out") | |
| 642 | 676 | 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") | |
| 646 | 680 | else: |
| 647 | 681 | return response |
| 648 | 682 | |
| … | … | @@ -659,6 +693,7 @@ class Iterable(abc.ABC): |
| 659 | 693 | |
| 660 | 694 | isinstance(x, collections.abc.Iterable) and not isinstance(x, str) |
| 661 | 695 | """ |
| 696 | ||
| 662 | 697 | @classmethod |
| 663 | 698 | def __subclasshook__(cls, C): |
| 664 | 699 | if cls is Iterable: |
| … | … | @@ -677,6 +712,7 @@ def key_exists_in_list_or_dict(key, lst_or_dct): |
| 677 | 712 | return True |
| 678 | 713 | return False |
| 679 | 714 | |
| 715 | ||
| 680 | 716 | def assert_type(obj, keys, exp_types, must_exist=True, check=None): |
| 681 | 717 | """ |
| 682 | 718 | 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): |
| 704 | 740 | break |
| 705 | 741 | keychain.append(key) |
| 706 | 742 | |
| 707 | keychain_str = ''.join(f'[{key!r}]' for key in keychain) | |
| 743 | keychain_str = "".join(f"[{key!r}]" for key in keychain) | |
| 708 | 744 | key = keys.pop(0) |
| 709 | 745 | |
| 710 | 746 | if not key_exists_in_list_or_dict(key, obj): |
| 711 | 747 | if must_exist: |
| 712 | 748 | 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}") | |
| 714 | 750 | else: |
| 715 | raise error.MetainfoError(f'Missing {key!r}') | |
| 751 | raise error.MetainfoError(f"Missing {key!r}") | |
| 716 | 752 | |
| 717 | 753 | elif not isinstance(obj[key], exp_types): |
| 718 | 754 | 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__ | |
| 721 | 757 | 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) | |
| 723 | 759 | 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 | ) | |
| 726 | 763 | |
| 727 | 764 | elif check is not None and not check(obj[key]): |
| 728 | 765 | raise error.MetainfoError(f"{keychain_str}[{key!r}] is invalid: {obj[key]!r}") |
| … | … | @@ -732,7 +769,7 @@ def decode_value(value): |
| 732 | 769 | if isinstance(value, collections.abc.ByteString): |
| 733 | 770 | # WARNING: Torrents can contain binary data (e.g. "pieces" field). You |
| 734 | 771 | # 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") | |
| 736 | 773 | elif isinstance(value, collections.abc.Sequence): |
| 737 | 774 | return decode_list(value) |
| 738 | 775 | elif isinstance(value, collections.abc.Mapping): |
| … | … | @@ -740,15 +777,17 @@ def decode_value(value): |
| 740 | 777 | else: |
| 741 | 778 | return value |
| 742 | 779 | |
| 780 | ||
| 743 | 781 | def decode_list(lst): |
| 744 | 782 | lst_dec = [] |
| 745 | 783 | for value in lst: |
| 746 | 784 | lst_dec.append(decode_value(value)) |
| 747 | 785 | return lst_dec |
| 748 | 786 | |
| 787 | ||
| 749 | 788 | def decode_dict(dct): |
| 750 | 789 | dct_dec = {} |
| 751 | for key,value in dct.items(): | |
| 790 | for key, value in dct.items(): | |
| 752 | 791 | value_dec = decode_value(value) |
| 753 | 792 | key_dec = decode_value(key) |
| 754 | 793 | dct_dec[key_dec] = value_dec |
| … | … | @@ -759,30 +798,33 @@ def encode_value(value): |
| 759 | 798 | if type(value) in ENCODE_ALLOWED_TYPES: |
| 760 | 799 | return value |
| 761 | 800 | else: |
| 762 | for cls,converter in ENCODE_CONVERTERS.items(): | |
| 801 | for cls, converter in ENCODE_CONVERTERS.items(): | |
| 763 | 802 | if isinstance(value, cls): |
| 764 | 803 | return converter(value) |
| 765 | raise ValueError(f'Invalid value: {value!r}') | |
| 804 | raise ValueError(f"Invalid value: {value!r}") | |
| 805 | ||
| 766 | 806 | |
| 767 | 807 | def encode_list(lst): |
| 768 | 808 | lst_enc = [] |
| 769 | for i,value in enumerate(lst): | |
| 809 | for i, value in enumerate(lst): | |
| 770 | 810 | lst_enc.append(encode_value(value)) |
| 771 | 811 | return lst_enc |
| 772 | 812 | |
| 813 | ||
| 773 | 814 | def encode_dict(dct): |
| 774 | 815 | dct_enc = collections.OrderedDict() |
| 775 | for key,value in sorted(dct.items()): | |
| 816 | for key, value in sorted(dct.items()): | |
| 776 | 817 | 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") | |
| 779 | 820 | value_enc = encode_value(value) |
| 780 | 821 | dct_enc[key_enc] = value_enc |
| 781 | 822 | return dct_enc |
| 782 | 823 | |
| 824 | ||
| 783 | 825 | ENCODE_ALLOWED_TYPES = (bytes, int) |
| 784 | 826 | ENCODE_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"), | |
| 786 | 828 | float: int, |
| 787 | 829 | bool: int, |
| 788 | 830 | collections.abc.ByteString: bytes, |
| … | … | @@ -0,0 +1,189 @@ |
| 1 | [build-system] | |
| 2 | requires = ["setuptools"] | |
| 3 | build-backend = "setuptools.build_meta" | |
| 4 | ||
| 5 | [project] | |
| 6 | name = "plugin.video.jacktook" | |
| 7 | version = "1.15.0" | |
| 8 | description = "Kodi addon for searching and streaming torrents via debrid services" | |
| 9 | requires-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) | |
| 17 | target-version = "py37" | |
| 18 | line-length = 100 | |
| 19 | indent-width = 4 | |
| 20 | ||
| 21 | # Files/directories to exclude | |
| 22 | exclude = [ | |
| 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 | |
| 41 | quote-style = "double" | |
| 42 | indent-style = "space" | |
| 43 | line-ending = "auto" | |
| 44 | skip-magic-trailing-comma = false | |
| 45 | ||
| 46 | # ── Linter ──────────────────────────────────── | |
| 47 | [tool.ruff.lint] | |
| 48 | # Start with a pragmatic set of rules — expand as needed | |
| 49 | select = [ | |
| 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 | |
| 64 | ignore = [ | |
| 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] | |
| 98 | known-first-party = ["lib"] | |
| 99 | known-third-party = ["xbmc", "xbmcgui", "xbmcplugin", "xbmcaddon", "xbmcvfs"] | |
| 100 | force-single-line = false | |
| 101 | order-by-type = true | |
| 102 | ||
| 103 | # ── pydocstyle ──────────────────────────────── | |
| 104 | [tool.ruff.lint.pydocstyle] | |
| 105 | convention = "google" | |
| 106 | ||
| 107 | # ── pycodestyle ─────────────────────────────── | |
| 108 | [tool.ruff.lint.pycodestyle] | |
| 109 | max-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] | |
| 125 | python_version = "3.13" | |
| 126 | strict_optional = true | |
| 127 | warn_unused_ignores = true | |
| 128 | warn_redundant_casts = true | |
| 129 | warn_return_any = true | |
| 130 | no_implicit_optional = true | |
| 131 | show_error_codes = true | |
| 132 | ignore_missing_imports = true | |
| 133 | ||
| 134 | # Exclude test/script/doc directories from initial file discovery | |
| 135 | exclude = [ | |
| 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]] | |
| 147 | module = "lib.vendor.*" | |
| 148 | follow_imports = "skip" | |
| 149 | ignore_errors = true | |
| 150 | ||
| 151 | [[tool.mypy.overrides]] | |
| 152 | module = "lib.utils.parsers.xmltodict" | |
| 153 | follow_imports = "skip" | |
| 154 | ignore_errors = true | |
| 155 | ||
| 156 | [[tool.mypy.overrides]] | |
| 157 | module = "lib.api.tmdbv3api.*" | |
| 158 | follow_imports = "skip" | |
| 159 | ignore_errors = true | |
| 160 | ||
| 161 | [tool.pytest.ini_options] | |
| 162 | minversion = "7.0" | |
| 163 | addopts = [ | |
| 164 | "-q", | |
| 165 | "--tb=short", | |
| 166 | ] | |
| 167 | testpaths = ["tests"] | |
| 168 | python_files = ["test_*.py", "verify_*.py"] | |
| 169 | filterwarnings = [ | |
| 170 | "ignore::DeprecationWarning:lib.db.cached:413", | |
| 171 | ] | |
| 172 | ||
| 173 | # ────────────────────────────────────────────── | |
| 174 | # Coverage — pytest-cov | |
| 175 | # ────────────────────────────────────────────── | |
| 176 | ||
| 177 | [tool.coverage.run] | |
| 178 | source = ["lib"] | |
| 179 | omit = [ | |
| 180 | "tests/*", | |
| 181 | "scripts/*", | |
| 182 | "*/__pycache__/*", | |
| 183 | ] | |
| 184 | ||
| 185 | [tool.coverage.report] | |
| 186 | show_missing = true | |
| 187 | skip_covered = true | |
| 188 | skip_empty = true | |
| 189 | fail_under = 0 |
| … | … | @@ -312,8 +312,8 @@ msgid "Unknown" |
| 312 | 312 | msgstr "" |
| 313 | 313 | |
| 314 | 314 | msgctxt "#30700" |
| 315 | msgid "Buffer and play" | |
| 316 | msgstr "" | |
| 315 | msgid "Play Torrent" | |
| 316 | msgstr "Play Torrent" | |
| 317 | 317 | |
| 318 | 318 | msgctxt "#30701" |
| 319 | 319 | msgid "Stop downloading" |
| … | … | @@ -431,6 +431,58 @@ msgctxt "#30939" |
| 431 | 431 | msgid "Auto-resume from last position without asking" |
| 432 | 432 | msgstr "" |
| 433 | 433 | |
| 434 | msgctxt "#30940" | |
| 435 | msgid "Use Percentage Trigger" | |
| 436 | msgstr "" | |
| 437 | ||
| 438 | msgctxt "#30941" | |
| 439 | msgid "Show Dialog at Playback Percentage" | |
| 440 | msgstr "" | |
| 441 | ||
| 442 | msgctxt "#30942" | |
| 443 | msgid "Trigger the next-episode dialog based on percentage of video played instead of seconds remaining." | |
| 444 | msgstr "" | |
| 445 | ||
| 446 | msgctxt "#30943" | |
| 447 | msgid "The percentage at which the next-episode dialog appears (e.g. 95 means 95% of the video)." | |
| 448 | msgstr "" | |
| 449 | ||
| 450 | msgctxt "#30944" | |
| 451 | msgid "Auto-Play Timeout" | |
| 452 | msgstr "" | |
| 453 | ||
| 454 | msgctxt "#30945" | |
| 455 | msgid "Seconds before auto-playing next episode. 0 = disabled, dialog waits for user input." | |
| 456 | msgstr "" | |
| 457 | ||
| 458 | msgctxt "#30946" | |
| 459 | msgid "Offcloud Configuration" | |
| 460 | msgstr "Offcloud Configuration" | |
| 461 | ||
| 462 | msgctxt "#30947" | |
| 463 | msgid "Enable Offcloud cached torrent and cloud transfer support." | |
| 464 | msgstr "Enable Offcloud cached torrent and cloud transfer support." | |
| 465 | ||
| 466 | msgctxt "#30948" | |
| 467 | msgid "Your Offcloud account username/email." | |
| 468 | msgstr "Your Offcloud account username/email." | |
| 469 | ||
| 470 | msgctxt "#30949" | |
| 471 | msgid "Offcloud API key from Account API Key settings." | |
| 472 | msgstr "Offcloud API key from Account API Key settings." | |
| 473 | ||
| 474 | msgctxt "#30950" | |
| 475 | msgid "View Offcloud account information." | |
| 476 | msgstr "View Offcloud account information." | |
| 477 | ||
| 478 | msgctxt "#30951" | |
| 479 | msgid "Authorize add-on to access Offcloud account." | |
| 480 | msgstr "Authorize add-on to access Offcloud account." | |
| 481 | ||
| 482 | msgctxt "#30952" | |
| 483 | msgid "Revoke Offcloud account access." | |
| 484 | msgstr "Revoke Offcloud account access." | |
| 485 | ||
| 434 | 486 | msgctxt "#30728" |
| 435 | 487 | msgid "Seeds" |
| 436 | 488 | msgstr "" |
| … | … | @@ -459,6 +511,10 @@ msgctxt "#30734" |
| 459 | 511 | msgid "Enable" |
| 460 | 512 | msgstr "" |
| 461 | 513 | |
| 514 | msgctxt "#30735" | |
| 515 | msgid "Still Watching? Threshold" | |
| 516 | msgstr "" | |
| 517 | ||
| 462 | 518 | msgctxt "#30736" |
| 463 | 519 | msgid "Subtitle Language" |
| 464 | 520 | msgstr "" |
| … | … | @@ -607,6 +663,10 @@ msgctxt "#30772" |
| 607 | 663 | msgid "Zilean Host" |
| 608 | 664 | msgstr "" |
| 609 | 665 | |
| 666 | msgctxt "#30773" | |
| 667 | msgid "Number of consecutive auto-played episodes before asking 'Still Watching?'. Set to 0 to disable." | |
| 668 | msgstr "" | |
| 669 | ||
| 610 | 670 | msgctxt "#30774" |
| 611 | 671 | msgid "Zilean Timeout" |
| 612 | 672 | msgstr "" |
| … | … | @@ -1557,7 +1617,7 @@ msgstr "" |
| 1557 | 1617 | |
| 1558 | 1618 | |
| 1559 | 1619 | msgctxt "#90022" |
| 1560 | msgid "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:" | |
| 1620 | msgid "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." | |
| 1561 | 1621 | msgstr "" |
| 1562 | 1622 | |
| 1563 | 1623 | msgctxt "#90023" |
| … | … | @@ -1999,21 +2059,37 @@ msgctxt "#90144" |
| 1999 | 2059 | msgid "Could not resolve playback URL" |
| 2000 | 2060 | msgstr "Could not resolve playback URL" |
| 2001 | 2061 | |
| 2002 | msgctxt "#90145" | |
| 2003 | msgid "Precaching Enabled" | |
| 2004 | msgstr "Precaching Enabled" | |
| 2062 | msgctxt "#90171" | |
| 2063 | msgid "Autoscrape Next Episode" | |
| 2064 | msgstr "Autoscrape Next Episode" | |
| 2065 | ||
| 2066 | msgctxt "#90172" | |
| 2067 | msgid "Pre-resolve the next episode's playback URL in the background while watching." | |
| 2068 | msgstr "Pre-resolve the next episode's playback URL in the background while watching." | |
| 2069 | ||
| 2070 | msgctxt "#90173" | |
| 2071 | msgid "Autoscrape Threshold" | |
| 2072 | msgstr "Autoscrape Threshold" | |
| 2073 | ||
| 2074 | msgctxt "#90174" | |
| 2075 | msgid "Percentage of playback to trigger background pre-resolution." | |
| 2076 | msgstr "Percentage of playback to trigger background pre-resolution." | |
| 2077 | ||
| 2078 | msgctxt "#90175" | |
| 2079 | msgid "Autoscrape Episode Count" | |
| 2080 | msgstr "Autoscrape Episode Count" | |
| 2005 | 2081 | |
| 2006 | msgctxt "#90146" | |
| 2007 | msgid "Enable or disable precaching of next episodes." | |
| 2008 | msgstr "Enable or disable precaching of next episodes." | |
| 2082 | msgctxt "#90176" | |
| 2083 | msgid "Number of upcoming episodes to pre-resolve." | |
| 2084 | msgstr "Number of upcoming episodes to pre-resolve." | |
| 2009 | 2085 | |
| 2010 | msgctxt "#90147" | |
| 2011 | msgid "Precaching Episode Count" | |
| 2012 | msgstr "Precaching Episode Count" | |
| 2086 | msgctxt "#90177" | |
| 2087 | msgid "Autoscrape Cache TTL" | |
| 2088 | msgstr "Autoscrape Cache TTL" | |
| 2013 | 2089 | |
| 2014 | msgctxt "#90148" | |
| 2015 | msgid "Number of next episodes to precache." | |
| 2016 | msgstr "Number of next episodes to precache." | |
| 2090 | msgctxt "#90178" | |
| 2091 | msgid "Hours to keep resolved playback data in cache." | |
| 2092 | msgstr "Hours to keep resolved playback data in cache." | |
| 2017 | 2093 | |
| 2018 | 2094 | msgctxt "#30901" |
| 2019 | 2095 | msgid "Show Only Stremio Catalogs" |
| … | … | @@ -2563,6 +2639,10 @@ msgctxt "#90298" |
| 2563 | 2639 | msgid "Liked Lists" |
| 2564 | 2640 | msgstr "Liked Lists" |
| 2565 | 2641 | |
| 2642 | msgctxt "#90299" | |
| 2643 | msgid "No metadata found for this torrent" | |
| 2644 | msgstr "No metadata found for this torrent" | |
| 2645 | ||
| 2566 | 2646 | msgctxt "#90331" |
| 2567 | 2647 | msgid "Stremio Addons" |
| 2568 | 2648 | msgstr "Stremio Addons" |
| … | … | @@ -4016,12 +4096,16 @@ msgid "Invalid episode" |
| 4016 | 4096 | msgstr "Invalid episode" |
| 4017 | 4097 | |
| 4018 | 4098 | msgctxt "#90740" |
| 4019 | msgid "Edit current title" | |
| 4020 | msgstr "Edit current title" | |
| 4099 | msgid "Search with current title" | |
| 4100 | msgstr "Search with current title" | |
| 4021 | 4101 | |
| 4022 | 4102 | msgctxt "#90741" |
| 4023 | msgid "Use original title" | |
| 4024 | msgstr "Use original title" | |
| 4103 | msgid "Search with original title" | |
| 4104 | msgstr "Search with original title" | |
| 4105 | ||
| 4106 | msgctxt "#90773" | |
| 4107 | msgid "Search full show with title + year" | |
| 4108 | msgstr "Search full show with title + year" | |
| 4025 | 4109 | |
| 4026 | 4110 | msgctxt "#90744" |
| 4027 | 4111 | msgid "Upload Subtitle" |
| … | … | @@ -4066,3 +4150,375 @@ msgstr "English only" |
| 4066 | 4150 | msgctxt "#90754" |
| 4067 | 4151 | msgid "Localized first" |
| 4068 | 4152 | msgstr "Localized first" |
| 4153 | ||
| 4154 | msgctxt "#90755" | |
| 4155 | msgid "Manage Sources" | |
| 4156 | msgstr "Manage Sources" | |
| 4157 | ||
| 4158 | msgctxt "#90756" | |
| 4159 | msgid "No sources available" | |
| 4160 | msgstr "No sources available" | |
| 4161 | ||
| 4162 | msgctxt "#90179" | |
| 4163 | msgid "Stinger Notifications" | |
| 4164 | msgstr "Stinger Notifications" | |
| 4165 | ||
| 4166 | msgctxt "#90180" | |
| 4167 | msgid "Show a notification when a movie has post-credit scenes." | |
| 4168 | msgstr "Show a notification when a movie has post-credit scenes." | |
| 4169 | ||
| 4170 | msgctxt "#90181" | |
| 4171 | msgid "Stinger Notification Time" | |
| 4172 | msgstr "Stinger Notification Time" | |
| 4173 | ||
| 4174 | msgctxt "#90182" | |
| 4175 | msgid "Seconds before the end to show the stinger notification." | |
| 4176 | msgstr "Seconds before the end to show the stinger notification." | |
| 4177 | ||
| 4178 | msgctxt "#90183" | |
| 4179 | msgid "Stay tuned after the credits!" | |
| 4180 | msgstr "Stay tuned after the credits!" | |
| 4181 | ||
| 4182 | msgctxt "#90757" | |
| 4183 | msgid "Image Resolution" | |
| 4184 | msgstr "Image Resolution" | |
| 4185 | ||
| 4186 | msgctxt "#90758" | |
| 4187 | msgid "Low" | |
| 4188 | msgstr "Low" | |
| 4189 | ||
| 4190 | msgctxt "#90759" | |
| 4191 | msgid "Medium" | |
| 4192 | msgstr "Medium" | |
| 4193 | ||
| 4194 | msgctxt "#90760" | |
| 4195 | msgid "High" | |
| 4196 | msgstr "High" | |
| 4197 | ||
| 4198 | msgctxt "#90761" | |
| 4199 | msgid "Original" | |
| 4200 | msgstr "Original" | |
| 4201 | ||
| 4202 | msgctxt "#90762" | |
| 4203 | msgid "Enable RPDB Posters" | |
| 4204 | msgstr "" | |
| 4205 | ||
| 4206 | msgctxt "#90763" | |
| 4207 | msgid "RPDB API Key" | |
| 4208 | msgstr "" | |
| 4209 | ||
| 4210 | msgctxt "#90764" | |
| 4211 | msgid "Get your API key at ratingposterdb.com" | |
| 4212 | msgstr "" | |
| 4213 | ||
| 4214 | msgctxt "#90765" | |
| 4215 | msgid "Filter by Codecs" | |
| 4216 | msgstr "" | |
| 4217 | ||
| 4218 | msgctxt "#90766" | |
| 4219 | msgid "Filter by HDR" | |
| 4220 | msgstr "" | |
| 4221 | ||
| 4222 | msgctxt "#90767" | |
| 4223 | msgid "Include HEVC / H.265" | |
| 4224 | msgstr "" | |
| 4225 | ||
| 4226 | msgctxt "#90768" | |
| 4227 | msgid "Include AV1" | |
| 4228 | msgstr "" | |
| 4229 | ||
| 4230 | msgctxt "#90769" | |
| 4231 | msgid "Include H.264" | |
| 4232 | msgstr "" | |
| 4233 | ||
| 4234 | msgctxt "#90770" | |
| 4235 | msgid "Include Dolby Vision" | |
| 4236 | msgstr "" | |
| 4237 | ||
| 4238 | msgctxt "#90771" | |
| 4239 | msgid "Include HDR10 / HDR10+" | |
| 4240 | msgstr "" | |
| 4241 | ||
| 4242 | msgctxt "#90772" | |
| 4243 | msgid "Include HDR (generic)" | |
| 4244 | msgstr "" | |
| 4245 | ||
| 4246 | msgctxt "#90774" | |
| 4247 | msgid "Organize downloads by type" | |
| 4248 | msgstr "" | |
| 4249 | ||
| 4250 | msgctxt "#90775" | |
| 4251 | msgid "Movies folder name" | |
| 4252 | msgstr "" | |
| 4253 | ||
| 4254 | msgctxt "#90776" | |
| 4255 | msgid "TV Shows folder name" | |
| 4256 | msgstr "" | |
| 4257 | ||
| 4258 | msgctxt "#90777" | |
| 4259 | msgid "Pause" | |
| 4260 | msgstr "" | |
| 4261 | ||
| 4262 | msgctxt "#90778" | |
| 4263 | msgid "Resume" | |
| 4264 | msgstr "" | |
| 4265 | ||
| 4266 | msgctxt "#90779" | |
| 4267 | msgid "Downloading" | |
| 4268 | msgstr "" | |
| 4269 | ||
| 4270 | msgctxt "#90780" | |
| 4271 | msgid "Paused" | |
| 4272 | msgstr "" | |
| 4273 | ||
| 4274 | msgctxt "#90781" | |
| 4275 | msgid "Completed" | |
| 4276 | msgstr "" | |
| 4277 | ||
| 4278 | msgctxt "#90782" | |
| 4279 | msgid "Delete" | |
| 4280 | msgstr "" | |
| 4281 | ||
| 4282 | msgctxt "#90783" | |
| 4283 | msgid "Startup" | |
| 4284 | msgstr "" | |
| 4285 | ||
| 4286 | msgctxt "#90784" | |
| 4287 | msgid "Auto-start Jacktook on Kodi boot" | |
| 4288 | msgstr "" | |
| 4289 | ||
| 4290 | msgctxt "#90785" | |
| 4291 | msgid "Automatically open Jacktook when Kodi starts" | |
| 4292 | msgstr "" | |
| 4293 | ||
| 4294 | msgctxt "#90786" | |
| 4295 | msgid "Widgets" | |
| 4296 | msgstr "" | |
| 4297 | ||
| 4298 | msgctxt "#90787" | |
| 4299 | msgid "Widget refresh interval (minutes)" | |
| 4300 | msgstr "" | |
| 4301 | ||
| 4302 | msgctxt "#90788" | |
| 4303 | msgid "Set to 0 to disable automatic widget refresh" | |
| 4304 | msgstr "" | |
| 4305 | ||
| 4306 | msgctxt "#90789" | |
| 4307 | msgid "Show refresh notification" | |
| 4308 | msgstr "" | |
| 4309 | ||
| 4310 | msgctxt "#90790" | |
| 4311 | msgid "Display a notification when widgets are refreshed" | |
| 4312 | msgstr "" | |
| 4313 | ||
| 4314 | msgctxt "#90791" | |
| 4315 | msgid "Download to Device" | |
| 4316 | msgstr "" | |
| 4317 | ||
| 4318 | msgctxt "#90792" | |
| 4319 | msgid "Download Manager" | |
| 4320 | msgstr "" | |
| 4321 | ||
| 4322 | msgctxt "#90793" | |
| 4323 | msgid "No active downloads" | |
| 4324 | msgstr "" | |
| 4325 | ||
| 4326 | msgctxt "#90794" | |
| 4327 | msgid "Progress" | |
| 4328 | msgstr "" | |
| 4329 | ||
| 4330 | msgctxt "#90795" | |
| 4331 | msgid "Speed" | |
| 4332 | msgstr "" | |
| 4333 | ||
| 4334 | msgctxt "#90796" | |
| 4335 | msgid "ETA" | |
| 4336 | msgstr "" | |
| 4337 | ||
| 4338 | msgctxt "#90797" | |
| 4339 | msgid "Size" | |
| 4340 | msgstr "" | |
| 4341 | ||
| 4342 | msgctxt "#90798" | |
| 4343 | msgid "Pause" | |
| 4344 | msgstr "" | |
| 4345 | ||
| 4346 | msgctxt "#90799" | |
| 4347 | msgid "Resume" | |
| 4348 | msgstr "" | |
| 4349 | ||
| 4350 | msgctxt "#90800" | |
| 4351 | msgid "Cancel" | |
| 4352 | msgstr "" | |
| 4353 | ||
| 4354 | msgctxt "#90801" | |
| 4355 | msgid "Delete" | |
| 4356 | msgstr "" | |
| 4357 | ||
| 4358 | msgctxt "#90802" | |
| 4359 | msgid "Clear Completed" | |
| 4360 | msgstr "" | |
| 4361 | ||
| 4362 | msgctxt "#90803" | |
| 4363 | msgid "Download not yet completed" | |
| 4364 | msgstr "" | |
| 4365 | ||
| 4366 | msgctxt "#90804" | |
| 4367 | msgid "File not found" | |
| 4368 | msgstr "" | |
| 4369 | ||
| 4370 | msgctxt "#90805" | |
| 4371 | msgid "Download Manager" | |
| 4372 | msgstr "" | |
| 4373 | ||
| 4374 | msgctxt "#90806" | |
| 4375 | msgid "My Downloads" | |
| 4376 | msgstr "" | |
| 4377 | ||
| 4378 | msgctxt "#90807" | |
| 4379 | msgid "Connecting..." | |
| 4380 | msgstr "" | |
| 4381 | ||
| 4382 | msgctxt "#90808" | |
| 4383 | msgid "Close" | |
| 4384 | msgstr "" | |
| 4385 | ||
| 4386 | msgctxt "#90809" | |
| 4387 | msgid "Delete download?" | |
| 4388 | msgstr "" | |
| 4389 | ||
| 4390 | msgctxt "#90810" | |
| 4391 | msgid "This will permanently delete the downloaded file. Are you sure?" | |
| 4392 | msgstr "" | |
| 4393 | ||
| 4394 | msgctxt "#90811" | |
| 4395 | msgid "Completed" | |
| 4396 | msgstr "" | |
| 4397 | ||
| 4398 | msgctxt "#90812" | |
| 4399 | msgid "Excluded TMDB Languages" | |
| 4400 | msgstr "" | |
| 4401 | ||
| 4402 | msgctxt "#90813" | |
| 4403 | msgid "Select languages to exclude" | |
| 4404 | msgstr "" | |
| 4405 | ||
| 4406 | msgctxt "#90814" | |
| 4407 | msgid "Choose original languages to hide from TMDB listings" | |
| 4408 | msgstr "" | |
| 4409 | ||
| 4410 | msgctxt "#90815" | |
| 4411 | msgid "Support Jacktook" | |
| 4412 | msgstr "" | |
| 4413 | ||
| 4414 | msgctxt "#90816" | |
| 4415 | msgid "Support Jacktook development with an optional Ko-fi donation." | |
| 4416 | msgstr "" | |
| 4417 | ||
| 4418 | msgctxt "#90817" | |
| 4419 | msgid "Scan the QR code or open this link:" | |
| 4420 | msgstr "" | |
| 4421 | ||
| 4422 | msgctxt "#90818" | |
| 4423 | msgid "Jacktook was updated with new improvements. Donations are optional, but they help keep development active. Would you like to see the support link?" | |
| 4424 | msgstr "" | |
| 4425 | ||
| 4426 | msgctxt "#90819" | |
| 4427 | msgid "Rename Stremio add-on" | |
| 4428 | msgstr "" | |
| 4429 | ||
| 4430 | msgctxt "#90820" | |
| 4431 | msgid "Enter add-on alias." | |
| 4432 | msgstr "" | |
| 4433 | ||
| 4434 | msgctxt "#90821" | |
| 4435 | msgid "Stremio alias" | |
| 4436 | msgstr "" | |
| 4437 | ||
| 4438 | msgctxt "#90822" | |
| 4439 | msgid "Alias saved." | |
| 4440 | msgstr "" | |
| 4441 | ||
| 4442 | msgctxt "#90823" | |
| 4443 | msgid "Alias cleared." | |
| 4444 | msgstr "" | |
| 4445 | ||
| 4446 | msgctxt "#90824" | |
| 4447 | msgid "Rename Stremio catalog" | |
| 4448 | msgstr "" | |
| 4449 | ||
| 4450 | msgctxt "#90825" | |
| 4451 | msgid "Select catalog" | |
| 4452 | msgstr "" | |
| 4453 | ||
| 4454 | msgctxt "#90826" | |
| 4455 | msgid "Enter catalog alias." | |
| 4456 | msgstr "" | |
| 4457 | ||
| 4458 | msgctxt "#90827" | |
| 4459 | msgid "Set a Kodi-only display alias for a Stremio add-on." | |
| 4460 | msgstr "" | |
| 4461 | ||
| 4462 | msgctxt "#90828" | |
| 4463 | msgid "Set a Kodi-only display alias for a Stremio catalog." | |
| 4464 | msgstr "" | |
| 4465 | ||
| 4466 | msgctxt "#90829" | |
| 4467 | msgid "Edit" | |
| 4468 | msgstr "" | |
| 4469 | ||
| 4470 | msgctxt "#90830" | |
| 4471 | msgid "Clear" | |
| 4472 | msgstr "" | |
| 4473 | ||
| 4474 | msgctxt "#90831" | |
| 4475 | msgid "This item already has an alias. Do you want to clear it?" | |
| 4476 | msgstr "" | |
| 4477 | ||
| 4478 | msgctxt "#90832" | |
| 4479 | msgid "Stremio: Addons Management" | |
| 4480 | msgstr "" | |
| 4481 | ||
| 4482 | msgctxt "#90833" | |
| 4483 | msgid "Stremio: Catalogs & Visibility" | |
| 4484 | msgstr "" | |
| 4485 | ||
| 4486 | msgctxt "#90834" | |
| 4487 | msgid "Stremio: Rename & Customization" | |
| 4488 | msgstr "" | |
| 4489 | ||
| 4490 | msgctxt "#90835" | |
| 4491 | msgid "Stremio: Search & Behavior" | |
| 4492 | msgstr "" | |
| 4493 | ||
| 4494 | msgctxt "#90300" | |
| 4495 | msgid "External Scraper" | |
| 4496 | msgstr "" | |
| 4497 | ||
| 4498 | msgctxt "#90301" | |
| 4499 | msgid "Enable external scraper module integration." | |
| 4500 | msgstr "" | |
| 4501 | ||
| 4502 | msgctxt "#90302" | |
| 4503 | msgid "Choose External Scraper Module" | |
| 4504 | msgstr "" | |
| 4505 | ||
| 4506 | msgctxt "#90303" | |
| 4507 | msgid "Select an external scraper addon module to use for torrent search." | |
| 4508 | msgstr "" | |
| 4509 | ||
| 4510 | msgctxt "#90304" | |
| 4511 | msgid "Choose External Scraper" | |
| 4512 | msgstr "" | |
| 4513 | ||
| 4514 | msgctxt "#90305" | |
| 4515 | msgid "Selected Module" | |
| 4516 | msgstr "" | |
| 4517 | ||
| 4518 | msgctxt "#90306" | |
| 4519 | msgid "Open External Scraper Settings" | |
| 4520 | msgstr "" | |
| 4521 | ||
| 4522 | msgctxt "#90307" | |
| 4523 | msgid "Open the settings for the selected external scraper module." | |
| 4524 | msgstr "" |
| … | … | @@ -312,8 +312,8 @@ msgid "Unknown" |
| 312 | 312 | msgstr "Desconhecido" |
| 313 | 313 | |
| 314 | 314 | msgctxt "#30700" |
| 315 | msgid "Buffer and play" | |
| 316 | msgstr "Carregar e reproduzir" | |
| 315 | msgid "Play Torrent" | |
| 316 | msgstr "Reproduzir torrent" | |
| 317 | 317 | |
| 318 | 318 | msgctxt "#30701" |
| 319 | 319 | msgid "Stop downloading" |
| … | … | @@ -431,6 +431,58 @@ msgctxt "#30939" |
| 431 | 431 | msgid "Auto-resume from last position without asking" |
| 432 | 432 | msgstr "Retomar automaticamente da última posição sem perguntar" |
| 433 | 433 | |
| 434 | msgctxt "#30940" | |
| 435 | msgid "Use Percentage Trigger" | |
| 436 | msgstr "Usar acionamento por porcentagem" | |
| 437 | ||
| 438 | msgctxt "#30941" | |
| 439 | msgid "Show Dialog at Playback Percentage" | |
| 440 | msgstr "Mostrar diálogo na porcentagem de reprodução" | |
| 441 | ||
| 442 | msgctxt "#30942" | |
| 443 | msgid "Trigger the next-episode dialog based on percentage of video played instead of seconds remaining." | |
| 444 | msgstr "Acionar o diálogo do próximo episódio com base na porcentagem do vídeo reproduzida em vez dos segundos restantes." | |
| 445 | ||
| 446 | msgctxt "#30943" | |
| 447 | msgid "The percentage at which the next-episode dialog appears (e.g. 95 means 95% of the video)." | |
| 448 | msgstr "A porcentagem em que o diálogo do próximo episódio aparece (por exemplo, 95 significa 95% do vídeo)." | |
| 449 | ||
| 450 | msgctxt "#30944" | |
| 451 | msgid "Auto-Play Timeout" | |
| 452 | msgstr "Tempo limite da reprodução automática" | |
| 453 | ||
| 454 | msgctxt "#30945" | |
| 455 | msgid "Seconds before auto-playing next episode. 0 = disabled, dialog waits for user input." | |
| 456 | msgstr "Segundos antes de reproduzir automaticamente o próximo episódio. 0 = desativado, o diálogo aguarda a entrada do usuário." | |
| 457 | ||
| 458 | ||
| 459 | msgctxt "#30946" | |
| 460 | msgid "Offcloud Configuration" | |
| 461 | msgstr "Configuração do Offcloud" | |
| 462 | ||
| 463 | msgctxt "#30947" | |
| 464 | msgid "Enable Offcloud cached torrent and cloud transfer support." | |
| 465 | msgstr "Ativar suporte a torrents em cache e transferências em nuvem do Offcloud." | |
| 466 | ||
| 467 | msgctxt "#30948" | |
| 468 | msgid "Your Offcloud account username/email." | |
| 469 | msgstr "Seu nome de usuário ou email do Offcloud." | |
| 470 | ||
| 471 | msgctxt "#30949" | |
| 472 | msgid "Offcloud API key from Account API Key settings." | |
| 473 | msgstr "Chave API do Offcloud nas configurações da Conta → Chave API." | |
| 474 | ||
| 475 | msgctxt "#30950" | |
| 476 | msgid "View Offcloud account information." | |
| 477 | msgstr "Ver informações da conta Offcloud." | |
| 478 | ||
| 479 | msgctxt "#30951" | |
| 480 | msgid "Authorize add-on to access Offcloud account." | |
| 481 | msgstr "Autorizar o addon a acessar a conta Offcloud." | |
| 482 | ||
| 483 | msgctxt "#30952" | |
| 484 | msgid "Revoke Offcloud account access." | |
| 485 | msgstr "Revogar o acesso da conta Offcloud." | |
| 434 | 486 | msgctxt "#30728" |
| 435 | 487 | msgid "Seeds" |
| 436 | 488 | msgstr "Sementes" |
| … | … | @@ -459,6 +511,10 @@ msgctxt "#30734" |
| 459 | 511 | msgid "Enable" |
| 460 | 512 | msgstr "Habilitar" |
| 461 | 513 | |
| 514 | msgctxt "#30735" | |
| 515 | msgid "Still Watching? Threshold" | |
| 516 | msgstr "Limite de Ainda assistindo?" | |
| 517 | ||
| 462 | 518 | msgctxt "#30736" |
| 463 | 519 | msgid "Subtitle Language" |
| 464 | 520 | msgstr "Idioma das legendas" |
| … | … | @@ -607,6 +663,10 @@ msgctxt "#30772" |
| 607 | 663 | msgid "Zilean Host" |
| 608 | 664 | msgstr "Host Zilean" |
| 609 | 665 | |
| 666 | msgctxt "#30773" | |
| 667 | msgid "Number of consecutive auto-played episodes before asking 'Still Watching?'. Set to 0 to disable." | |
| 668 | msgstr "Número de episódios reproduzidos automaticamente em sequência antes de perguntar 'Ainda assistindo?'. Defina como 0 para desativar." | |
| 669 | ||
| 610 | 670 | msgctxt "#30774" |
| 611 | 671 | msgid "Zilean Timeout" |
| 612 | 672 | msgstr "Zilean Tempo limite" |
| … | … | @@ -1556,8 +1616,8 @@ msgid "TV Calendar" |
| 1556 | 1616 | msgstr "Calendário de TV" |
| 1557 | 1617 | |
| 1558 | 1618 | msgctxt "#90022" |
| 1559 | msgid "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:" | |
| 1560 | msgstr "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:" | |
| 1619 | msgid "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." | |
| 1620 | msgstr "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." | |
| 1561 | 1621 | |
| 1562 | 1622 | msgctxt "#90023" |
| 1563 | 1623 | msgid "Support Jacktook" |
| … | … | @@ -1933,15 +1993,15 @@ msgstr "Senha" |
| 1933 | 1993 | |
| 1934 | 1994 | msgctxt "#90129" |
| 1935 | 1995 | msgid "Test Connection" |
| 1936 | msgstr "" | |
| 1996 | msgstr "Testar conexão" | |
| 1937 | 1997 | |
| 1938 | 1998 | msgctxt "#90131" |
| 1939 | 1999 | msgid "Webdav" |
| 1940 | msgstr "" | |
| 2000 | msgstr "WebDAV" | |
| 1941 | 2001 | |
| 1942 | 2002 | msgctxt "#90132" |
| 1943 | 2003 | msgid "Remote Path" |
| 1944 | msgstr "" | |
| 2004 | msgstr "Caminho remoto" | |
| 1945 | 2005 | |
| 1946 | 2006 | msgctxt "#90133" |
| 1947 | 2007 | msgid "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. |
| 1949 | 2009 | |
| 1950 | 2010 | msgctxt "#90134" |
| 1951 | 2011 | msgid "The username required for authenticating with your WebDAV server" |
| 1952 | msgstr "" | |
| 2012 | msgstr "O nome de usuário necessário para autenticação no seu servidor WebDAV" | |
| 1953 | 2013 | |
| 1954 | 2014 | msgctxt "#90135" |
| 1955 | 2015 | msgid "The password for the specified username to authenticate with your WebDAV server" |
| 1956 | msgstr "" | |
| 2016 | msgstr "A senha do nome de usuário especificado para autenticação no seu servidor WebDAV" | |
| 1957 | 2017 | |
| 1958 | 2018 | msgctxt "#90136" |
| 1959 | 2019 | msgid "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 |
| 1961 | 2021 | |
| 1962 | 2022 | msgctxt "#90137" |
| 1963 | 2023 | msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials." |
| 1964 | msgstr "" | |
| 2024 | msgstr "Clique para verificar se o cliente WebDAV consegue se conectar com sucesso ao servidor configurado usando as credenciais fornecidas." | |
| 1965 | 2025 | |
| 1966 | 2026 | msgctxt "#90138" |
| 1967 | 2027 | msgid "Remove Custom Addon" |
| 1968 | msgstr "" | |
| 2028 | msgstr "Remover addon personalizado" | |
| 1969 | 2029 | |
| 1970 | 2030 | msgctxt "#90139" |
| 1971 | 2031 | msgid "Playback" |
| … | … | @@ -1973,43 +2033,59 @@ msgstr "Reprodução" |
| 1973 | 2033 | |
| 1974 | 2034 | msgctxt "#90140" |
| 1975 | 2035 | msgid "Super quick play" |
| 1976 | msgstr "" | |
| 2036 | msgstr "Reprodução super rápida" | |
| 1977 | 2037 | |
| 1978 | 2038 | msgctxt "#90141" |
| 1979 | 2039 | msgid "Re-ask to play last played video" |
| 1980 | msgstr "" | |
| 2040 | msgstr "Perguntar novamente para reproduzir o último vídeo reproduzido" | |
| 1981 | 2041 | |
| 1982 | 2042 | msgctxt "#90142" |
| 1983 | 2043 | msgid "Replay Dialog" |
| 1984 | msgstr "" | |
| 2044 | msgstr "Diálogo de repetição" | |
| 1985 | 2045 | |
| 1986 | 2046 | msgctxt "#90143" |
| 1987 | 2047 | msgid "Do you want to play last watched?" |
| 1988 | msgstr "" | |
| 2048 | msgstr "Deseja reproduzir o último assistido?" | |
| 1989 | 2049 | |
| 1990 | 2050 | msgctxt "#90144" |
| 1991 | 2051 | msgid "Could not resolve playback URL" |
| 1992 | msgstr "" | |
| 2052 | msgstr "Não foi possível resolver a URL de reprodução" | |
| 1993 | 2053 | |
| 1994 | msgctxt "#90145" | |
| 1995 | msgid "Precaching Enabled" | |
| 1996 | msgstr "Pré-carregamento Ativado" | |
| 2054 | msgctxt "#90171" | |
| 2055 | msgid "Autoscrape Next Episode" | |
| 2056 | msgstr "Autoscrape do próximo episódio" | |
| 1997 | 2057 | |
| 1998 | msgctxt "#90146" | |
| 1999 | msgid "Enable or disable precaching of next episodes." | |
| 2000 | msgstr "Ativar ou desativar o pré-carregamento dos próximos episódios." | |
| 2058 | msgctxt "#90172" | |
| 2059 | msgid "Pre-resolve the next episode's playback URL in the background while watching." | |
| 2060 | msgstr "Pré-resolve em segundo plano a URL de reprodução do próximo episódio enquanto você assiste." | |
| 2001 | 2061 | |
| 2002 | msgctxt "#90147" | |
| 2003 | msgid "Precaching Episode Count" | |
| 2004 | msgstr "Contagem de Episódios para Pré-carregar" | |
| 2062 | msgctxt "#90173" | |
| 2063 | msgid "Autoscrape Threshold" | |
| 2064 | msgstr "Limiar do autoscrape" | |
| 2005 | 2065 | |
| 2006 | msgctxt "#90148" | |
| 2007 | msgid "Number of next episodes to precache." | |
| 2008 | msgstr "Número de próximos episódios para pré-carregar." | |
| 2066 | msgctxt "#90174" | |
| 2067 | msgid "Percentage of playback to trigger background pre-resolution." | |
| 2068 | msgstr "Percentual da reprodução para acionar a pré-resolução em segundo plano." | |
| 2069 | ||
| 2070 | msgctxt "#90175" | |
| 2071 | msgid "Autoscrape Episode Count" | |
| 2072 | msgstr "Quantidade de episódios do autoscrape" | |
| 2073 | ||
| 2074 | msgctxt "#90176" | |
| 2075 | msgid "Number of upcoming episodes to pre-resolve." | |
| 2076 | msgstr "Número de próximos episódios para pré-resolver." | |
| 2077 | ||
| 2078 | msgctxt "#90177" | |
| 2079 | msgid "Autoscrape Cache TTL" | |
| 2080 | msgstr "TTL do cache do autoscrape" | |
| 2081 | ||
| 2082 | msgctxt "#90178" | |
| 2083 | msgid "Hours to keep resolved playback data in cache." | |
| 2084 | msgstr "Horas para manter em cache os dados de reprodução resolvidos." | |
| 2009 | 2085 | |
| 2010 | 2086 | msgctxt "#30901" |
| 2011 | 2087 | msgid "Show Only Stremio Catalogs" |
| 2012 | msgstr "" | |
| 2088 | msgstr "Mostrar apenas catálogos do Stremio" | |
| 2013 | 2089 | |
| 2014 | 2090 | msgctxt "#30902" |
| 2015 | 2091 | msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus." |
| … | … | @@ -2073,19 +2149,19 @@ msgstr "Filtrar por Qualidade" |
| 2073 | 2149 | |
| 2074 | 2150 | msgctxt "#32301" |
| 2075 | 2151 | msgid "Filter by Provider" |
| 2076 | msgstr "" | |
| 2152 | msgstr "Filtrar por provedor" | |
| 2077 | 2153 | |
| 2078 | 2154 | msgctxt "#32302" |
| 2079 | 2155 | msgid "Filter by Source" |
| 2080 | msgstr "" | |
| 2156 | msgstr "Filtrar por fonte" | |
| 2081 | 2157 | |
| 2082 | 2158 | msgctxt "#32303" |
| 2083 | 2159 | msgid "Filter by Language" |
| 2084 | msgstr "" | |
| 2160 | msgstr "Filtrar por idioma" | |
| 2085 | 2161 | |
| 2086 | 2162 | msgctxt "#32304" |
| 2087 | 2163 | msgid "Filter by Torrents" |
| 2088 | msgstr "" | |
| 2164 | msgstr "Filtrar por torrents" | |
| 2089 | 2165 | |
| 2090 | 2166 | msgctxt "#32305" |
| 2091 | 2167 | msgid "Filter by Debrid" |
| … | … | @@ -2093,7 +2169,7 @@ msgstr "Filtrar por Debrid" |
| 2093 | 2169 | |
| 2094 | 2170 | msgctxt "#32306" |
| 2095 | 2171 | msgid "Reset Filter" |
| 2096 | msgstr "" | |
| 2172 | msgstr "Redefinir filtro" | |
| 2097 | 2173 | |
| 2098 | 2174 | msgctxt "#30930" |
| 2099 | 2175 | msgid "Ping Addons" |
| … | … | @@ -2189,7 +2265,7 @@ msgstr "Ignorar a ordenação nativa dos add-ons do Stremio" |
| 2189 | 2265 | |
| 2190 | 2266 | msgctxt "#90212" |
| 2191 | 2267 | msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds." |
| 2192 | msgstr "" | |
| 2268 | msgstr "Se ativado, os addons especificados não terão seus resultados reordenados nem filtrados pelos limites nativos de qualidade e tamanho do Jacktook." | |
| 2193 | 2269 | |
| 2194 | 2270 | msgctxt "#90213" |
| 2195 | 2271 | msgid "Select Bypassed Addons" |
| … | … | @@ -2197,7 +2273,7 @@ msgstr "Selecionar add-ons a ignorar" |
| 2197 | 2273 | |
| 2198 | 2274 | msgctxt "#90214" |
| 2199 | 2275 | msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering." |
| 2200 | msgstr "" | |
| 2276 | msgstr "Selecione quais addons do Stremio ignoram a filtragem nativa de tamanho e qualidade do Jacktook." | |
| 2201 | 2277 | |
| 2202 | 2278 | msgctxt "#90215" |
| 2203 | 2279 | msgid "Downgrade" |
| … | … | @@ -2209,27 +2285,27 @@ msgstr "Instala uma versao publicada anterior do Jacktook. A versao atual e salv |
| 2209 | 2285 | |
| 2210 | 2286 | msgctxt "#30932" |
| 2211 | 2287 | msgid "Update Action" |
| 2212 | msgstr "" | |
| 2288 | msgstr "Ação de atualização" | |
| 2213 | 2289 | |
| 2214 | 2290 | msgctxt "#30933" |
| 2215 | 2291 | msgid "Choose how to handle background update checks." |
| 2216 | msgstr "" | |
| 2292 | msgstr "Escolha como lidar com as verificações de atualização em segundo plano." | |
| 2217 | 2293 | |
| 2218 | 2294 | msgctxt "#30934" |
| 2219 | 2295 | msgid "Ask" |
| 2220 | msgstr "" | |
| 2296 | msgstr "Perguntar" | |
| 2221 | 2297 | |
| 2222 | 2298 | msgctxt "#30935" |
| 2223 | 2299 | msgid "Notify" |
| 2224 | msgstr "" | |
| 2300 | msgstr "Notificar" | |
| 2225 | 2301 | |
| 2226 | 2302 | msgctxt "#90216" |
| 2227 | 2303 | msgid "Volume Check Enabled" |
| 2228 | msgstr "" | |
| 2304 | msgstr "Verificação de volume ativada" | |
| 2229 | 2305 | |
| 2230 | 2306 | msgctxt "#90217" |
| 2231 | 2307 | msgid "Automatically limits volume if it is above the threshold before playback." |
| 2232 | msgstr "" | |
| 2308 | msgstr "Limita automaticamente o volume se ele estiver acima do limite antes da reprodução." | |
| 2233 | 2309 | |
| 2234 | 2310 | msgctxt "#90218" |
| 2235 | 2311 | msgid "Volume Threshold" |
| … | … | @@ -2237,15 +2313,15 @@ msgstr "Limite de volume" |
| 2237 | 2313 | |
| 2238 | 2314 | msgctxt "#90219" |
| 2239 | 2315 | msgid "The volume percentage above which the warning triggers." |
| 2240 | msgstr "" | |
| 2316 | msgstr "A porcentagem de volume acima da qual o aviso é acionado." | |
| 2241 | 2317 | |
| 2242 | 2318 | msgctxt "#90220" |
| 2243 | 2319 | msgid "High Volume Warning" |
| 2244 | msgstr "" | |
| 2320 | msgstr "Aviso de volume alto" | |
| 2245 | 2321 | |
| 2246 | 2322 | msgctxt "#90221" |
| 2247 | 2323 | msgid "High volume detected. Adjusting to %s%% for safety." |
| 2248 | msgstr "" | |
| 2324 | msgstr "Volume alto detectado. Ajustando para %s%% por segurança." | |
| 2249 | 2325 | |
| 2250 | 2326 | msgctxt "#90222" |
| 2251 | 2327 | msgid "Fanart.tv Configuration" |
| … | … | @@ -2253,19 +2329,19 @@ msgstr "Fanart.tv Configuração" |
| 2253 | 2329 | |
| 2254 | 2330 | msgctxt "#90223" |
| 2255 | 2331 | msgid "Client Key" |
| 2256 | msgstr "" | |
| 2332 | msgstr "Chave do cliente" | |
| 2257 | 2333 | |
| 2258 | 2334 | msgctxt "#90224" |
| 2259 | 2335 | msgid "Your personal Fanart.tv client key for higher quality assets." |
| 2260 | msgstr "" | |
| 2336 | msgstr "Sua chave pessoal de cliente do Fanart.tv para recursos de maior qualidade." | |
| 2261 | 2337 | |
| 2262 | 2338 | msgctxt "#90225" |
| 2263 | 2339 | msgid "Debrid Notifications" |
| 2264 | msgstr "" | |
| 2340 | msgstr "Notificações Debrid" | |
| 2265 | 2341 | |
| 2266 | 2342 | msgctxt "#90226" |
| 2267 | 2343 | msgid "Debrid Expiration Notification" |
| 2268 | msgstr "" | |
| 2344 | msgstr "Notificação de expiração do Debrid" | |
| 2269 | 2345 | |
| 2270 | 2346 | msgctxt "#90227" |
| 2271 | 2347 | msgid "Enable or disable notifications when your Debrid subscription is about to expire." |
| … | … | @@ -2277,15 +2353,15 @@ msgstr "Expiration Threshold (Days)" |
| 2277 | 2353 | |
| 2278 | 2354 | msgctxt "#90229" |
| 2279 | 2355 | msgid "How many days before expiration to start notifying." |
| 2280 | msgstr "" | |
| 2356 | msgstr "Quantos dias antes da expiração começar a notificar." | |
| 2281 | 2357 | |
| 2282 | 2358 | msgctxt "#90230" |
| 2283 | 2359 | msgid "%s expires in %d days." |
| 2284 | msgstr "" | |
| 2360 | msgstr "%s expira em %d dias." | |
| 2285 | 2361 | |
| 2286 | 2362 | msgctxt "#90231" |
| 2287 | 2363 | msgid "%s expires today!" |
| 2288 | msgstr "" | |
| 2364 | msgstr "%s expira hoje!" | |
| 2289 | 2365 | |
| 2290 | 2366 | msgctxt "#90232" |
| 2291 | 2367 | msgid "Easynews" |
| … | … | @@ -2293,19 +2369,19 @@ msgstr "Easynews" |
| 2293 | 2369 | |
| 2294 | 2370 | msgctxt "#90233" |
| 2295 | 2371 | msgid "Enable EasyNews as a direct stream provider." |
| 2296 | msgstr "" | |
| 2372 | msgstr "Ativar o EasyNews como provedor de stream direto." | |
| 2297 | 2373 | |
| 2298 | 2374 | msgctxt "#90234" |
| 2299 | 2375 | msgid "Your EasyNews username." |
| 2300 | msgstr "" | |
| 2376 | msgstr "Seu nome de usuário do EasyNews." | |
| 2301 | 2377 | |
| 2302 | 2378 | msgctxt "#90235" |
| 2303 | 2379 | msgid "Your EasyNews password." |
| 2304 | msgstr "" | |
| 2380 | msgstr "Sua senha do EasyNews." | |
| 2305 | 2381 | |
| 2306 | 2382 | msgctxt "#90236" |
| 2307 | 2383 | msgid "Request timeout in seconds." |
| 2308 | msgstr "" | |
| 2384 | msgstr "Tempo limite da requisição em segundos." | |
| 2309 | 2385 | |
| 2310 | 2386 | msgctxt "#90237" |
| 2311 | 2387 | msgid "Account Information" |
| … | … | @@ -2313,15 +2389,15 @@ msgstr "Informações da conta" |
| 2313 | 2389 | |
| 2314 | 2390 | msgctxt "#90238" |
| 2315 | 2391 | msgid "Search Dialog Style" |
| 2316 | msgstr "" | |
| 2392 | msgstr "Estilo da caixa de diálogo de busca" | |
| 2317 | 2393 | |
| 2318 | 2394 | msgctxt "#90239" |
| 2319 | 2395 | msgid "Background" |
| 2320 | msgstr "" | |
| 2396 | msgstr "Plano de fundo" | |
| 2321 | 2397 | |
| 2322 | 2398 | msgctxt "#90240" |
| 2323 | 2399 | msgid "Detailed Status" |
| 2324 | msgstr "" | |
| 2400 | msgstr "Status detalhado" | |
| 2325 | 2401 | |
| 2326 | 2402 | msgctxt "#90241" |
| 2327 | 2403 | msgid "Search Status" |
| … | … | @@ -2357,7 +2433,7 @@ msgstr "Cancelado" |
| 2357 | 2433 | |
| 2358 | 2434 | msgctxt "#90249" |
| 2359 | 2435 | msgid "Your personal Jackgram API token for authentication." |
| 2360 | msgstr "" | |
| 2436 | msgstr "Seu token pessoal da API do Jackgram para autenticação." | |
| 2361 | 2437 | |
| 2362 | 2438 | msgctxt "#90250" |
| 2363 | 2439 | msgid "Subtitles Found" |
| … | … | @@ -2555,6 +2631,10 @@ msgctxt "#90298" |
| 2555 | 2631 | msgid "Liked Lists" |
| 2556 | 2632 | msgstr "Listas Curtidas" |
| 2557 | 2633 | |
| 2634 | msgctxt "#90299" | |
| 2635 | msgid "No metadata found for this torrent" | |
| 2636 | msgstr "Nenhum metadado encontrado para este torrent" | |
| 2637 | ||
| 2558 | 2638 | msgctxt "#90331" |
| 2559 | 2639 | msgid "Stremio Addons" |
| 2560 | 2640 | msgstr "Addons do Stremio" |
| … | … | @@ -2641,27 +2721,27 @@ msgstr "Nao foi possivel baixar o backup de configuracoes." |
| 2641 | 2721 | |
| 2642 | 2722 | msgctxt "#90352" |
| 2643 | 2723 | msgid "Source Select Style" |
| 2644 | msgstr "" | |
| 2724 | msgstr "Estilo da seleção de fontes" | |
| 2645 | 2725 | |
| 2646 | 2726 | msgctxt "#90353" |
| 2647 | 2727 | msgid "Modern" |
| 2648 | msgstr "" | |
| 2728 | msgstr "Moderno" | |
| 2649 | 2729 | |
| 2650 | 2730 | msgctxt "#90354" |
| 2651 | 2731 | msgid "OLED" |
| 2652 | msgstr "" | |
| 2732 | msgstr "OLED" | |
| 2653 | 2733 | |
| 2654 | 2734 | msgctxt "#90355" |
| 2655 | 2735 | msgid "Cyberpunk" |
| 2656 | msgstr "" | |
| 2736 | msgstr "Cyberpunk" | |
| 2657 | 2737 | |
| 2658 | 2738 | msgctxt "#90356" |
| 2659 | 2739 | msgid "Forest" |
| 2660 | msgstr "" | |
| 2740 | msgstr "Floresta" | |
| 2661 | 2741 | |
| 2662 | 2742 | msgctxt "#90357" |
| 2663 | 2743 | msgid "Gold" |
| 2664 | msgstr "" | |
| 2744 | msgstr "Dourado" | |
| 2665 | 2745 | |
| 2666 | 2746 | msgctxt "#90358" |
| 2667 | 2747 | msgid "No playable results found" |
| … | … | @@ -3651,6 +3731,18 @@ msgctxt "#90749" |
| 3651 | 3731 | msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file" |
| 3652 | 3732 | msgstr "Escaneie o código QR ou abra a URL (%s) no seu navegador para enviar um arquivo de legendas" |
| 3653 | 3733 | |
| 3734 | msgctxt "#90740" | |
| 3735 | msgid "Search with current title" | |
| 3736 | msgstr "Buscar com o título atual" | |
| 3737 | ||
| 3738 | msgctxt "#90741" | |
| 3739 | msgid "Search with original title" | |
| 3740 | msgstr "Buscar com o título original" | |
| 3741 | ||
| 3742 | msgctxt "#90773" | |
| 3743 | msgid "Search full show with title + year" | |
| 3744 | msgstr "Buscar série completa com título + ano" | |
| 3745 | ||
| 3654 | 3746 | msgctxt "#90750" |
| 3655 | 3747 | msgid "Search Title Language" |
| 3656 | 3748 | msgstr "Idioma do título de busca" |
| … | … | @@ -3670,3 +3762,715 @@ msgstr "Somente inglês" |
| 3670 | 3762 | msgctxt "#90754" |
| 3671 | 3763 | msgid "Localized first" |
| 3672 | 3764 | msgstr "Localizado primeiro" |
| 3765 | ||
| 3766 | msgctxt "#90755" | |
| 3767 | msgid "Manage Sources" | |
| 3768 | msgstr "Gerenciar Fontes" | |
| 3769 | ||
| 3770 | msgctxt "#90756" | |
| 3771 | msgid "No sources available" | |
| 3772 | msgstr "Nenhuma fonte disponível" | |
| 3773 | ||
| 3774 | msgctxt "#90179" | |
| 3775 | msgid "Stinger Notifications" | |
| 3776 | msgstr "Notificações de stinger" | |
| 3777 | ||
| 3778 | msgctxt "#90180" | |
| 3779 | msgid "Show a notification when a movie has post-credit scenes." | |
| 3780 | msgstr "Mostrar uma notificação quando um filme tiver cenas pós-créditos." | |
| 3781 | ||
| 3782 | msgctxt "#90181" | |
| 3783 | msgid "Stinger Notification Time" | |
| 3784 | msgstr "Momento da notificação de stinger" | |
| 3785 | ||
| 3786 | msgctxt "#90182" | |
| 3787 | msgid "Seconds before the end to show the stinger notification." | |
| 3788 | msgstr "Segundos antes do fim para mostrar a notificação de stinger." | |
| 3789 | ||
| 3790 | msgctxt "#90183" | |
| 3791 | msgid "Stay tuned after the credits!" | |
| 3792 | msgstr "Fique ligado após os créditos!" | |
| 3793 | ||
| 3794 | msgctxt "#90757" | |
| 3795 | msgid "Image Resolution" | |
| 3796 | msgstr "Resolução da imagem" | |
| 3797 | ||
| 3798 | msgctxt "#90758" | |
| 3799 | msgid "Low" | |
| 3800 | msgstr "Baixa" | |
| 3801 | ||
| 3802 | msgctxt "#90759" | |
| 3803 | msgid "Medium" | |
| 3804 | msgstr "Média" | |
| 3805 | ||
| 3806 | msgctxt "#90760" | |
| 3807 | msgid "High" | |
| 3808 | msgstr "Alta" | |
| 3809 | ||
| 3810 | msgctxt "#90761" | |
| 3811 | msgid "Original" | |
| 3812 | msgstr "Original" | |
| 3813 | ||
| 3814 | msgctxt "#90762" | |
| 3815 | msgid "Enable RPDB Posters" | |
| 3816 | msgstr "Ativar pôsteres RPDB" | |
| 3817 | ||
| 3818 | msgctxt "#90763" | |
| 3819 | msgid "RPDB API Key" | |
| 3820 | msgstr "Chave da API do RPDB" | |
| 3821 | ||
| 3822 | msgctxt "#90764" | |
| 3823 | msgid "Get your API key at ratingposterdb.com" | |
| 3824 | msgstr "Obtenha sua chave de API em ratingposterdb.com" | |
| 3825 | ||
| 3826 | msgctxt "#90765" | |
| 3827 | msgid "Filter by Codecs" | |
| 3828 | msgstr "Filtrar por codecs" | |
| 3829 | ||
| 3830 | msgctxt "#90766" | |
| 3831 | msgid "Filter by HDR" | |
| 3832 | msgstr "Filtrar por HDR" | |
| 3833 | ||
| 3834 | msgctxt "#90767" | |
| 3835 | msgid "Include HEVC / H.265" | |
| 3836 | msgstr "Incluir HEVC / H.265" | |
| 3837 | ||
| 3838 | msgctxt "#90768" | |
| 3839 | msgid "Include AV1" | |
| 3840 | msgstr "Incluir AV1" | |
| 3841 | ||
| 3842 | msgctxt "#90769" | |
| 3843 | msgid "Include H.264" | |
| 3844 | msgstr "Incluir H.264" | |
| 3845 | ||
| 3846 | msgctxt "#90770" | |
| 3847 | msgid "Include Dolby Vision" | |
| 3848 | msgstr "Incluir Dolby Vision" | |
| 3849 | ||
| 3850 | msgctxt "#90771" | |
| 3851 | msgid "Include HDR10 / HDR10+" | |
| 3852 | msgstr "Incluir HDR10 / HDR10+" | |
| 3853 | ||
| 3854 | msgctxt "#90772" | |
| 3855 | msgid "Include HDR (generic)" | |
| 3856 | msgstr "Incluir HDR (genérico)" | |
| 3857 | ||
| 3858 | msgctxt "#90774" | |
| 3859 | msgid "Organize downloads by type" | |
| 3860 | msgstr "Organizar downloads por tipo" | |
| 3861 | ||
| 3862 | msgctxt "#90775" | |
| 3863 | msgid "Movies folder name" | |
| 3864 | msgstr "Nome da pasta de filmes" | |
| 3865 | ||
| 3866 | msgctxt "#90776" | |
| 3867 | msgid "TV Shows folder name" | |
| 3868 | msgstr "Nome da pasta de séries" | |
| 3869 | ||
| 3870 | msgctxt "#90777" | |
| 3871 | msgid "Pause" | |
| 3872 | msgstr "Pausar" | |
| 3873 | ||
| 3874 | msgctxt "#90778" | |
| 3875 | msgid "Resume" | |
| 3876 | msgstr "Retomar" | |
| 3877 | ||
| 3878 | msgctxt "#90779" | |
| 3879 | msgid "Downloading" | |
| 3880 | msgstr "Baixando" | |
| 3881 | ||
| 3882 | msgctxt "#90780" | |
| 3883 | msgid "Paused" | |
| 3884 | msgstr "Pausado" | |
| 3885 | ||
| 3886 | msgctxt "#90781" | |
| 3887 | msgid "Completed" | |
| 3888 | msgstr "Concluído" | |
| 3889 | ||
| 3890 | msgctxt "#90782" | |
| 3891 | msgid "Delete" | |
| 3892 | msgstr "Excluir" | |
| 3893 | ||
| 3894 | msgctxt "#90783" | |
| 3895 | msgid "Startup" | |
| 3896 | msgstr "Inicialização" | |
| 3897 | ||
| 3898 | msgctxt "#90784" | |
| 3899 | msgid "Auto-start Jacktook on Kodi boot" | |
| 3900 | msgstr "Iniciar Jacktook automaticamente na inicialização do Kodi" | |
| 3901 | ||
| 3902 | msgctxt "#90785" | |
| 3903 | msgid "Automatically open Jacktook when Kodi starts" | |
| 3904 | msgstr "Abrir Jacktook automaticamente quando o Kodi iniciar" | |
| 3905 | ||
| 3906 | msgctxt "#90786" | |
| 3907 | msgid "Widgets" | |
| 3908 | msgstr "Widgets" | |
| 3909 | ||
| 3910 | msgctxt "#90787" | |
| 3911 | msgid "Widget refresh interval (minutes)" | |
| 3912 | msgstr "Intervalo de atualização de widgets (minutos)" | |
| 3913 | ||
| 3914 | msgctxt "#90788" | |
| 3915 | msgid "Set to 0 to disable automatic widget refresh" | |
| 3916 | msgstr "Definir como 0 para desativar a atualização automática" | |
| 3917 | ||
| 3918 | msgctxt "#90789" | |
| 3919 | msgid "Show refresh notification" | |
| 3920 | msgstr "Mostrar notificação de atualização" | |
| 3921 | ||
| 3922 | msgctxt "#90790" | |
| 3923 | msgid "Display a notification when widgets are refreshed" | |
| 3924 | msgstr "Mostrar uma notificação quando os widgets são atualizados" | |
| 3925 | ||
| 3926 | msgctxt "#90791" | |
| 3927 | msgid "Download to Device" | |
| 3928 | msgstr "Baixar para o dispositivo" | |
| 3929 | ||
| 3930 | msgctxt "#90792" | |
| 3931 | msgid "Download Manager" | |
| 3932 | msgstr "Gerenciador de downloads" | |
| 3933 | ||
| 3934 | msgctxt "#90793" | |
| 3935 | msgid "No active downloads" | |
| 3936 | msgstr "Nenhum download ativo" | |
| 3937 | ||
| 3938 | msgctxt "#90794" | |
| 3939 | msgid "Progress" | |
| 3940 | msgstr "Progresso" | |
| 3941 | ||
| 3942 | msgctxt "#90795" | |
| 3943 | msgid "Speed" | |
| 3944 | msgstr "Velocidade" | |
| 3945 | ||
| 3946 | msgctxt "#90796" | |
| 3947 | msgid "ETA" | |
| 3948 | msgstr "Tempo restante" | |
| 3949 | ||
| 3950 | msgctxt "#90797" | |
| 3951 | msgid "Size" | |
| 3952 | msgstr "Tamanho" | |
| 3953 | ||
| 3954 | msgctxt "#90798" | |
| 3955 | msgid "Pause" | |
| 3956 | msgstr "Pausar" | |
| 3957 | ||
| 3958 | msgctxt "#90799" | |
| 3959 | msgid "Resume" | |
| 3960 | msgstr "Retomar" | |
| 3961 | ||
| 3962 | msgctxt "#90800" | |
| 3963 | msgid "Cancel" | |
| 3964 | msgstr "Cancelar" | |
| 3965 | ||
| 3966 | msgctxt "#90801" | |
| 3967 | msgid "Delete" | |
| 3968 | msgstr "Excluir" | |
| 3969 | ||
| 3970 | msgctxt "#90802" | |
| 3971 | msgid "Clear Completed" | |
| 3972 | msgstr "Limpar concluídos" | |
| 3973 | ||
| 3974 | msgctxt "#90803" | |
| 3975 | msgid "Download not yet completed" | |
| 3976 | msgstr "Download ainda não concluído" | |
| 3977 | ||
| 3978 | msgctxt "#90804" | |
| 3979 | msgid "File not found" | |
| 3980 | msgstr "Arquivo não encontrado" | |
| 3981 | ||
| 3982 | msgctxt "#90805" | |
| 3983 | msgid "Download Manager" | |
| 3984 | msgstr "Gerenciador de downloads" | |
| 3985 | ||
| 3986 | msgctxt "#90806" | |
| 3987 | msgid "My Downloads" | |
| 3988 | msgstr "Meus downloads" | |
| 3989 | ||
| 3990 | msgctxt "#90807" | |
| 3991 | msgid "Connecting..." | |
| 3992 | msgstr "Conectando..." | |
| 3993 | ||
| 3994 | msgctxt "#90808" | |
| 3995 | msgid "Close" | |
| 3996 | msgstr "Fechar" | |
| 3997 | ||
| 3998 | msgctxt "#90809" | |
| 3999 | msgid "Delete download?" | |
| 4000 | msgstr "Excluir download?" | |
| 4001 | ||
| 4002 | msgctxt "#90810" | |
| 4003 | msgid "This will permanently delete the downloaded file. Are you sure?" | |
| 4004 | msgstr "Isso excluirá permanentemente o arquivo baixado. Tem certeza?" | |
| 4005 | ||
| 4006 | msgctxt "#90811" | |
| 4007 | msgid "Completed" | |
| 4008 | msgstr "Concluído" | |
| 4009 | ||
| 4010 | msgctxt "#90812" | |
| 4011 | msgid "Excluded TMDB Languages" | |
| 4012 | msgstr "Idiomas excluídos do TMDB" | |
| 4013 | ||
| 4014 | msgctxt "#90813" | |
| 4015 | msgid "Select languages to exclude" | |
| 4016 | msgstr "Selecione os idiomas para excluir" | |
| 4017 | ||
| 4018 | msgctxt "#90814" | |
| 4019 | msgid "Choose original languages to hide from TMDB listings" | |
| 4020 | msgstr "Escolha os idiomas originais para ocultar das listagens do TMDB" | |
| 4021 | ||
| 4022 | msgctxt "#90815" | |
| 4023 | msgid "Support Jacktook" | |
| 4024 | msgstr "Apoiar o Jacktook" | |
| 4025 | ||
| 4026 | msgctxt "#90816" | |
| 4027 | msgid "Support Jacktook development with an optional Ko-fi donation." | |
| 4028 | msgstr "Apoie o desenvolvimento do Jacktook com uma doação opcional no Ko-fi." | |
| 4029 | ||
| 4030 | msgctxt "#90817" | |
| 4031 | msgid "Scan the QR code or open this link:" | |
| 4032 | msgstr "Escaneie o código QR ou abra este link:" | |
| 4033 | ||
| 4034 | msgctxt "#90818" | |
| 4035 | msgid "Jacktook was updated with new improvements. Donations are optional, but they help keep development active. Would you like to see the support link?" | |
| 4036 | msgstr "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 | ||
| 4038 | msgctxt "#90300" | |
| 4039 | msgid "External Scraper" | |
| 4040 | msgstr "Scraper Externo" | |
| 4041 | ||
| 4042 | msgctxt "#90301" | |
| 4043 | msgid "Enable external scraper module integration." | |
| 4044 | msgstr "Ativar a integração do módulo scraper externo." | |
| 4045 | ||
| 4046 | msgctxt "#90302" | |
| 4047 | msgid "Choose External Scraper Module" | |
| 4048 | msgstr "Escolher Módulo Scraper Externo" | |
| 4049 | ||
| 4050 | msgctxt "#90303" | |
| 4051 | msgid "Select an external scraper addon module to use for torrent search." | |
| 4052 | msgstr "Selecione um módulo addon scraper externo para pesquisar torrents." | |
| 4053 | ||
| 4054 | msgctxt "#90304" | |
| 4055 | msgid "Choose External Scraper" | |
| 4056 | msgstr "Escolher Scraper Externo" | |
| 4057 | ||
| 4058 | msgctxt "#90305" | |
| 4059 | msgid "Selected Module" | |
| 4060 | msgstr "Módulo Selecionado" | |
| 4061 | ||
| 4062 | msgctxt "#90306" | |
| 4063 | msgid "Open External Scraper Settings" | |
| 4064 | msgstr "Abrir Configurações do Scraper Externo" | |
| 4065 | ||
| 4066 | msgctxt "#90307" | |
| 4067 | msgid "Open the settings for the selected external scraper module." | |
| 4068 | msgstr "Abrir as configurações do módulo scraper externo selecionado." | |
| 4069 | ||
| 4070 | msgctxt "#90382" | |
| 4071 | msgid "Please set a valid Trakt Client ID Key" | |
| 4072 | msgstr "Defina uma chave de ID de cliente do Trakt válida" | |
| 4073 | ||
| 4074 | msgctxt "#90383" | |
| 4075 | msgid "Please set a valid Trakt Client Secret Key" | |
| 4076 | msgstr "Defina uma chave secreta de cliente do Trakt válida" | |
| 4077 | ||
| 4078 | msgctxt "#90384" | |
| 4079 | msgid "Trakt Error Authorizing" | |
| 4080 | msgstr "Erro ao autorizar o Trakt" | |
| 4081 | ||
| 4082 | msgctxt "#90385" | |
| 4083 | msgid "Trakt Account Authorized" | |
| 4084 | msgstr "Conta do Trakt autorizada" | |
| 4085 | ||
| 4086 | msgctxt "#90386" | |
| 4087 | msgid "You are now logged out from Trakt.tv" | |
| 4088 | msgstr "Você saiu do Trakt.tv" | |
| 4089 | ||
| 4090 | msgctxt "#90387" | |
| 4091 | msgid "Failed to mark as watched" | |
| 4092 | msgstr "Falha ao marcar como assistido" | |
| 4093 | ||
| 4094 | msgctxt "#90388" | |
| 4095 | msgid "Marked as watched" | |
| 4096 | msgstr "Marcado como assistido" | |
| 4097 | ||
| 4098 | msgctxt "#90389" | |
| 4099 | msgid "No results found" | |
| 4100 | msgstr "Nenhum resultado encontrado" | |
| 4101 | ||
| 4102 | msgctxt "#90390" | |
| 4103 | msgid "Invalid mode" | |
| 4104 | msgstr "Modo inválido" | |
| 4105 | ||
| 4106 | msgctxt "#90391" | |
| 4107 | msgid "No recommendations found" | |
| 4108 | msgstr "Nenhuma recomendação encontrada" | |
| 4109 | ||
| 4110 | msgctxt "#90392" | |
| 4111 | msgid "No similar items found" | |
| 4112 | msgstr "Nenhum item semelhante encontrado" | |
| 4113 | ||
| 4114 | msgctxt "#90393" | |
| 4115 | msgid "TMDB metadata not found" | |
| 4116 | msgstr "Metadados do TMDB não encontrados" | |
| 4117 | ||
| 4118 | msgctxt "#90394" | |
| 4119 | msgid "No TV shows found" | |
| 4120 | msgstr "Nenhuma série encontrada" | |
| 4121 | ||
| 4122 | msgctxt "#90395" | |
| 4123 | msgid "No shows airing today" | |
| 4124 | msgstr "Nenhuma série sendo exibida hoje" | |
| 4125 | ||
| 4126 | msgctxt "#90396" | |
| 4127 | msgid "Invalid collection query" | |
| 4128 | msgstr "Consulta de coleção inválida" | |
| 4129 | ||
| 4130 | msgctxt "#90397" | |
| 4131 | msgid "No keywords found" | |
| 4132 | msgstr "Nenhuma palavra-chave encontrada" | |
| 4133 | ||
| 4134 | msgctxt "#90398" | |
| 4135 | msgid "No keyword ID provided" | |
| 4136 | msgstr "Nenhum ID de palavra-chave fornecido" | |
| 4137 | ||
| 4138 | msgctxt "#90399" | |
| 4139 | msgid "No results found for this keyword" | |
| 4140 | msgstr "Nenhum resultado encontrado para esta palavra-chave" | |
| 4141 | ||
| 4142 | msgctxt "#90400" | |
| 4143 | msgid "No TMDB ID found" | |
| 4144 | msgstr "Nenhum ID do TMDB encontrado" | |
| 4145 | ||
| 4146 | msgctxt "#90401" | |
| 4147 | msgid "Unsupported media type or missing external_ids" | |
| 4148 | msgstr "Tipo de mídia não suportado ou external_ids ausentes" | |
| 4149 | ||
| 4150 | msgctxt "#90402" | |
| 4151 | msgid "Refreshed Metadata" | |
| 4152 | msgstr "Metadados atualizados" | |
| 4153 | ||
| 4154 | msgctxt "#90403" | |
| 4155 | msgid "No Stremio addons selected" | |
| 4156 | msgstr "Nenhum addon do Stremio selecionado" | |
| 4157 | ||
| 4158 | msgctxt "#90404" | |
| 4159 | msgid "No suitable source found for auto play." | |
| 4160 | msgstr "Nenhuma fonte adequada encontrada para reprodução automática." | |
| 4161 | ||
| 4162 | msgctxt "#90405" | |
| 4163 | msgid "No sources found with the preferred quality." | |
| 4164 | msgstr "Nenhuma fonte encontrada com a qualidade preferida." | |
| 4165 | ||
| 4166 | msgctxt "#90406" | |
| 4167 | msgid "No languages found" | |
| 4168 | msgstr "Nenhum idioma encontrado" | |
| 4169 | ||
| 4170 | msgctxt "#90407" | |
| 4171 | msgid "Failed to resolve playback source" | |
| 4172 | msgstr "Falha ao resolver a fonte de reprodução" | |
| 4173 | ||
| 4174 | msgctxt "#90408" | |
| 4175 | msgid "Trakt not authorized. Please authenticate." | |
| 4176 | msgstr "Trakt não autorizado. Faça a autenticação." | |
| 4177 | ||
| 4178 | msgctxt "#90409" | |
| 4179 | msgid "Added to Trakt Watchlist" | |
| 4180 | msgstr "Adicionado à lista de acompanhamento do Trakt" | |
| 4181 | ||
| 4182 | msgctxt "#90428" | |
| 4183 | msgid "Database read error. File may be corrupted." | |
| 4184 | msgstr "Erro ao ler o banco de dados. O arquivo pode estar corrompido." | |
| 4185 | ||
| 4186 | msgctxt "#90429" | |
| 4187 | msgid "Failed to delete data. Database error." | |
| 4188 | msgstr "Falha ao excluir dados. Erro no banco de dados." | |
| 4189 | ||
| 4190 | msgctxt "#90430" | |
| 4191 | msgid "Failed to clear cache. Database in use." | |
| 4192 | msgstr "Falha ao limpar o cache. Banco de dados em uso." | |
| 4193 | ||
| 4194 | msgctxt "#90458" | |
| 4195 | msgid "OpenSubtitles Stremio: No results found." | |
| 4196 | msgstr "OpenSubtitles Stremio: nenhum resultado encontrado." | |
| 4197 | ||
| 4198 | msgctxt "#90460" | |
| 4199 | msgid "Subtitle Manager: No subtitles available for this video." | |
| 4200 | msgstr "Gerenciador de legendas: não há legendas disponíveis para este vídeo." | |
| 4201 | ||
| 4202 | msgctxt "#90468" | |
| 4203 | msgid "Search menu" | |
| 4204 | msgstr "Menu de pesquisa" | |
| 4205 | ||
| 4206 | msgctxt "#90469" | |
| 4207 | msgid "Continue watching menu" | |
| 4208 | msgstr "Menu Continuar assistindo" | |
| 4209 | ||
| 4210 | msgctxt "#90470" | |
| 4211 | msgid "TV Shows Items" | |
| 4212 | msgstr "Itens de séries" | |
| 4213 | ||
| 4214 | msgctxt "#90471" | |
| 4215 | msgid "Movies Items" | |
| 4216 | msgstr "Itens de filmes" | |
| 4217 | ||
| 4218 | msgctxt "#90472" | |
| 4219 | msgid "Anime menu" | |
| 4220 | msgstr "Menu de anime" | |
| 4221 | ||
| 4222 | msgctxt "#90473" | |
| 4223 | msgid "TV Menu" | |
| 4224 | msgstr "Menu de TV" | |
| 4225 | ||
| 4226 | msgctxt "#90474" | |
| 4227 | msgid "Library menu" | |
| 4228 | msgstr "Menu da biblioteca" | |
| 4229 | ||
| 4230 | msgctxt "#90475" | |
| 4231 | msgid "Torrents Management" | |
| 4232 | msgstr "Gerenciamento de torrents" | |
| 4233 | ||
| 4234 | msgctxt "#90476" | |
| 4235 | msgid "Telegram menu" | |
| 4236 | msgstr "Menu do Telegram" | |
| 4237 | ||
| 4238 | msgctxt "#90477" | |
| 4239 | msgid "Cloud Storage" | |
| 4240 | msgstr "Armazenamento na nuvem" | |
| 4241 | ||
| 4242 | msgctxt "#90478" | |
| 4243 | msgid "Downloads Management" | |
| 4244 | msgstr "Gerenciamento de downloads" | |
| 4245 | ||
| 4246 | msgctxt "#90479" | |
| 4247 | msgid "Settings Management" | |
| 4248 | msgstr "Gerenciamento de configurações" | |
| 4249 | ||
| 4250 | msgctxt "#90480" | |
| 4251 | msgid "History Menu" | |
| 4252 | msgstr "Menu de histórico" | |
| 4253 | ||
| 4254 | msgctxt "#90481" | |
| 4255 | msgid "Files History" | |
| 4256 | msgstr "Histórico de arquivos" | |
| 4257 | ||
| 4258 | msgctxt "#90482" | |
| 4259 | msgid "Titles History" | |
| 4260 | msgstr "Histórico de títulos" | |
| 4261 | ||
| 4262 | msgctxt "#90483" | |
| 4263 | msgid "Titles Calendar" | |
| 4264 | msgstr "Calendário de títulos" | |
| 4265 | ||
| 4266 | msgctxt "#90484" | |
| 4267 | msgid "My Shows" | |
| 4268 | msgstr "Minhas séries" | |
| 4269 | ||
| 4270 | msgctxt "#90485" | |
| 4271 | msgid "My Movies" | |
| 4272 | msgstr "Meus filmes" | |
| 4273 | ||
| 4274 | msgctxt "#90486" | |
| 4275 | msgid "Upcoming Episodes" | |
| 4276 | msgstr "Próximos episódios" | |
| 4277 | ||
| 4278 | msgctxt "#90487" | |
| 4279 | msgid "Discovery List" | |
| 4280 | msgstr "Lista de descoberta" | |
| 4281 | ||
| 4282 | msgctxt "#90488" | |
| 4283 | msgid "Library List" | |
| 4284 | msgstr "Lista da biblioteca" | |
| 4285 | ||
| 4286 | msgctxt "#90489" | |
| 4287 | msgid "Trakt Recommendations" | |
| 4288 | msgstr "Recomendações do Trakt" | |
| 4289 | ||
| 4290 | msgctxt "#90490" | |
| 4291 | msgid "Trending Lists" | |
| 4292 | msgstr "Listas em alta" | |
| 4293 | ||
| 4294 | msgctxt "#90491" | |
| 4295 | msgid "Popular Lists" | |
| 4296 | msgstr "Listas populares" | |
| 4297 | ||
| 4298 | msgctxt "#90492" | |
| 4299 | msgid "Search Lists" | |
| 4300 | msgstr "Pesquisar listas" | |
| 4301 | ||
| 4302 | msgctxt "#90493" | |
| 4303 | msgid "Recent Activity" | |
| 4304 | msgstr "Atividade recente" | |
| 4305 | ||
| 4306 | msgctxt "#90494" | |
| 4307 | msgid "Downloads List" | |
| 4308 | msgstr "Lista de downloads" | |
| 4309 | ||
| 4310 | msgctxt "#90495" | |
| 4311 | msgid "Download New" | |
| 4312 | msgstr "Novo download" | |
| 4313 | ||
| 4314 | msgctxt "#90496" | |
| 4315 | msgid "Cartoons Popular" | |
| 4316 | msgstr "Desenhos animados populares" | |
| 4317 | ||
| 4318 | msgctxt "#90497" | |
| 4319 | msgid "Animation Popular" | |
| 4320 | msgstr "Animação popular" | |
| 4321 | ||
| 4322 | msgctxt "#90500" | |
| 4323 | msgid "Translating subtitle..." | |
| 4324 | msgstr "Traduzindo legenda..." | |
| 4325 | ||
| 4326 | msgctxt "#90501" | |
| 4327 | msgid "Translation done." | |
| 4328 | msgstr "Tradução concluída." | |
| 4329 | ||
| 4330 | msgctxt "#90502" | |
| 4331 | msgid "Searching subtitles..." | |
| 4332 | msgstr "Pesquisando legendas..." | |
| 4333 | ||
| 4334 | msgctxt "#90503" | |
| 4335 | msgid "No subtitles found" | |
| 4336 | msgstr "Nenhuma legenda encontrada" | |
| 4337 | ||
| 4338 | msgctxt "#90504" | |
| 4339 | msgid "Failed to authenticate with DeepL. Please check your API key." | |
| 4340 | msgstr "Falha ao autenticar com o DeepL. Verifique sua chave de API." | |
| 4341 | ||
| 4342 | msgctxt "#90505" | |
| 4343 | msgid "DeepL API key is missing or invalid." | |
| 4344 | msgstr "A chave de API do DeepL está ausente ou é inválida." | |
| 4345 | ||
| 4346 | msgctxt "#90712" | |
| 4347 | msgid "Views" | |
| 4348 | msgstr "Visualizações" | |
| 4349 | ||
| 4350 | msgctxt "#90713" | |
| 4351 | msgid "Set Main View" | |
| 4352 | msgstr "Definir visualização principal" | |
| 4353 | ||
| 4354 | msgctxt "#90714" | |
| 4355 | msgid "Set Movies View" | |
| 4356 | msgstr "Definir visualização de filmes" | |
| 4357 | ||
| 4358 | msgctxt "#90715" | |
| 4359 | msgid "Set TV Shows View" | |
| 4360 | msgstr "Definir visualização de séries" | |
| 4361 | ||
| 4362 | msgctxt "#90716" | |
| 4363 | msgid "Set Seasons View" | |
| 4364 | msgstr "Definir visualização de temporadas" | |
| 4365 | ||
| 4366 | msgctxt "#90717" | |
| 4367 | msgid "Set Episodes View" | |
| 4368 | msgstr "Definir visualização de episódios" | |
| 4369 | ||
| 4370 | msgctxt "#90718" | |
| 4371 | msgid "Set Library View" | |
| 4372 | msgstr "Definir visualização da biblioteca" | |
| 4373 | ||
| 4374 | msgctxt "#90719" | |
| 4375 | msgid "Set History View" | |
| 4376 | msgstr "Definir visualização do histórico" | |
| 4377 | ||
| 4378 | msgctxt "#90720" | |
| 4379 | msgid "Set Downloads View" | |
| 4380 | msgstr "Definir visualização de downloads" | |
| 4381 | ||
| 4382 | msgctxt "#90721" | |
| 4383 | msgid "Save Current View" | |
| 4384 | msgstr "Salvar visualização atual" | |
| 4385 | ||
| 4386 | msgctxt "#90722" | |
| 4387 | msgid "View saved" | |
| 4388 | msgstr "Visualização salva" | |
| 4389 | ||
| 4390 | msgctxt "#90723" | |
| 4391 | msgid "Could not detect the current view" | |
| 4392 | msgstr "Não foi possível detectar a visualização atual" | |
| 4393 | ||
| 4394 | msgctxt "#90724" | |
| 4395 | msgid "Saved views reset" | |
| 4396 | msgstr "Visualizações salvas redefinidas" | |
| 4397 | ||
| 4398 | msgctxt "#90725" | |
| 4399 | msgid "Reset Saved Views" | |
| 4400 | msgstr "Redefinir visualizações salvas" | |
| 4401 | ||
| 4402 | msgctxt "#90726" | |
| 4403 | msgid "Open this screen, change the Kodi view manually, then select Save Current View." | |
| 4404 | msgstr "Abra esta tela, altere manualmente a visualização do Kodi e depois selecione Salvar visualização atual." | |
| 4405 | ||
| 4406 | msgctxt "#90730" | |
| 4407 | msgid "Search with title + year" | |
| 4408 | msgstr "Pesquisar com título + ano" | |
| 4409 | ||
| 4410 | msgctxt "#90731" | |
| 4411 | msgid "Search with original title" | |
| 4412 | msgstr "Pesquisar com título original" | |
| 4413 | ||
| 4414 | msgctxt "#90732" | |
| 4415 | msgid "Search with original title + year" | |
| 4416 | msgstr "Pesquisar com título original + ano" | |
| 4417 | ||
| 4418 | msgctxt "#90733" | |
| 4419 | msgid "Search modes" | |
| 4420 | msgstr "Modos de pesquisa" | |
| 4421 | ||
| 4422 | msgctxt "#90734" | |
| 4423 | msgid "Enter year" | |
| 4424 | msgstr "Digite o ano" | |
| 4425 | ||
| 4426 | msgctxt "#90735" | |
| 4427 | msgid "Invalid year" | |
| 4428 | msgstr "Ano inválido" | |
| 4429 | ||
| 4430 | msgctxt "#90736" | |
| 4431 | msgid "Edit title" | |
| 4432 | msgstr "Editar título" | |
| 4433 | ||
| 4434 | msgctxt "#90737" | |
| 4435 | msgid "Invalid season" | |
| 4436 | msgstr "Temporada inválida" | |
| 4437 | ||
| 4438 | msgctxt "#90738" | |
| 4439 | msgid "Episode" | |
| 4440 | msgstr "Episódio" | |
| 4441 | ||
| 4442 | msgctxt "#90739" | |
| 4443 | msgid "Invalid episode" | |
| 4444 | msgstr "Episódio inválido" | |
| 4445 | ||
| 4446 | msgctxt "#90819" | |
| 4447 | msgid "Rename Stremio add-on" | |
| 4448 | msgstr "Renomear complemento do Stremio" | |
| 4449 | ||
| 4450 | msgctxt "#90824" | |
| 4451 | msgid "Rename Stremio catalog" | |
| 4452 | msgstr "Renomear catálogo do Stremio" | |
| 4453 | ||
| 4454 | msgctxt "#90827" | |
| 4455 | msgid "Set a Kodi-only display alias for a Stremio add-on." | |
| 4456 | msgstr "Definir um alias de exibição apenas no Kodi para um complemento do Stremio." | |
| 4457 | ||
| 4458 | msgctxt "#90828" | |
| 4459 | msgid "Set a Kodi-only display alias for a Stremio catalog." | |
| 4460 | msgstr "Definir um alias de exibição apenas no Kodi para um catálogo do Stremio." | |
| 4461 | ||
| 4462 | msgctxt "#90832" | |
| 4463 | msgid "Stremio: Addons Management" | |
| 4464 | msgstr "Stremio: Gerenciamento de complementos" | |
| 4465 | ||
| 4466 | msgctxt "#90833" | |
| 4467 | msgid "Stremio: Catalogs & Visibility" | |
| 4468 | msgstr "Stremio: Catálogos e visibilidade" | |
| 4469 | ||
| 4470 | msgctxt "#90834" | |
| 4471 | msgid "Stremio: Rename & Customization" | |
| 4472 | msgstr "Stremio: Renomeação e personalização" | |
| 4473 | ||
| 4474 | msgctxt "#90835" | |
| 4475 | msgid "Stremio: Search & Behavior" | |
| 4476 | msgstr "Stremio: Pesquisa e comportamento" |
| … | … | @@ -312,8 +312,8 @@ msgid "Unknown" |
| 312 | 312 | msgstr "Desconhecido" |
| 313 | 313 | |
| 314 | 314 | msgctxt "#30700" |
| 315 | msgid "Buffer and play" | |
| 316 | msgstr "Bufferizar e reproduzir" | |
| 315 | msgid "Play Torrent" | |
| 316 | msgstr "Reproduzir torrent" | |
| 317 | 317 | |
| 318 | 318 | msgctxt "#30701" |
| 319 | 319 | msgid "Stop downloading" |
| … | … | @@ -431,6 +431,58 @@ msgctxt "#30939" |
| 431 | 431 | msgid "Auto-resume from last position without asking" |
| 432 | 432 | msgstr "Retomar automaticamente da última posição sem perguntar" |
| 433 | 433 | |
| 434 | msgctxt "#30940" | |
| 435 | msgid "Use Percentage Trigger" | |
| 436 | msgstr "Usar acionamento por percentagem" | |
| 437 | ||
| 438 | msgctxt "#30941" | |
| 439 | msgid "Show Dialog at Playback Percentage" | |
| 440 | msgstr "Mostrar diálogo na percentagem de reprodução" | |
| 441 | ||
| 442 | msgctxt "#30942" | |
| 443 | msgid "Trigger the next-episode dialog based on percentage of video played instead of seconds remaining." | |
| 444 | msgstr "Acionar o diálogo do próximo episódio com base na percentagem do vídeo reproduzida em vez dos segundos restantes." | |
| 445 | ||
| 446 | msgctxt "#30943" | |
| 447 | msgid "The percentage at which the next-episode dialog appears (e.g. 95 means 95% of the video)." | |
| 448 | msgstr "A percentagem em que o diálogo do próximo episódio aparece (por exemplo, 95 significa 95% do vídeo)." | |
| 449 | ||
| 450 | msgctxt "#30944" | |
| 451 | msgid "Auto-Play Timeout" | |
| 452 | msgstr "Tempo limite da reprodução automática" | |
| 453 | ||
| 454 | msgctxt "#30945" | |
| 455 | msgid "Seconds before auto-playing next episode. 0 = disabled, dialog waits for user input." | |
| 456 | msgstr "Segundos antes de reproduzir automaticamente o próximo episódio. 0 = desativado, o diálogo aguarda a entrada do utilizador." | |
| 457 | ||
| 458 | ||
| 459 | msgctxt "#30946" | |
| 460 | msgid "Offcloud Configuration" | |
| 461 | msgstr "Configuração do Offcloud" | |
| 462 | ||
| 463 | msgctxt "#30947" | |
| 464 | msgid "Enable Offcloud cached torrent and cloud transfer support." | |
| 465 | msgstr "Ativar suporte a torrents em cache e transferências em nuvem do Offcloud." | |
| 466 | ||
| 467 | msgctxt "#30948" | |
| 468 | msgid "Your Offcloud account username/email." | |
| 469 | msgstr "O teu nome de utilizador ou email do Offcloud." | |
| 470 | ||
| 471 | msgctxt "#30949" | |
| 472 | msgid "Offcloud API key from Account API Key settings." | |
| 473 | msgstr "Chave API do Offcloud nas definições da Conta → Chave API." | |
| 474 | ||
| 475 | msgctxt "#30950" | |
| 476 | msgid "View Offcloud account information." | |
| 477 | msgstr "Ver informações da conta Offcloud." | |
| 478 | ||
| 479 | msgctxt "#30951" | |
| 480 | msgid "Authorize add-on to access Offcloud account." | |
| 481 | msgstr "Autorizar o addon a aceder à conta Offcloud." | |
| 482 | ||
| 483 | msgctxt "#30952" | |
| 484 | msgid "Revoke Offcloud account access." | |
| 485 | msgstr "Revogar o acesso da conta Offcloud." | |
| 434 | 486 | msgctxt "#30728" |
| 435 | 487 | msgid "Seeds" |
| 436 | 488 | msgstr "Sementes" |
| … | … | @@ -459,6 +511,10 @@ msgctxt "#30734" |
| 459 | 511 | msgid "Enable" |
| 460 | 512 | msgstr "Ativar" |
| 461 | 513 | |
| 514 | msgctxt "#30735" | |
| 515 | msgid "Still Watching? Threshold" | |
| 516 | msgstr "Limite de Ainda a ver?" | |
| 517 | ||
| 462 | 518 | msgctxt "#30736" |
| 463 | 519 | msgid "Subtitle Language" |
| 464 | 520 | msgstr "Idioma das legendas" |
| … | … | @@ -607,6 +663,10 @@ msgctxt "#30772" |
| 607 | 663 | msgid "Zilean Host" |
| 608 | 664 | msgstr "Host Zilean" |
| 609 | 665 | |
| 666 | msgctxt "#30773" | |
| 667 | msgid "Number of consecutive auto-played episodes before asking 'Still Watching?'. Set to 0 to disable." | |
| 668 | msgstr "Número de episódios reproduzidos automaticamente em sequência antes de perguntar 'Ainda a ver?'. Defina como 0 para desativar." | |
| 669 | ||
| 610 | 670 | msgctxt "#30774" |
| 611 | 671 | msgid "Zilean Timeout" |
| 612 | 672 | msgstr "Zilean Tempo limite" |
| … | … | @@ -1556,8 +1616,8 @@ msgid "TV Calendar" |
| 1556 | 1616 | msgstr "Calendário de TV" |
| 1557 | 1617 | |
| 1558 | 1618 | msgctxt "#90022" |
| 1559 | msgid "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:" | |
| 1560 | msgstr "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:" | |
| 1619 | msgid "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." | |
| 1620 | msgstr "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." | |
| 1561 | 1621 | |
| 1562 | 1622 | msgctxt "#90023" |
| 1563 | 1623 | msgid "Support Jacktook" |
| … | … | @@ -1933,15 +1993,15 @@ msgstr "Palavra-passe" |
| 1933 | 1993 | |
| 1934 | 1994 | msgctxt "#90129" |
| 1935 | 1995 | msgid "Test Connection" |
| 1936 | msgstr "" | |
| 1996 | msgstr "Testar ligação" | |
| 1937 | 1997 | |
| 1938 | 1998 | msgctxt "#90131" |
| 1939 | 1999 | msgid "Webdav" |
| 1940 | msgstr "" | |
| 2000 | msgstr "WebDAV" | |
| 1941 | 2001 | |
| 1942 | 2002 | msgctxt "#90132" |
| 1943 | 2003 | msgid "Remote Path" |
| 1944 | msgstr "" | |
| 2004 | msgstr "Caminho remoto" | |
| 1945 | 2005 | |
| 1946 | 2006 | msgctxt "#90133" |
| 1947 | 2007 | msgid "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. |
| 1949 | 2009 | |
| 1950 | 2010 | msgctxt "#90134" |
| 1951 | 2011 | msgid "The username required for authenticating with your WebDAV server" |
| 1952 | msgstr "" | |
| 2012 | msgstr "O nome de utilizador necessário para autenticação no seu servidor WebDAV" | |
| 1953 | 2013 | |
| 1954 | 2014 | msgctxt "#90135" |
| 1955 | 2015 | msgid "The password for the specified username to authenticate with your WebDAV server" |
| 1956 | msgstr "" | |
| 2016 | msgstr "A palavra-passe do nome de utilizador especificado para autenticação no seu servidor WebDAV" | |
| 1957 | 2017 | |
| 1958 | 2018 | msgctxt "#90136" |
| 1959 | 2019 | msgid "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 |
| 1961 | 2021 | |
| 1962 | 2022 | msgctxt "#90137" |
| 1963 | 2023 | msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials." |
| 1964 | msgstr "" | |
| 2024 | msgstr "Clique para verificar se o cliente WebDAV consegue ligar-se com sucesso ao servidor configurado usando as credenciais fornecidas." | |
| 1965 | 2025 | |
| 1966 | 2026 | msgctxt "#90138" |
| 1967 | 2027 | msgid "Remove Custom Addon" |
| 1968 | msgstr "" | |
| 2028 | msgstr "Remover addon personalizado" | |
| 1969 | 2029 | |
| 1970 | 2030 | msgctxt "#90139" |
| 1971 | 2031 | msgid "Playback" |
| … | … | @@ -1973,43 +2033,59 @@ msgstr "Reprodução" |
| 1973 | 2033 | |
| 1974 | 2034 | msgctxt "#90140" |
| 1975 | 2035 | msgid "Super quick play" |
| 1976 | msgstr "" | |
| 2036 | msgstr "Reprodução super rápida" | |
| 1977 | 2037 | |
| 1978 | 2038 | msgctxt "#90141" |
| 1979 | 2039 | msgid "Re-ask to play last played video" |
| 1980 | msgstr "" | |
| 2040 | msgstr "Perguntar novamente para reproduzir o último vídeo reproduzido" | |
| 1981 | 2041 | |
| 1982 | 2042 | msgctxt "#90142" |
| 1983 | 2043 | msgid "Replay Dialog" |
| 1984 | msgstr "" | |
| 2044 | msgstr "Diálogo de repetição" | |
| 1985 | 2045 | |
| 1986 | 2046 | msgctxt "#90143" |
| 1987 | 2047 | msgid "Do you want to play last watched?" |
| 1988 | msgstr "" | |
| 2048 | msgstr "Deseja reproduzir o último visto?" | |
| 1989 | 2049 | |
| 1990 | 2050 | msgctxt "#90144" |
| 1991 | 2051 | msgid "Could not resolve playback URL" |
| 1992 | msgstr "" | |
| 2052 | msgstr "Não foi possível resolver o URL de reprodução" | |
| 1993 | 2053 | |
| 1994 | msgctxt "#90145" | |
| 1995 | msgid "Precaching Enabled" | |
| 1996 | msgstr "Pré-carregamento Ativado" | |
| 2054 | msgctxt "#90171" | |
| 2055 | msgid "Autoscrape Next Episode" | |
| 2056 | msgstr "Auto-scrape do próximo episódio" | |
| 1997 | 2057 | |
| 1998 | msgctxt "#90146" | |
| 1999 | msgid "Enable or disable precaching of next episodes." | |
| 2000 | msgstr "Ativar ou desativar o pré-carregamento dos próximos episódios." | |
| 2058 | msgctxt "#90172" | |
| 2059 | msgid "Pre-resolve the next episode's playback URL in the background while watching." | |
| 2060 | msgstr "Pré-resolver em segundo plano o URL de reprodução do próximo episódio enquanto assiste." | |
| 2001 | 2061 | |
| 2002 | msgctxt "#90147" | |
| 2003 | msgid "Precaching Episode Count" | |
| 2004 | msgstr "Contagem de Episódios para Pré-carregar" | |
| 2062 | msgctxt "#90173" | |
| 2063 | msgid "Autoscrape Threshold" | |
| 2064 | msgstr "Limiar do auto-scrape" | |
| 2005 | 2065 | |
| 2006 | msgctxt "#90148" | |
| 2007 | msgid "Number of next episodes to precache." | |
| 2008 | msgstr "Número de próximos episódios para pré-carregar." | |
| 2066 | msgctxt "#90174" | |
| 2067 | msgid "Percentage of playback to trigger background pre-resolution." | |
| 2068 | msgstr "Percentagem da reprodução para acionar a pré-resolução em segundo plano." | |
| 2069 | ||
| 2070 | msgctxt "#90175" | |
| 2071 | msgid "Autoscrape Episode Count" | |
| 2072 | msgstr "Número de episódios do auto-scrape" | |
| 2073 | ||
| 2074 | msgctxt "#90176" | |
| 2075 | msgid "Number of upcoming episodes to pre-resolve." | |
| 2076 | msgstr "Número de próximos episódios a pré-resolver." | |
| 2077 | ||
| 2078 | msgctxt "#90177" | |
| 2079 | msgid "Autoscrape Cache TTL" | |
| 2080 | msgstr "TTL da cache do auto-scrape" | |
| 2081 | ||
| 2082 | msgctxt "#90178" | |
| 2083 | msgid "Hours to keep resolved playback data in cache." | |
| 2084 | msgstr "Horas para manter em cache os dados de reprodução resolvidos." | |
| 2009 | 2085 | |
| 2010 | 2086 | msgctxt "#30901" |
| 2011 | 2087 | msgid "Show Only Stremio Catalogs" |
| 2012 | msgstr "" | |
| 2088 | msgstr "Mostrar apenas catálogos do Stremio" | |
| 2013 | 2089 | |
| 2014 | 2090 | msgctxt "#30902" |
| 2015 | 2091 | msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus." |
| … | … | @@ -2073,19 +2149,19 @@ msgstr "Filtrar por Qualidade" |
| 2073 | 2149 | |
| 2074 | 2150 | msgctxt "#32301" |
| 2075 | 2151 | msgid "Filter by Provider" |
| 2076 | msgstr "" | |
| 2152 | msgstr "Filtrar por fornecedor" | |
| 2077 | 2153 | |
| 2078 | 2154 | msgctxt "#32302" |
| 2079 | 2155 | msgid "Filter by Source" |
| 2080 | msgstr "" | |
| 2156 | msgstr "Filtrar por fonte" | |
| 2081 | 2157 | |
| 2082 | 2158 | msgctxt "#32303" |
| 2083 | 2159 | msgid "Filter by Language" |
| 2084 | msgstr "" | |
| 2160 | msgstr "Filtrar por idioma" | |
| 2085 | 2161 | |
| 2086 | 2162 | msgctxt "#32304" |
| 2087 | 2163 | msgid "Filter by Torrents" |
| 2088 | msgstr "" | |
| 2164 | msgstr "Filtrar por torrents" | |
| 2089 | 2165 | |
| 2090 | 2166 | msgctxt "#32305" |
| 2091 | 2167 | msgid "Filter by Debrid" |
| … | … | @@ -2093,7 +2169,7 @@ msgstr "Filtrar por Debrid" |
| 2093 | 2169 | |
| 2094 | 2170 | msgctxt "#32306" |
| 2095 | 2171 | msgid "Reset Filter" |
| 2096 | msgstr "" | |
| 2172 | msgstr "Repor filtro" | |
| 2097 | 2173 | |
| 2098 | 2174 | msgctxt "#30930" |
| 2099 | 2175 | msgid "Ping Addons" |
| … | … | @@ -2189,7 +2265,7 @@ msgstr "Ignorar a ordenação nativa dos extras do Stremio" |
| 2189 | 2265 | |
| 2190 | 2266 | msgctxt "#90212" |
| 2191 | 2267 | msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds." |
| 2192 | msgstr "" | |
| 2268 | msgstr "Se ativado, os addons especificados não terão os seus resultados reordenados nem filtrados pelos limites nativos de qualidade e tamanho do Jacktook." | |
| 2193 | 2269 | |
| 2194 | 2270 | msgctxt "#90213" |
| 2195 | 2271 | msgid "Select Bypassed Addons" |
| … | … | @@ -2197,7 +2273,7 @@ msgstr "Selecionar extras a ignorar" |
| 2197 | 2273 | |
| 2198 | 2274 | msgctxt "#90214" |
| 2199 | 2275 | msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering." |
| 2200 | msgstr "" | |
| 2276 | msgstr "Selecione quais addons do Stremio ignoram a filtragem nativa de tamanho e qualidade do Jacktook." | |
| 2201 | 2277 | |
| 2202 | 2278 | msgctxt "#90215" |
| 2203 | 2279 | msgid "Downgrade" |
| … | … | @@ -2209,27 +2285,27 @@ msgstr "Instala uma versao publicada anterior do Jacktook. A versao atual e salv |
| 2209 | 2285 | |
| 2210 | 2286 | msgctxt "#30932" |
| 2211 | 2287 | msgid "Update Action" |
| 2212 | msgstr "" | |
| 2288 | msgstr "Ação de atualização" | |
| 2213 | 2289 | |
| 2214 | 2290 | msgctxt "#30933" |
| 2215 | 2291 | msgid "Choose how to handle background update checks." |
| 2216 | msgstr "" | |
| 2292 | msgstr "Escolha como tratar as verificações de atualização em segundo plano." | |
| 2217 | 2293 | |
| 2218 | 2294 | msgctxt "#30934" |
| 2219 | 2295 | msgid "Ask" |
| 2220 | msgstr "" | |
| 2296 | msgstr "Perguntar" | |
| 2221 | 2297 | |
| 2222 | 2298 | msgctxt "#30935" |
| 2223 | 2299 | msgid "Notify" |
| 2224 | msgstr "" | |
| 2300 | msgstr "Notificar" | |
| 2225 | 2301 | |
| 2226 | 2302 | msgctxt "#90216" |
| 2227 | 2303 | msgid "Volume Check Enabled" |
| 2228 | msgstr "" | |
| 2304 | msgstr "Verificação de volume ativada" | |
| 2229 | 2305 | |
| 2230 | 2306 | msgctxt "#90217" |
| 2231 | 2307 | msgid "Automatically limits volume if it is above the threshold before playback." |
| 2232 | msgstr "" | |
| 2308 | msgstr "Limita automaticamente o volume se estiver acima do limite antes da reprodução." | |
| 2233 | 2309 | |
| 2234 | 2310 | msgctxt "#90218" |
| 2235 | 2311 | msgid "Volume Threshold" |
| … | … | @@ -2237,7 +2313,7 @@ msgstr "Limite de volume" |
| 2237 | 2313 | |
| 2238 | 2314 | msgctxt "#90219" |
| 2239 | 2315 | msgid "The volume percentage above which the warning triggers." |
| 2240 | msgstr "" | |
| 2316 | msgstr "A percentagem de volume acima da qual o aviso é acionado." | |
| 2241 | 2317 | |
| 2242 | 2318 | msgctxt "#90220" |
| 2243 | 2319 | msgid "High Volume Warning" |
| … | … | @@ -2261,11 +2337,11 @@ msgstr "Tempo limite do pedido em segundos." |
| 2261 | 2337 | |
| 2262 | 2338 | msgctxt "#90225" |
| 2263 | 2339 | msgid "Debrid Notifications" |
| 2264 | msgstr "" | |
| 2340 | msgstr "Notificações Debrid" | |
| 2265 | 2341 | |
| 2266 | 2342 | msgctxt "#90226" |
| 2267 | 2343 | msgid "Debrid Expiration Notification" |
| 2268 | msgstr "" | |
| 2344 | msgstr "Notificação de expiração do Debrid" | |
| 2269 | 2345 | |
| 2270 | 2346 | msgctxt "#90227" |
| 2271 | 2347 | msgid "Enable or disable notifications when your Debrid subscription is about to expire." |
| … | … | @@ -2277,15 +2353,15 @@ msgstr "Expiration Threshold (Days)" |
| 2277 | 2353 | |
| 2278 | 2354 | msgctxt "#90229" |
| 2279 | 2355 | msgid "How many days before expiration to start notifying." |
| 2280 | msgstr "" | |
| 2356 | msgstr "Quantos dias antes da expiração começar a notificar." | |
| 2281 | 2357 | |
| 2282 | 2358 | msgctxt "#90230" |
| 2283 | 2359 | msgid "%s expires in %d days." |
| 2284 | msgstr "" | |
| 2360 | msgstr "%s expira em %d dias." | |
| 2285 | 2361 | |
| 2286 | 2362 | msgctxt "#90231" |
| 2287 | 2363 | msgid "%s expires today!" |
| 2288 | msgstr "" | |
| 2364 | msgstr "%s expira hoje!" | |
| 2289 | 2365 | |
| 2290 | 2366 | msgctxt "#90232" |
| 2291 | 2367 | msgid "Easynews" |
| … | … | @@ -2293,19 +2369,19 @@ msgstr "Easynews" |
| 2293 | 2369 | |
| 2294 | 2370 | msgctxt "#90233" |
| 2295 | 2371 | msgid "Enable EasyNews as a direct stream provider." |
| 2296 | msgstr "" | |
| 2372 | msgstr "Ativar o EasyNews como fornecedor de stream direto." | |
| 2297 | 2373 | |
| 2298 | 2374 | msgctxt "#90234" |
| 2299 | 2375 | msgid "Your EasyNews username." |
| 2300 | msgstr "" | |
| 2376 | msgstr "O seu nome de utilizador do EasyNews." | |
| 2301 | 2377 | |
| 2302 | 2378 | msgctxt "#90235" |
| 2303 | 2379 | msgid "Your EasyNews password." |
| 2304 | msgstr "" | |
| 2380 | msgstr "A sua palavra-passe do EasyNews." | |
| 2305 | 2381 | |
| 2306 | 2382 | msgctxt "#90236" |
| 2307 | 2383 | msgid "Request timeout in seconds." |
| 2308 | msgstr "" | |
| 2384 | msgstr "Tempo limite do pedido em segundos." | |
| 2309 | 2385 | |
| 2310 | 2386 | msgctxt "#90237" |
| 2311 | 2387 | msgid "Account Information" |
| … | … | @@ -2313,15 +2389,15 @@ msgstr "Informações da conta" |
| 2313 | 2389 | |
| 2314 | 2390 | msgctxt "#90238" |
| 2315 | 2391 | msgid "Search Dialog Style" |
| 2316 | msgstr "" | |
| 2392 | msgstr "Estilo do diálogo de pesquisa" | |
| 2317 | 2393 | |
| 2318 | 2394 | msgctxt "#90239" |
| 2319 | 2395 | msgid "Background" |
| 2320 | msgstr "" | |
| 2396 | msgstr "Fundo" | |
| 2321 | 2397 | |
| 2322 | 2398 | msgctxt "#90240" |
| 2323 | 2399 | msgid "Detailed Status" |
| 2324 | msgstr "" | |
| 2400 | msgstr "Estado detalhado" | |
| 2325 | 2401 | |
| 2326 | 2402 | msgctxt "#90241" |
| 2327 | 2403 | msgid "Search Status" |
| … | … | @@ -2357,7 +2433,7 @@ msgstr "Cancelado" |
| 2357 | 2433 | |
| 2358 | 2434 | msgctxt "#90249" |
| 2359 | 2435 | msgid "Your personal Jackgram API token for authentication." |
| 2360 | msgstr "" | |
| 2436 | msgstr "O seu token pessoal da API do Jackgram para autenticação." | |
| 2361 | 2437 | |
| 2362 | 2438 | msgctxt "#90250" |
| 2363 | 2439 | msgid "Subtitles Found" |
| … | … | @@ -2555,6 +2631,10 @@ msgctxt "#90298" |
| 2555 | 2631 | msgid "Liked Lists" |
| 2556 | 2632 | msgstr "Listas com Gostos" |
| 2557 | 2633 | |
| 2634 | msgctxt "#90299" | |
| 2635 | msgid "No metadata found for this torrent" | |
| 2636 | msgstr "Nenhum metadado encontrado para este torrent" | |
| 2637 | ||
| 2558 | 2638 | msgctxt "#90331" |
| 2559 | 2639 | msgid "Stremio Addons" |
| 2560 | 2640 | msgstr "Addons do Stremio" |
| … | … | @@ -2641,27 +2721,27 @@ msgstr "Nao foi possivel transferir a copia de seguranca." |
| 2641 | 2721 | |
| 2642 | 2722 | msgctxt "#90352" |
| 2643 | 2723 | msgid "Source Select Style" |
| 2644 | msgstr "" | |
| 2724 | msgstr "Estilo da seleção de fontes" | |
| 2645 | 2725 | |
| 2646 | 2726 | msgctxt "#90353" |
| 2647 | 2727 | msgid "Modern" |
| 2648 | msgstr "" | |
| 2728 | msgstr "Moderno" | |
| 2649 | 2729 | |
| 2650 | 2730 | msgctxt "#90354" |
| 2651 | 2731 | msgid "OLED" |
| 2652 | msgstr "" | |
| 2732 | msgstr "OLED" | |
| 2653 | 2733 | |
| 2654 | 2734 | msgctxt "#90355" |
| 2655 | 2735 | msgid "Cyberpunk" |
| 2656 | msgstr "" | |
| 2736 | msgstr "Cyberpunk" | |
| 2657 | 2737 | |
| 2658 | 2738 | msgctxt "#90356" |
| 2659 | 2739 | msgid "Forest" |
| 2660 | msgstr "" | |
| 2740 | msgstr "Floresta" | |
| 2661 | 2741 | |
| 2662 | 2742 | msgctxt "#90357" |
| 2663 | 2743 | msgid "Gold" |
| 2664 | msgstr "" | |
| 2744 | msgstr "Dourado" | |
| 2665 | 2745 | |
| 2666 | 2746 | msgctxt "#90358" |
| 2667 | 2747 | msgid "No playable results found" |
| … | … | @@ -3652,6 +3732,18 @@ msgctxt "#90749" |
| 3652 | 3732 | msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file" |
| 3653 | 3733 | msgstr "Digitalize o código QR ou abra o URL (%s) no seu navegador para carregar um ficheiro de legendas" |
| 3654 | 3734 | |
| 3735 | msgctxt "#90740" | |
| 3736 | msgid "Search with current title" | |
| 3737 | msgstr "Pesquisar com o título atual" | |
| 3738 | ||
| 3739 | msgctxt "#90741" | |
| 3740 | msgid "Search with original title" | |
| 3741 | msgstr "Pesquisar com o título original" | |
| 3742 | ||
| 3743 | msgctxt "#90773" | |
| 3744 | msgid "Search full show with title + year" | |
| 3745 | msgstr "Pesquisar série completa com título + ano" | |
| 3746 | ||
| 3655 | 3747 | msgctxt "#90750" |
| 3656 | 3748 | msgid "Search Title Language" |
| 3657 | 3749 | msgstr "Idioma do título de pesquisa" |
| … | … | @@ -3671,3 +3763,715 @@ msgstr "Apenas inglês" |
| 3671 | 3763 | msgctxt "#90754" |
| 3672 | 3764 | msgid "Localized first" |
| 3673 | 3765 | msgstr "Localizado primeiro" |
| 3766 | ||
| 3767 | msgctxt "#90755" | |
| 3768 | msgid "Manage Sources" | |
| 3769 | msgstr "Gerenciar Fontes" | |
| 3770 | ||
| 3771 | msgctxt "#90756" | |
| 3772 | msgid "No sources available" | |
| 3773 | msgstr "Nenhuma fonte disponível" | |
| 3774 | ||
| 3775 | msgctxt "#90179" | |
| 3776 | msgid "Stinger Notifications" | |
| 3777 | msgstr "Notificações de stinger" | |
| 3778 | ||
| 3779 | msgctxt "#90180" | |
| 3780 | msgid "Show a notification when a movie has post-credit scenes." | |
| 3781 | msgstr "Mostrar uma notificação quando um filme tiver cenas pós-créditos." | |
| 3782 | ||
| 3783 | msgctxt "#90181" | |
| 3784 | msgid "Stinger Notification Time" | |
| 3785 | msgstr "Momento da notificação de stinger" | |
| 3786 | ||
| 3787 | msgctxt "#90182" | |
| 3788 | msgid "Seconds before the end to show the stinger notification." | |
| 3789 | msgstr "Segundos antes do fim para mostrar a notificação de stinger." | |
| 3790 | ||
| 3791 | msgctxt "#90183" | |
| 3792 | msgid "Stay tuned after the credits!" | |
| 3793 | msgstr "Fique atento após os créditos!" | |
| 3794 | ||
| 3795 | msgctxt "#90757" | |
| 3796 | msgid "Image Resolution" | |
| 3797 | msgstr "Resolução da Imagem" | |
| 3798 | ||
| 3799 | msgctxt "#90758" | |
| 3800 | msgid "Low" | |
| 3801 | msgstr "Baixa" | |
| 3802 | ||
| 3803 | msgctxt "#90759" | |
| 3804 | msgid "Medium" | |
| 3805 | msgstr "Média" | |
| 3806 | ||
| 3807 | msgctxt "#90760" | |
| 3808 | msgid "High" | |
| 3809 | msgstr "Alta" | |
| 3810 | ||
| 3811 | msgctxt "#90761" | |
| 3812 | msgid "Original" | |
| 3813 | msgstr "Original" | |
| 3814 | ||
| 3815 | msgctxt "#90762" | |
| 3816 | msgid "Enable RPDB Posters" | |
| 3817 | msgstr "Ativar Posters RPDB" | |
| 3818 | ||
| 3819 | msgctxt "#90763" | |
| 3820 | msgid "RPDB API Key" | |
| 3821 | msgstr "Chave API RPDB" | |
| 3822 | ||
| 3823 | msgctxt "#90764" | |
| 3824 | msgid "Get your API key at ratingposterdb.com" | |
| 3825 | msgstr "Obtenha a sua chave API em ratingposterdb.com" | |
| 3826 | ||
| 3827 | msgctxt "#90765" | |
| 3828 | msgid "Filter by Codecs" | |
| 3829 | msgstr "Filtrar por codecs" | |
| 3830 | ||
| 3831 | msgctxt "#90766" | |
| 3832 | msgid "Filter by HDR" | |
| 3833 | msgstr "Filtrar por HDR" | |
| 3834 | ||
| 3835 | msgctxt "#90767" | |
| 3836 | msgid "Include HEVC / H.265" | |
| 3837 | msgstr "Incluir HEVC / H.265" | |
| 3838 | ||
| 3839 | msgctxt "#90768" | |
| 3840 | msgid "Include AV1" | |
| 3841 | msgstr "Incluir AV1" | |
| 3842 | ||
| 3843 | msgctxt "#90769" | |
| 3844 | msgid "Include H.264" | |
| 3845 | msgstr "Incluir H.264" | |
| 3846 | ||
| 3847 | msgctxt "#90770" | |
| 3848 | msgid "Include Dolby Vision" | |
| 3849 | msgstr "Incluir Dolby Vision" | |
| 3850 | ||
| 3851 | msgctxt "#90771" | |
| 3852 | msgid "Include HDR10 / HDR10+" | |
| 3853 | msgstr "Incluir HDR10 / HDR10+" | |
| 3854 | ||
| 3855 | msgctxt "#90772" | |
| 3856 | msgid "Include HDR (generic)" | |
| 3857 | msgstr "Incluir HDR (genérico)" | |
| 3858 | ||
| 3859 | msgctxt "#90774" | |
| 3860 | msgid "Organize downloads by type" | |
| 3861 | msgstr "Organizar transferências por tipo" | |
| 3862 | ||
| 3863 | msgctxt "#90775" | |
| 3864 | msgid "Movies folder name" | |
| 3865 | msgstr "Nome da pasta de filmes" | |
| 3866 | ||
| 3867 | msgctxt "#90776" | |
| 3868 | msgid "TV Shows folder name" | |
| 3869 | msgstr "Nome da pasta de séries" | |
| 3870 | ||
| 3871 | msgctxt "#90777" | |
| 3872 | msgid "Pause" | |
| 3873 | msgstr "Pausar" | |
| 3874 | ||
| 3875 | msgctxt "#90778" | |
| 3876 | msgid "Resume" | |
| 3877 | msgstr "Retomar" | |
| 3878 | ||
| 3879 | msgctxt "#90779" | |
| 3880 | msgid "Downloading" | |
| 3881 | msgstr "A transferir" | |
| 3882 | ||
| 3883 | msgctxt "#90780" | |
| 3884 | msgid "Paused" | |
| 3885 | msgstr "Em pausa" | |
| 3886 | ||
| 3887 | msgctxt "#90781" | |
| 3888 | msgid "Completed" | |
| 3889 | msgstr "Concluído" | |
| 3890 | ||
| 3891 | msgctxt "#90782" | |
| 3892 | msgid "Delete" | |
| 3893 | msgstr "Eliminar" | |
| 3894 | ||
| 3895 | msgctxt "#90783" | |
| 3896 | msgid "Startup" | |
| 3897 | msgstr "Arranque" | |
| 3898 | ||
| 3899 | msgctxt "#90784" | |
| 3900 | msgid "Auto-start Jacktook on Kodi boot" | |
| 3901 | msgstr "Iniciar Jacktook automaticamente ao arrancar o Kodi" | |
| 3902 | ||
| 3903 | msgctxt "#90785" | |
| 3904 | msgid "Automatically open Jacktook when Kodi starts" | |
| 3905 | msgstr "Abrir Jacktook automaticamente quando o Kodi inicia" | |
| 3906 | ||
| 3907 | msgctxt "#90786" | |
| 3908 | msgid "Widgets" | |
| 3909 | msgstr "Widgets" | |
| 3910 | ||
| 3911 | msgctxt "#90787" | |
| 3912 | msgid "Widget refresh interval (minutes)" | |
| 3913 | msgstr "Intervalo de atualização de widgets (minutos)" | |
| 3914 | ||
| 3915 | msgctxt "#90788" | |
| 3916 | msgid "Set to 0 to disable automatic widget refresh" | |
| 3917 | msgstr "Definir como 0 para desativar a atualização automática" | |
| 3918 | ||
| 3919 | msgctxt "#90789" | |
| 3920 | msgid "Show refresh notification" | |
| 3921 | msgstr "Mostrar notificação de atualização" | |
| 3922 | ||
| 3923 | msgctxt "#90790" | |
| 3924 | msgid "Display a notification when widgets are refreshed" | |
| 3925 | msgstr "Mostrar uma notificação quando os widgets são atualizados" | |
| 3926 | ||
| 3927 | msgctxt "#90791" | |
| 3928 | msgid "Download to Device" | |
| 3929 | msgstr "Transferir para o dispositivo" | |
| 3930 | ||
| 3931 | msgctxt "#90792" | |
| 3932 | msgid "Download Manager" | |
| 3933 | msgstr "Gestor de transferências" | |
| 3934 | ||
| 3935 | msgctxt "#90793" | |
| 3936 | msgid "No active downloads" | |
| 3937 | msgstr "Sem transferências ativas" | |
| 3938 | ||
| 3939 | msgctxt "#90794" | |
| 3940 | msgid "Progress" | |
| 3941 | msgstr "Progresso" | |
| 3942 | ||
| 3943 | msgctxt "#90795" | |
| 3944 | msgid "Speed" | |
| 3945 | msgstr "Velocidade" | |
| 3946 | ||
| 3947 | msgctxt "#90796" | |
| 3948 | msgid "ETA" | |
| 3949 | msgstr "Tempo restante" | |
| 3950 | ||
| 3951 | msgctxt "#90797" | |
| 3952 | msgid "Size" | |
| 3953 | msgstr "Tamanho" | |
| 3954 | ||
| 3955 | msgctxt "#90798" | |
| 3956 | msgid "Pause" | |
| 3957 | msgstr "Pausa" | |
| 3958 | ||
| 3959 | msgctxt "#90799" | |
| 3960 | msgid "Resume" | |
| 3961 | msgstr "Retomar" | |
| 3962 | ||
| 3963 | msgctxt "#90800" | |
| 3964 | msgid "Cancel" | |
| 3965 | msgstr "Cancelar" | |
| 3966 | ||
| 3967 | msgctxt "#90801" | |
| 3968 | msgid "Delete" | |
| 3969 | msgstr "Eliminar" | |
| 3970 | ||
| 3971 | msgctxt "#90802" | |
| 3972 | msgid "Clear Completed" | |
| 3973 | msgstr "Limpar concluídas" | |
| 3974 | ||
| 3975 | msgctxt "#90803" | |
| 3976 | msgid "Download not yet completed" | |
| 3977 | msgstr "Transferência ainda não concluída" | |
| 3978 | ||
| 3979 | msgctxt "#90804" | |
| 3980 | msgid "File not found" | |
| 3981 | msgstr "Ficheiro não encontrado" | |
| 3982 | ||
| 3983 | msgctxt "#90805" | |
| 3984 | msgid "Download Manager" | |
| 3985 | msgstr "Gestor de transferências" | |
| 3986 | ||
| 3987 | msgctxt "#90806" | |
| 3988 | msgid "My Downloads" | |
| 3989 | msgstr "Minhas transferências" | |
| 3990 | ||
| 3991 | msgctxt "#90807" | |
| 3992 | msgid "Connecting..." | |
| 3993 | msgstr "A conectar..." | |
| 3994 | ||
| 3995 | msgctxt "#90808" | |
| 3996 | msgid "Close" | |
| 3997 | msgstr "Fechar" | |
| 3998 | ||
| 3999 | msgctxt "#90809" | |
| 4000 | msgid "Delete download?" | |
| 4001 | msgstr "Eliminar transferência?" | |
| 4002 | ||
| 4003 | msgctxt "#90810" | |
| 4004 | msgid "This will permanently delete the downloaded file. Are you sure?" | |
| 4005 | msgstr "Isto eliminará permanentemente o ficheiro transferido. Tem a certeza?" | |
| 4006 | ||
| 4007 | msgctxt "#90811" | |
| 4008 | msgid "Completed" | |
| 4009 | msgstr "Concluída" | |
| 4010 | ||
| 4011 | msgctxt "#90812" | |
| 4012 | msgid "Excluded TMDB Languages" | |
| 4013 | msgstr "Idiomas excluídos do TMDB" | |
| 4014 | ||
| 4015 | msgctxt "#90813" | |
| 4016 | msgid "Select languages to exclude" | |
| 4017 | msgstr "Seleciona os idiomas a excluir" | |
| 4018 | ||
| 4019 | msgctxt "#90814" | |
| 4020 | msgid "Choose original languages to hide from TMDB listings" | |
| 4021 | msgstr "Escolhe os idiomas originais a ocultar das listagens do TMDB" | |
| 4022 | ||
| 4023 | msgctxt "#90815" | |
| 4024 | msgid "Support Jacktook" | |
| 4025 | msgstr "Apoiar o Jacktook" | |
| 4026 | ||
| 4027 | msgctxt "#90816" | |
| 4028 | msgid "Support Jacktook development with an optional Ko-fi donation." | |
| 4029 | msgstr "Apoie o desenvolvimento do Jacktook com uma doação opcional no Ko-fi." | |
| 4030 | ||
| 4031 | msgctxt "#90817" | |
| 4032 | msgid "Scan the QR code or open this link:" | |
| 4033 | msgstr "Digitalize o código QR ou abra este link:" | |
| 4034 | ||
| 4035 | msgctxt "#90818" | |
| 4036 | msgid "Jacktook was updated with new improvements. Donations are optional, but they help keep development active. Would you like to see the support link?" | |
| 4037 | msgstr "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 | ||
| 4039 | msgctxt "#90300" | |
| 4040 | msgid "External Scraper" | |
| 4041 | msgstr "Scraper Externo" | |
| 4042 | ||
| 4043 | msgctxt "#90301" | |
| 4044 | msgid "Enable external scraper module integration." | |
| 4045 | msgstr "Ativar a integração do módulo scraper externo." | |
| 4046 | ||
| 4047 | msgctxt "#90302" | |
| 4048 | msgid "Choose External Scraper Module" | |
| 4049 | msgstr "Escolher Módulo Scraper Externo" | |
| 4050 | ||
| 4051 | msgctxt "#90303" | |
| 4052 | msgid "Select an external scraper addon module to use for torrent search." | |
| 4053 | msgstr "Selecione um módulo addon scraper externo para pesquisar torrents." | |
| 4054 | ||
| 4055 | msgctxt "#90304" | |
| 4056 | msgid "Choose External Scraper" | |
| 4057 | msgstr "Escolher Scraper Externo" | |
| 4058 | ||
| 4059 | msgctxt "#90305" | |
| 4060 | msgid "Selected Module" | |
| 4061 | msgstr "Módulo Selecionado" | |
| 4062 | ||
| 4063 | msgctxt "#90306" | |
| 4064 | msgid "Open External Scraper Settings" | |
| 4065 | msgstr "Abrir Definições do Scraper Externo" | |
| 4066 | ||
| 4067 | msgctxt "#90307" | |
| 4068 | msgid "Open the settings for the selected external scraper module." | |
| 4069 | msgstr "Abrir as definições do módulo scraper externo selecionado." | |
| 4070 | ||
| 4071 | msgctxt "#90382" | |
| 4072 | msgid "Please set a valid Trakt Client ID Key" | |
| 4073 | msgstr "Defina uma chave de ID de cliente do Trakt válida" | |
| 4074 | ||
| 4075 | msgctxt "#90383" | |
| 4076 | msgid "Please set a valid Trakt Client Secret Key" | |
| 4077 | msgstr "Defina uma chave secreta de cliente do Trakt válida" | |
| 4078 | ||
| 4079 | msgctxt "#90384" | |
| 4080 | msgid "Trakt Error Authorizing" | |
| 4081 | msgstr "Erro ao autorizar o Trakt" | |
| 4082 | ||
| 4083 | msgctxt "#90385" | |
| 4084 | msgid "Trakt Account Authorized" | |
| 4085 | msgstr "Conta do Trakt autorizada" | |
| 4086 | ||
| 4087 | msgctxt "#90386" | |
| 4088 | msgid "You are now logged out from Trakt.tv" | |
| 4089 | msgstr "Terminou sessão no Trakt.tv" | |
| 4090 | ||
| 4091 | msgctxt "#90387" | |
| 4092 | msgid "Failed to mark as watched" | |
| 4093 | msgstr "Falha ao marcar como visto" | |
| 4094 | ||
| 4095 | msgctxt "#90388" | |
| 4096 | msgid "Marked as watched" | |
| 4097 | msgstr "Marcado como visto" | |
| 4098 | ||
| 4099 | msgctxt "#90389" | |
| 4100 | msgid "No results found" | |
| 4101 | msgstr "Nenhum resultado encontrado" | |
| 4102 | ||
| 4103 | msgctxt "#90390" | |
| 4104 | msgid "Invalid mode" | |
| 4105 | msgstr "Modo inválido" | |
| 4106 | ||
| 4107 | msgctxt "#90391" | |
| 4108 | msgid "No recommendations found" | |
| 4109 | msgstr "Nenhuma recomendação encontrada" | |
| 4110 | ||
| 4111 | msgctxt "#90392" | |
| 4112 | msgid "No similar items found" | |
| 4113 | msgstr "Nenhum item semelhante encontrado" | |
| 4114 | ||
| 4115 | msgctxt "#90393" | |
| 4116 | msgid "TMDB metadata not found" | |
| 4117 | msgstr "Metadados do TMDB não encontrados" | |
| 4118 | ||
| 4119 | msgctxt "#90394" | |
| 4120 | msgid "No TV shows found" | |
| 4121 | msgstr "Nenhuma série encontrada" | |
| 4122 | ||
| 4123 | msgctxt "#90395" | |
| 4124 | msgid "No shows airing today" | |
| 4125 | msgstr "Nenhuma série em exibição hoje" | |
| 4126 | ||
| 4127 | msgctxt "#90396" | |
| 4128 | msgid "Invalid collection query" | |
| 4129 | msgstr "Consulta de coleção inválida" | |
| 4130 | ||
| 4131 | msgctxt "#90397" | |
| 4132 | msgid "No keywords found" | |
| 4133 | msgstr "Nenhuma palavra-chave encontrada" | |
| 4134 | ||
| 4135 | msgctxt "#90398" | |
| 4136 | msgid "No keyword ID provided" | |
| 4137 | msgstr "Nenhum ID de palavra-chave fornecido" | |
| 4138 | ||
| 4139 | msgctxt "#90399" | |
| 4140 | msgid "No results found for this keyword" | |
| 4141 | msgstr "Nenhum resultado encontrado para esta palavra-chave" | |
| 4142 | ||
| 4143 | msgctxt "#90400" | |
| 4144 | msgid "No TMDB ID found" | |
| 4145 | msgstr "Nenhum ID do TMDB encontrado" | |
| 4146 | ||
| 4147 | msgctxt "#90401" | |
| 4148 | msgid "Unsupported media type or missing external_ids" | |
| 4149 | msgstr "Tipo de media não suportado ou external_ids em falta" | |
| 4150 | ||
| 4151 | msgctxt "#90402" | |
| 4152 | msgid "Refreshed Metadata" | |
| 4153 | msgstr "Metadados atualizados" | |
| 4154 | ||
| 4155 | msgctxt "#90403" | |
| 4156 | msgid "No Stremio addons selected" | |
| 4157 | msgstr "Nenhum addon do Stremio selecionado" | |
| 4158 | ||
| 4159 | msgctxt "#90404" | |
| 4160 | msgid "No suitable source found for auto play." | |
| 4161 | msgstr "Nenhuma fonte adequada encontrada para reprodução automática." | |
| 4162 | ||
| 4163 | msgctxt "#90405" | |
| 4164 | msgid "No sources found with the preferred quality." | |
| 4165 | msgstr "Nenhuma fonte encontrada com a qualidade preferida." | |
| 4166 | ||
| 4167 | msgctxt "#90406" | |
| 4168 | msgid "No languages found" | |
| 4169 | msgstr "Nenhum idioma encontrado" | |
| 4170 | ||
| 4171 | msgctxt "#90407" | |
| 4172 | msgid "Failed to resolve playback source" | |
| 4173 | msgstr "Falha ao resolver a fonte de reprodução" | |
| 4174 | ||
| 4175 | msgctxt "#90408" | |
| 4176 | msgid "Trakt not authorized. Please authenticate." | |
| 4177 | msgstr "Trakt não autorizado. Autentique-se." | |
| 4178 | ||
| 4179 | msgctxt "#90409" | |
| 4180 | msgid "Added to Trakt Watchlist" | |
| 4181 | msgstr "Adicionado à lista de acompanhamento do Trakt" | |
| 4182 | ||
| 4183 | msgctxt "#90428" | |
| 4184 | msgid "Database read error. File may be corrupted." | |
| 4185 | msgstr "Erro ao ler a base de dados. O ficheiro pode estar corrompido." | |
| 4186 | ||
| 4187 | msgctxt "#90429" | |
| 4188 | msgid "Failed to delete data. Database error." | |
| 4189 | msgstr "Falha ao eliminar dados. Erro da base de dados." | |
| 4190 | ||
| 4191 | msgctxt "#90430" | |
| 4192 | msgid "Failed to clear cache. Database in use." | |
| 4193 | msgstr "Falha ao limpar a cache. Base de dados em uso." | |
| 4194 | ||
| 4195 | msgctxt "#90458" | |
| 4196 | msgid "OpenSubtitles Stremio: No results found." | |
| 4197 | msgstr "OpenSubtitles Stremio: nenhum resultado encontrado." | |
| 4198 | ||
| 4199 | msgctxt "#90460" | |
| 4200 | msgid "Subtitle Manager: No subtitles available for this video." | |
| 4201 | msgstr "Gestor de legendas: não há legendas disponíveis para este vídeo." | |
| 4202 | ||
| 4203 | msgctxt "#90468" | |
| 4204 | msgid "Search menu" | |
| 4205 | msgstr "Menu de pesquisa" | |
| 4206 | ||
| 4207 | msgctxt "#90469" | |
| 4208 | msgid "Continue watching menu" | |
| 4209 | msgstr "Menu Continuar a ver" | |
| 4210 | ||
| 4211 | msgctxt "#90470" | |
| 4212 | msgid "TV Shows Items" | |
| 4213 | msgstr "Itens de séries" | |
| 4214 | ||
| 4215 | msgctxt "#90471" | |
| 4216 | msgid "Movies Items" | |
| 4217 | msgstr "Itens de filmes" | |
| 4218 | ||
| 4219 | msgctxt "#90472" | |
| 4220 | msgid "Anime menu" | |
| 4221 | msgstr "Menu de anime" | |
| 4222 | ||
| 4223 | msgctxt "#90473" | |
| 4224 | msgid "TV Menu" | |
| 4225 | msgstr "Menu de TV" | |
| 4226 | ||
| 4227 | msgctxt "#90474" | |
| 4228 | msgid "Library menu" | |
| 4229 | msgstr "Menu da biblioteca" | |
| 4230 | ||
| 4231 | msgctxt "#90475" | |
| 4232 | msgid "Torrents Management" | |
| 4233 | msgstr "Gestão de torrents" | |
| 4234 | ||
| 4235 | msgctxt "#90476" | |
| 4236 | msgid "Telegram menu" | |
| 4237 | msgstr "Menu do Telegram" | |
| 4238 | ||
| 4239 | msgctxt "#90477" | |
| 4240 | msgid "Cloud Storage" | |
| 4241 | msgstr "Armazenamento na nuvem" | |
| 4242 | ||
| 4243 | msgctxt "#90478" | |
| 4244 | msgid "Downloads Management" | |
| 4245 | msgstr "Gestão de transferências" | |
| 4246 | ||
| 4247 | msgctxt "#90479" | |
| 4248 | msgid "Settings Management" | |
| 4249 | msgstr "Gestão de definições" | |
| 4250 | ||
| 4251 | msgctxt "#90480" | |
| 4252 | msgid "History Menu" | |
| 4253 | msgstr "Menu de histórico" | |
| 4254 | ||
| 4255 | msgctxt "#90481" | |
| 4256 | msgid "Files History" | |
| 4257 | msgstr "Histórico de ficheiros" | |
| 4258 | ||
| 4259 | msgctxt "#90482" | |
| 4260 | msgid "Titles History" | |
| 4261 | msgstr "Histórico de títulos" | |
| 4262 | ||
| 4263 | msgctxt "#90483" | |
| 4264 | msgid "Titles Calendar" | |
| 4265 | msgstr "Calendário de títulos" | |
| 4266 | ||
| 4267 | msgctxt "#90484" | |
| 4268 | msgid "My Shows" | |
| 4269 | msgstr "As minhas séries" | |
| 4270 | ||
| 4271 | msgctxt "#90485" | |
| 4272 | msgid "My Movies" | |
| 4273 | msgstr "Os meus filmes" | |
| 4274 | ||
| 4275 | msgctxt "#90486" | |
| 4276 | msgid "Upcoming Episodes" | |
| 4277 | msgstr "Próximos episódios" | |
| 4278 | ||
| 4279 | msgctxt "#90487" | |
| 4280 | msgid "Discovery List" | |
| 4281 | msgstr "Lista de descoberta" | |
| 4282 | ||
| 4283 | msgctxt "#90488" | |
| 4284 | msgid "Library List" | |
| 4285 | msgstr "Lista da biblioteca" | |
| 4286 | ||
| 4287 | msgctxt "#90489" | |
| 4288 | msgid "Trakt Recommendations" | |
| 4289 | msgstr "Recomendações do Trakt" | |
| 4290 | ||
| 4291 | msgctxt "#90490" | |
| 4292 | msgid "Trending Lists" | |
| 4293 | msgstr "Listas em tendência" | |
| 4294 | ||
| 4295 | msgctxt "#90491" | |
| 4296 | msgid "Popular Lists" | |
| 4297 | msgstr "Listas populares" | |
| 4298 | ||
| 4299 | msgctxt "#90492" | |
| 4300 | msgid "Search Lists" | |
| 4301 | msgstr "Pesquisar listas" | |
| 4302 | ||
| 4303 | msgctxt "#90493" | |
| 4304 | msgid "Recent Activity" | |
| 4305 | msgstr "Atividade recente" | |
| 4306 | ||
| 4307 | msgctxt "#90494" | |
| 4308 | msgid "Downloads List" | |
| 4309 | msgstr "Lista de transferências" | |
| 4310 | ||
| 4311 | msgctxt "#90495" | |
| 4312 | msgid "Download New" | |
| 4313 | msgstr "Nova transferência" | |
| 4314 | ||
| 4315 | msgctxt "#90496" | |
| 4316 | msgid "Cartoons Popular" | |
| 4317 | msgstr "Desenhos animados populares" | |
| 4318 | ||
| 4319 | msgctxt "#90497" | |
| 4320 | msgid "Animation Popular" | |
| 4321 | msgstr "Animação popular" | |
| 4322 | ||
| 4323 | msgctxt "#90500" | |
| 4324 | msgid "Translating subtitle..." | |
| 4325 | msgstr "A traduzir legenda..." | |
| 4326 | ||
| 4327 | msgctxt "#90501" | |
| 4328 | msgid "Translation done." | |
| 4329 | msgstr "Tradução concluída." | |
| 4330 | ||
| 4331 | msgctxt "#90502" | |
| 4332 | msgid "Searching subtitles..." | |
| 4333 | msgstr "A pesquisar legendas..." | |
| 4334 | ||
| 4335 | msgctxt "#90503" | |
| 4336 | msgid "No subtitles found" | |
| 4337 | msgstr "Nenhuma legenda encontrada" | |
| 4338 | ||
| 4339 | msgctxt "#90504" | |
| 4340 | msgid "Failed to authenticate with DeepL. Please check your API key." | |
| 4341 | msgstr "Falha ao autenticar com o DeepL. Verifique a sua chave API." | |
| 4342 | ||
| 4343 | msgctxt "#90505" | |
| 4344 | msgid "DeepL API key is missing or invalid." | |
| 4345 | msgstr "A chave API do DeepL está em falta ou é inválida." | |
| 4346 | ||
| 4347 | msgctxt "#90712" | |
| 4348 | msgid "Views" | |
| 4349 | msgstr "Vistas" | |
| 4350 | ||
| 4351 | msgctxt "#90713" | |
| 4352 | msgid "Set Main View" | |
| 4353 | msgstr "Definir vista principal" | |
| 4354 | ||
| 4355 | msgctxt "#90714" | |
| 4356 | msgid "Set Movies View" | |
| 4357 | msgstr "Definir vista de filmes" | |
| 4358 | ||
| 4359 | msgctxt "#90715" | |
| 4360 | msgid "Set TV Shows View" | |
| 4361 | msgstr "Definir vista de séries" | |
| 4362 | ||
| 4363 | msgctxt "#90716" | |
| 4364 | msgid "Set Seasons View" | |
| 4365 | msgstr "Definir vista de temporadas" | |
| 4366 | ||
| 4367 | msgctxt "#90717" | |
| 4368 | msgid "Set Episodes View" | |
| 4369 | msgstr "Definir vista de episódios" | |
| 4370 | ||
| 4371 | msgctxt "#90718" | |
| 4372 | msgid "Set Library View" | |
| 4373 | msgstr "Definir vista da biblioteca" | |
| 4374 | ||
| 4375 | msgctxt "#90719" | |
| 4376 | msgid "Set History View" | |
| 4377 | msgstr "Definir vista do histórico" | |
| 4378 | ||
| 4379 | msgctxt "#90720" | |
| 4380 | msgid "Set Downloads View" | |
| 4381 | msgstr "Definir vista de transferências" | |
| 4382 | ||
| 4383 | msgctxt "#90721" | |
| 4384 | msgid "Save Current View" | |
| 4385 | msgstr "Guardar vista atual" | |
| 4386 | ||
| 4387 | msgctxt "#90722" | |
| 4388 | msgid "View saved" | |
| 4389 | msgstr "Vista guardada" | |
| 4390 | ||
| 4391 | msgctxt "#90723" | |
| 4392 | msgid "Could not detect the current view" | |
| 4393 | msgstr "Não foi possível detetar a vista atual" | |
| 4394 | ||
| 4395 | msgctxt "#90724" | |
| 4396 | msgid "Saved views reset" | |
| 4397 | msgstr "Vistas guardadas repostas" | |
| 4398 | ||
| 4399 | msgctxt "#90725" | |
| 4400 | msgid "Reset Saved Views" | |
| 4401 | msgstr "Repor vistas guardadas" | |
| 4402 | ||
| 4403 | msgctxt "#90726" | |
| 4404 | msgid "Open this screen, change the Kodi view manually, then select Save Current View." | |
| 4405 | msgstr "Abra este ecrã, altere manualmente a vista do Kodi e depois selecione Guardar vista atual." | |
| 4406 | ||
| 4407 | msgctxt "#90730" | |
| 4408 | msgid "Search with title + year" | |
| 4409 | msgstr "Pesquisar com título + ano" | |
| 4410 | ||
| 4411 | msgctxt "#90731" | |
| 4412 | msgid "Search with original title" | |
| 4413 | msgstr "Pesquisar com título original" | |
| 4414 | ||
| 4415 | msgctxt "#90732" | |
| 4416 | msgid "Search with original title + year" | |
| 4417 | msgstr "Pesquisar com título original + ano" | |
| 4418 | ||
| 4419 | msgctxt "#90733" | |
| 4420 | msgid "Search modes" | |
| 4421 | msgstr "Modos de pesquisa" | |
| 4422 | ||
| 4423 | msgctxt "#90734" | |
| 4424 | msgid "Enter year" | |
| 4425 | msgstr "Introduza o ano" | |
| 4426 | ||
| 4427 | msgctxt "#90735" | |
| 4428 | msgid "Invalid year" | |
| 4429 | msgstr "Ano inválido" | |
| 4430 | ||
| 4431 | msgctxt "#90736" | |
| 4432 | msgid "Edit title" | |
| 4433 | msgstr "Editar título" | |
| 4434 | ||
| 4435 | msgctxt "#90737" | |
| 4436 | msgid "Invalid season" | |
| 4437 | msgstr "Temporada inválida" | |
| 4438 | ||
| 4439 | msgctxt "#90738" | |
| 4440 | msgid "Episode" | |
| 4441 | msgstr "Episódio" | |
| 4442 | ||
| 4443 | msgctxt "#90739" | |
| 4444 | msgid "Invalid episode" | |
| 4445 | msgstr "Episódio inválido" | |
| 4446 | ||
| 4447 | msgctxt "#90819" | |
| 4448 | msgid "Rename Stremio add-on" | |
| 4449 | msgstr "Renomear add-on do Stremio" | |
| 4450 | ||
| 4451 | msgctxt "#90824" | |
| 4452 | msgid "Rename Stremio catalog" | |
| 4453 | msgstr "Renomear catálogo do Stremio" | |
| 4454 | ||
| 4455 | msgctxt "#90827" | |
| 4456 | msgid "Set a Kodi-only display alias for a Stremio add-on." | |
| 4457 | msgstr "Definir um alias de apresentação apenas no Kodi para um add-on do Stremio." | |
| 4458 | ||
| 4459 | msgctxt "#90828" | |
| 4460 | msgid "Set a Kodi-only display alias for a Stremio catalog." | |
| 4461 | msgstr "Definir um alias de apresentação apenas no Kodi para um catálogo do Stremio." | |
| 4462 | ||
| 4463 | msgctxt "#90832" | |
| 4464 | msgid "Stremio: Addons Management" | |
| 4465 | msgstr "Stremio: Gestão de complementos" | |
| 4466 | ||
| 4467 | msgctxt "#90833" | |
| 4468 | msgid "Stremio: Catalogs & Visibility" | |
| 4469 | msgstr "Stremio: Catálogos e visibilidade" | |
| 4470 | ||
| 4471 | msgctxt "#90834" | |
| 4472 | msgid "Stremio: Rename & Customization" | |
| 4473 | msgstr "Stremio: Renomear e personalização" | |
| 4474 | ||
| 4475 | msgctxt "#90835" | |
| 4476 | msgid "Stremio: Search & Behavior" | |
| 4477 | msgstr "Stremio: Pesquisa e comportamento" |
| … | … | @@ -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 | # | |
| 6 | msgid "" | |
| 7 | msgstr "" | |
| 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 | |
| 18 | msgctxt "#30000" | |
| 19 | msgid "Players" | |
| 20 | msgstr "Playere" | |
| 21 | ||
| 22 | msgctxt "#30001" | |
| 23 | msgid "Clients" | |
| 24 | msgstr "Clienți" | |
| 25 | ||
| 26 | msgctxt "#30002" | |
| 27 | msgid "Torrent Client" | |
| 28 | msgstr "Client torrent" | |
| 29 | ||
| 30 | msgctxt "#30020" | |
| 31 | msgid "Indexers" | |
| 32 | msgstr "Indexatori" | |
| 33 | ||
| 34 | msgctxt "#30021" | |
| 35 | msgid "Indexer" | |
| 36 | msgstr "Indexator" | |
| 37 | ||
| 38 | msgctxt "#30022" | |
| 39 | msgid "Jackett Configuration:" | |
| 40 | msgstr "Configurează Jackett:" | |
| 41 | ||
| 42 | msgctxt "#30023" | |
| 43 | msgid "Client Key" | |
| 44 | msgstr "Cheie client" | |
| 45 | ||
| 46 | msgctxt "#30025" | |
| 47 | msgid "Host" | |
| 48 | msgstr "Host" | |
| 49 | ||
| 50 | msgctxt "#30026" | |
| 51 | msgid "Description length" | |
| 52 | msgstr "Lungime descriere" | |
| 53 | ||
| 54 | msgctxt "#30027" | |
| 55 | msgid "Insecured connection" | |
| 56 | msgstr "Conexiune nesecurizată" | |
| 57 | ||
| 58 | msgctxt "#30028" | |
| 59 | msgid "Sort by" | |
| 60 | msgstr "Sortează după" | |
| 61 | ||
| 62 | msgctxt "#30029" | |
| 63 | msgid "Total Results" | |
| 64 | msgstr "Rezultate totale" | |
| 65 | ||
| 66 | msgctxt "#30030" | |
| 67 | msgid "Timeout" | |
| 68 | msgstr "Timp de expirare" | |
| 69 | ||
| 70 | msgctxt "#30031" | |
| 71 | msgid "Timeout" | |
| 72 | msgstr "Timp de expirare" | |
| 73 | ||
| 74 | msgctxt "#30050" | |
| 75 | msgid "Prowlarr Configuration:" | |
| 76 | msgstr "Configurează Prowlarr:" | |
| 77 | ||
| 78 | msgctxt "#30051" | |
| 79 | msgid "Client Key" | |
| 80 | msgstr "Cheie client" | |
| 81 | ||
| 82 | msgctxt "#30052" | |
| 83 | msgid "Host" | |
| 84 | msgstr "Host" | |
| 85 | ||
| 86 | msgctxt "#30053" | |
| 87 | msgid "Indexers-Ids" | |
| 88 | msgstr "ID-uri indexatori" | |
| 89 | ||
| 90 | msgctxt "#30054" | |
| 91 | msgid "Anime IndexersIds" | |
| 92 | msgstr "ID-uri indexatori anime" | |
| 93 | ||
| 94 | msgctxt "#30100" | |
| 95 | msgid "TMDB" | |
| 96 | msgstr "TMDB" | |
| 97 | ||
| 98 | msgctxt "#30102" | |
| 99 | msgid "TMDB Configuration" | |
| 100 | msgstr "Configurează TMDB" | |
| 101 | ||
| 102 | msgctxt "#30103" | |
| 103 | msgid "Client Key" | |
| 104 | msgstr "Cheie client" | |
| 105 | ||
| 106 | msgctxt "#30104" | |
| 107 | msgid "Clear tmdb cache" | |
| 108 | msgstr "Șterge cache TMDB" | |
| 109 | ||
| 110 | msgctxt "#30105" | |
| 111 | msgid "Cache expiration hours" | |
| 112 | msgstr "Expirare cache (ore)" | |
| 113 | ||
| 114 | msgctxt "#30106" | |
| 115 | msgid "Clear Cache" | |
| 116 | msgstr "Șterge cache" | |
| 117 | ||
| 118 | msgctxt "#30107" | |
| 119 | msgid "Enable Cache" | |
| 120 | msgstr "Activează cache" | |
| 121 | ||
| 122 | msgctxt "#30140" | |
| 123 | msgid "AniList" | |
| 124 | msgstr "AniList" | |
| 125 | ||
| 126 | msgctxt "#30142" | |
| 127 | msgid "AniList Configuration:" | |
| 128 | msgstr "Configurează AniList:" | |
| 129 | ||
| 130 | msgctxt "#30143" | |
| 131 | msgid "Client ID" | |
| 132 | msgstr "ID client" | |
| 133 | ||
| 134 | msgctxt "#30144" | |
| 135 | msgid "Client Secret" | |
| 136 | msgstr "Secret client" | |
| 137 | ||
| 138 | msgctxt "#30150" | |
| 139 | msgid "Services" | |
| 140 | msgstr "Servicii" | |
| 141 | ||
| 142 | msgctxt "#30152" | |
| 143 | msgid "Real Debrid Configuration" | |
| 144 | msgstr "Configurează Real Debrid" | |
| 145 | ||
| 146 | msgctxt "#30154" | |
| 147 | msgid "Token" | |
| 148 | msgstr "Token" | |
| 149 | ||
| 150 | msgctxt "#30200" | |
| 151 | msgid "Fanart.TV" | |
| 152 | msgstr "Fanart.TV" | |
| 153 | ||
| 154 | msgctxt "#30202" | |
| 155 | msgid "Fanart.TV Configuration:" | |
| 156 | msgstr "Configurează Fanart.TV:" | |
| 157 | ||
| 158 | msgctxt "#30203" | |
| 159 | msgid "Client ID" | |
| 160 | msgstr "ID client" | |
| 161 | ||
| 162 | msgctxt "#30221" | |
| 163 | msgid "No Client Key provided for" | |
| 164 | msgstr "Nu ai furnizat cheia client pentru" | |
| 165 | ||
| 166 | msgctxt "#30223" | |
| 167 | msgid "You need to configure" | |
| 168 | msgstr "Trebuie să configurezi" | |
| 169 | ||
| 170 | msgctxt "#30228" | |
| 171 | msgid "The request to Torrentio failed." | |
| 172 | msgstr "Cererea către Torrentio a eșuat." | |
| 173 | ||
| 174 | msgctxt "#30229" | |
| 175 | msgid "The request to Jackett failed." | |
| 176 | msgstr "Cererea către Jackett a eșuat." | |
| 177 | ||
| 178 | msgctxt "#30230" | |
| 179 | msgid "The request to Prowlarr failed." | |
| 180 | msgstr "Cererea către Prowlarr a eșuat." | |
| 181 | ||
| 182 | msgctxt "#30231" | |
| 183 | msgid "The request to Elhosted failed." | |
| 184 | msgstr "Cererea către Elhosted a eșuat." | |
| 185 | ||
| 186 | msgctxt "#30232" | |
| 187 | msgid "The request to Jackgram failed." | |
| 188 | msgstr "Cererea către Jackgram a eșuat." | |
| 189 | ||
| 190 | msgctxt "#30233" | |
| 191 | msgid "The request to Mediafusion failed." | |
| 192 | msgstr "Cererea către Mediafusion a eșuat." | |
| 193 | ||
| 194 | msgctxt "#30234" | |
| 195 | msgid "The request to Peerflix failed." | |
| 196 | msgstr "Cererea către Peerflix a eșuat." | |
| 197 | ||
| 198 | msgctxt "#30235" | |
| 199 | msgid "Stremio" | |
| 200 | msgstr "Stremio" | |
| 201 | ||
| 202 | msgctxt "#30236" | |
| 203 | msgid "Stremio add-ons" | |
| 204 | msgstr "Add-on-uri Stremio" | |
| 205 | ||
| 206 | msgctxt "#30237" | |
| 207 | msgid "Import your config" | |
| 208 | msgstr "Importă configurația" | |
| 209 | ||
| 210 | msgctxt "#30238" | |
| 211 | msgid "Manage add-ons" | |
| 212 | msgstr "Gestionează add-on-urile" | |
| 213 | ||
| 214 | msgctxt "#30240" | |
| 215 | msgid "TMDB cache cleared" | |
| 216 | msgstr "Cache TMDB șters" | |
| 217 | ||
| 218 | msgctxt "#30241" | |
| 219 | msgid "Search on TMDB:" | |
| 220 | msgstr "Caută pe TMDB:" | |
| 221 | ||
| 222 | msgctxt "#30242" | |
| 223 | msgid "Search on AniList:" | |
| 224 | msgstr "Caută pe AniList:" | |
| 225 | ||
| 226 | msgctxt "#30243" | |
| 227 | msgid "Search for a keyword:" | |
| 228 | msgstr "Caută după cuvânt cheie:" | |
| 229 | ||
| 230 | msgctxt "#30244" | |
| 231 | msgid "Cache cleared" | |
| 232 | msgstr "Cache șters" | |
| 233 | ||
| 234 | msgctxt "#30249" | |
| 235 | msgid "Addon Jacktook Burst not found - (script.jacktook.burst)" | |
| 236 | msgstr "Nu s-a găsit add-on-ul Jacktook Burst - (script.jacktook.burst)" | |
| 237 | ||
| 238 | msgctxt "#30250" | |
| 239 | msgid "Addon Torrest not found - (plugin.video.torrest)" | |
| 240 | msgstr "Nu s-a găsit add-on-ul Torrest - (plugin.video.torrest)" | |
| 241 | ||
| 242 | msgctxt "#30251" | |
| 243 | msgid "No sources found to play" | |
| 244 | msgstr "Nu s-au găsit surse pentru redare" | |
| 245 | ||
| 246 | msgctxt "#30252" | |
| 247 | msgid "Addon Elementum not found - (plugin.video.elementum)" | |
| 248 | msgstr "Nu s-a găsit add-on-ul Elementum - (plugin.video.elementum)" | |
| 249 | ||
| 250 | msgctxt "#30253" | |
| 251 | msgid "Addon Jacktorr not found - (plugin.video.jacktorr)" | |
| 252 | msgstr "Nu s-a găsit add-on-ul Jacktorr - (plugin.video.jacktorr)" | |
| 253 | ||
| 254 | msgctxt "#30254" | |
| 255 | msgid "Do you want to install Elementum?" | |
| 256 | msgstr "Vrei să instalezi Elementum?" | |
| 257 | ||
| 258 | msgctxt "#30255" | |
| 259 | msgid "Do you want to install Jacktorr?" | |
| 260 | msgstr "Vrei să instalezi Jacktorr?" | |
| 261 | ||
| 262 | msgctxt "#30256" | |
| 263 | msgid "Do you want to install Torrest?" | |
| 264 | msgstr "Vrei să instalezi Torrest?" | |
| 265 | ||
| 266 | msgctxt "#30257" | |
| 267 | msgid "Do you want to install Jacktook Burst?" | |
| 268 | msgstr "Vrei să instalezi Jacktook Burst?" | |
| 269 | ||
| 270 | msgctxt "#30650" | |
| 271 | msgid "Queued" | |
| 272 | msgstr "În așteptare" | |
| 273 | ||
| 274 | msgctxt "#30651" | |
| 275 | msgid "Checking" | |
| 276 | msgstr "Se verifică" | |
| 277 | ||
| 278 | msgctxt "#30652" | |
| 279 | msgid "Finding" | |
| 280 | msgstr "Se caută" | |
| 281 | ||
| 282 | msgctxt "#30653" | |
| 283 | msgid "Downloading" | |
| 284 | msgstr "Se descarcă" | |
| 285 | ||
| 286 | msgctxt "#30654" | |
| 287 | msgid "Finished" | |
| 288 | msgstr "Finalizat" | |
| 289 | ||
| 290 | msgctxt "#30655" | |
| 291 | msgid "Seeding" | |
| 292 | msgstr "Se distribuie" | |
| 293 | ||
| 294 | msgctxt "#30656" | |
| 295 | msgid "Allocating" | |
| 296 | msgstr "Se alocă" | |
| 297 | ||
| 298 | msgctxt "#30657" | |
| 299 | msgid "Checking resume data" | |
| 300 | msgstr "Se verifică datele de reluare" | |
| 301 | ||
| 302 | msgctxt "#30658" | |
| 303 | msgid "Paused" | |
| 304 | msgstr "În pauză" | |
| 305 | ||
| 306 | msgctxt "#30659" | |
| 307 | msgid "Buffering" | |
| 308 | msgstr "Se încarcă" | |
| 309 | ||
| 310 | msgctxt "#30660" | |
| 311 | msgid "Unknown" | |
| 312 | msgstr "Necunoscut" | |
| 313 | ||
| 314 | msgctxt "#30700" | |
| 315 | msgid "Play Torrent" | |
| 316 | msgstr "Redă torrent" | |
| 317 | ||
| 318 | msgctxt "#30701" | |
| 319 | msgid "Stop downloading" | |
| 320 | msgstr "Oprește descărcarea" | |
| 321 | ||
| 322 | msgctxt "#30702" | |
| 323 | msgid "Downloads" | |
| 324 | msgstr "Descărcări" | |
| 325 | ||
| 326 | msgctxt "#30703" | |
| 327 | msgid "Resume" | |
| 328 | msgstr "Reia" | |
| 329 | ||
| 330 | msgctxt "#30704" | |
| 331 | msgid "Pause" | |
| 332 | msgstr "Pauză" | |
| 333 | ||
| 334 | msgctxt "#30705" | |
| 335 | msgid "Remove torrent" | |
| 336 | msgstr "Șterge torrentul" | |
| 337 | ||
| 338 | msgctxt "#30706" | |
| 339 | msgid "Remove torrent and files" | |
| 340 | msgstr "Șterge torrentul și fișierele" | |
| 341 | ||
| 342 | msgctxt "#30707" | |
| 343 | msgid "Torrent status" | |
| 344 | msgstr "Stare torrent" | |
| 345 | ||
| 346 | msgctxt "#30708" | |
| 347 | msgid "Stop" | |
| 348 | msgstr "Oprește" | |
| 349 | ||
| 350 | msgctxt "#30709" | |
| 351 | msgid "Drop torrent" | |
| 352 | msgstr "Renunță la torrent" | |
| 353 | ||
| 354 | msgctxt "#30710" | |
| 355 | msgid "Include Season Packs" | |
| 356 | msgstr "Include seturi de sezoane" | |
| 357 | ||
| 358 | msgctxt "#30711" | |
| 359 | msgid "Torrent Settings" | |
| 360 | msgstr "Setări torrent" | |
| 361 | ||
| 362 | msgctxt "#30712" | |
| 363 | msgid "General" | |
| 364 | msgstr "General" | |
| 365 | ||
| 366 | msgctxt "#30713" | |
| 367 | msgid "Enable" | |
| 368 | msgstr "Activează" | |
| 369 | ||
| 370 | msgctxt "#30714" | |
| 371 | msgid "Jacktorr" | |
| 372 | msgstr "Jacktorr" | |
| 373 | ||
| 374 | msgctxt "#30715" | |
| 375 | msgid "Torrest" | |
| 376 | msgstr "Torrest" | |
| 377 | ||
| 378 | msgctxt "#30716" | |
| 379 | msgid "Elementum" | |
| 380 | msgstr "Elementum" | |
| 381 | ||
| 382 | msgctxt "#30717" | |
| 383 | msgid "General" | |
| 384 | msgstr "General" | |
| 385 | ||
| 386 | msgctxt "#30718" | |
| 387 | msgid "Auto-Play" | |
| 388 | msgstr "Redare automată" | |
| 389 | ||
| 390 | msgctxt "#30719" | |
| 391 | msgid "Enable Next Episode Dialog" | |
| 392 | msgstr "Activează dialogul pentru episodul următor" | |
| 393 | ||
| 394 | msgctxt "#30720" | |
| 395 | msgid "Seconds left before show Next Episode Dialog" | |
| 396 | msgstr "Secunde rămase înainte de afișarea dialogului episodului următor" | |
| 397 | ||
| 398 | msgctxt "#30721" | |
| 399 | msgid "Cache Enabled" | |
| 400 | msgstr "Cache activat" | |
| 401 | ||
| 402 | msgctxt "#30722" | |
| 403 | msgid "Cache Expiration (hours)" | |
| 404 | msgstr "Expirare cache (ore)" | |
| 405 | ||
| 406 | msgctxt "#30723" | |
| 407 | msgid "Cache" | |
| 408 | msgstr "Cache" | |
| 409 | ||
| 410 | msgctxt "#30724" | |
| 411 | msgid "Update" | |
| 412 | msgstr "Actualizează" | |
| 413 | ||
| 414 | msgctxt "#30725" | |
| 415 | msgid "Indexers Configuration" | |
| 416 | msgstr "Configurare indexatori" | |
| 417 | ||
| 418 | msgctxt "#30726" | |
| 419 | msgid "Sort" | |
| 420 | msgstr "Sortează" | |
| 421 | ||
| 422 | msgctxt "#30727" | |
| 423 | msgid "Quality" | |
| 424 | msgstr "Calitate" | |
| 425 | ||
| 426 | msgctxt "#30938" | |
| 427 | msgid "Silent Resume" | |
| 428 | msgstr "Reluare silențioasă" | |
| 429 | ||
| 430 | msgctxt "#30939" | |
| 431 | msgid "Auto-resume from last position without asking" | |
| 432 | msgstr "Reia automat de la ultima poziție fără confirmare" | |
| 433 | ||
| 434 | msgctxt "#30940" | |
| 435 | msgid "Use Percentage Trigger" | |
| 436 | msgstr "Folosește declanșare procentuală" | |
| 437 | ||
| 438 | msgctxt "#30941" | |
| 439 | msgid "Show Dialog at Playback Percentage" | |
| 440 | msgstr "Afișează dialogul la procentul de redare" | |
| 441 | ||
| 442 | msgctxt "#30942" | |
| 443 | msgid "Trigger the next-episode dialog based on percentage of video played instead of seconds remaining." | |
| 444 | msgstr "Declanșează dialogul episodului următor pe baza procentului redat din video, în locul secundelor rămase." | |
| 445 | ||
| 446 | msgctxt "#30943" | |
| 447 | msgid "The percentage at which the next-episode dialog appears (e.g. 95 means 95% of the video)." | |
| 448 | msgstr "Procentul la care apare dialogul episodului următor (de ex. 95 înseamnă 95% din video)." | |
| 449 | ||
| 450 | msgctxt "#30944" | |
| 451 | msgid "Auto-Play Timeout" | |
| 452 | msgstr "Timeout redare automată" | |
| 453 | ||
| 454 | msgctxt "#30945" | |
| 455 | msgid "Seconds before auto-playing next episode. 0 = disabled, dialog waits for user input." | |
| 456 | msgstr "Secunde înainte de redarea automată a episodului următor. 0 = dezactivat, dialogul așteaptă intervenția utilizatorului." | |
| 457 | ||
| 458 | ||
| 459 | msgctxt "#30946" | |
| 460 | msgid "Offcloud Configuration" | |
| 461 | msgstr "Configurare Offcloud" | |
| 462 | ||
| 463 | msgctxt "#30947" | |
| 464 | msgid "Enable Offcloud cached torrent and cloud transfer support." | |
| 465 | msgstr "Activează suportul pentru torrente cache și transferuri în cloud Offcloud." | |
| 466 | ||
| 467 | msgctxt "#30948" | |
| 468 | msgid "Your Offcloud account username/email." | |
| 469 | msgstr "Numele de utilizator sau emailul tău Offcloud." | |
| 470 | ||
| 471 | msgctxt "#30949" | |
| 472 | msgid "Offcloud API key from Account API Key settings." | |
| 473 | msgstr "Cheia API Offcloud din setările Contului → Cheie API." | |
| 474 | ||
| 475 | msgctxt "#30950" | |
| 476 | msgid "View Offcloud account information." | |
| 477 | msgstr "Vezi informațiile contului Offcloud." | |
| 478 | ||
| 479 | msgctxt "#30951" | |
| 480 | msgid "Authorize add-on to access Offcloud account." | |
| 481 | msgstr "Autorizează addon-ul să acceseze contul Offcloud." | |
| 482 | ||
| 483 | msgctxt "#30952" | |
| 484 | msgid "Revoke Offcloud account access." | |
| 485 | msgstr "Revocă accesul contului Offcloud." | |
| 486 | msgctxt "#30728" | |
| 487 | msgid "Seeds" | |
| 488 | msgstr "Seederi" | |
| 489 | ||
| 490 | msgctxt "#30729" | |
| 491 | msgid "Size" | |
| 492 | msgstr "Mărime" | |
| 493 | ||
| 494 | msgctxt "#30730" | |
| 495 | msgid "Cached" | |
| 496 | msgstr "În cache" | |
| 497 | ||
| 498 | msgctxt "#30731" | |
| 499 | msgid "Date" | |
| 500 | msgstr "Dată" | |
| 501 | ||
| 502 | msgctxt "#30732" | |
| 503 | msgid "None" | |
| 504 | msgstr "Niciunul" | |
| 505 | ||
| 506 | msgctxt "#30733" | |
| 507 | msgid "Torrentio Configuration" | |
| 508 | msgstr "Configurează Torrentio" | |
| 509 | ||
| 510 | msgctxt "#30734" | |
| 511 | msgid "Enable" | |
| 512 | msgstr "Activează" | |
| 513 | ||
| 514 | msgctxt "#30735" | |
| 515 | msgid "Still Watching? Threshold" | |
| 516 | msgstr "Prag Încă vizionezi?" | |
| 517 | ||
| 518 | msgctxt "#30736" | |
| 519 | msgid "Subtitle Language" | |
| 520 | msgstr "Limbă subtitrări" | |
| 521 | ||
| 522 | msgctxt "#30737" | |
| 523 | msgid "GB" | |
| 524 | msgstr "GB" | |
| 525 | ||
| 526 | msgctxt "#30738" | |
| 527 | msgid "JP" | |
| 528 | msgstr "JP" | |
| 529 | ||
| 530 | msgctxt "#30739" | |
| 531 | msgid "RU" | |
| 532 | msgstr "RU" | |
| 533 | ||
| 534 | msgctxt "#30740" | |
| 535 | msgid "IT" | |
| 536 | msgstr "IT" | |
| 537 | ||
| 538 | msgctxt "#30741" | |
| 539 | msgid "PT" | |
| 540 | msgstr "PT" | |
| 541 | ||
| 542 | msgctxt "#30742" | |
| 543 | msgid "ES" | |
| 544 | msgstr "ES" | |
| 545 | ||
| 546 | msgctxt "#30743" | |
| 547 | msgid "MX" | |
| 548 | msgstr "MX" | |
| 549 | ||
| 550 | msgctxt "#30744" | |
| 551 | msgid "KR" | |
| 552 | msgstr "KR" | |
| 553 | ||
| 554 | msgctxt "#30745" | |
| 555 | msgid "CN" | |
| 556 | msgstr "CN" | |
| 557 | ||
| 558 | msgctxt "#30746" | |
| 559 | msgid "TW" | |
| 560 | msgstr "TW" | |
| 561 | ||
| 562 | msgctxt "#30747" | |
| 563 | msgid "FR" | |
| 564 | msgstr "FR" | |
| 565 | ||
| 566 | msgctxt "#30748" | |
| 567 | msgid "DE" | |
| 568 | msgstr "DE" | |
| 569 | ||
| 570 | msgctxt "#30749" | |
| 571 | msgid "NL" | |
| 572 | msgstr "NL" | |
| 573 | ||
| 574 | msgctxt "#30750" | |
| 575 | msgid "IN" | |
| 576 | msgstr "IN" | |
| 577 | ||
| 578 | msgctxt "#30751" | |
| 579 | msgid "PL" | |
| 580 | msgstr "PL" | |
| 581 | ||
| 582 | msgctxt "#30752" | |
| 583 | msgid "LT" | |
| 584 | msgstr "LT" | |
| 585 | ||
| 586 | msgctxt "#30753" | |
| 587 | msgid "CZ" | |
| 588 | msgstr "CZ" | |
| 589 | ||
| 590 | msgctxt "#30754" | |
| 591 | msgid "SK" | |
| 592 | msgstr "SK" | |
| 593 | ||
| 594 | msgctxt "#30755" | |
| 595 | msgid "SI" | |
| 596 | msgstr "SI" | |
| 597 | ||
| 598 | msgctxt "#30756" | |
| 599 | msgid "HU" | |
| 600 | msgstr "HU" | |
| 601 | ||
| 602 | msgctxt "#30757" | |
| 603 | msgid "RO" | |
| 604 | msgstr "RO" | |
| 605 | ||
| 606 | msgctxt "#30758" | |
| 607 | msgid "HR" | |
| 608 | msgstr "HR" | |
| 609 | ||
| 610 | msgctxt "#30759" | |
| 611 | msgid "UA" | |
| 612 | msgstr "UA" | |
| 613 | ||
| 614 | msgctxt "#30760" | |
| 615 | msgid "GR" | |
| 616 | msgstr "GR" | |
| 617 | ||
| 618 | msgctxt "#30761" | |
| 619 | msgid "DK" | |
| 620 | msgstr "DK" | |
| 621 | ||
| 622 | msgctxt "#30762" | |
| 623 | msgid "FI" | |
| 624 | msgstr "FI" | |
| 625 | ||
| 626 | msgctxt "#30763" | |
| 627 | msgid "SE" | |
| 628 | msgstr "SE" | |
| 629 | ||
| 630 | msgctxt "#30764" | |
| 631 | msgid "NO" | |
| 632 | msgstr "NO" | |
| 633 | ||
| 634 | msgctxt "#30765" | |
| 635 | msgid "TR" | |
| 636 | msgstr "TR" | |
| 637 | ||
| 638 | msgctxt "#30766" | |
| 639 | msgid "Torrentio Providers" | |
| 640 | msgstr "Provideri Torrentio" | |
| 641 | ||
| 642 | msgctxt "#30767" | |
| 643 | msgid "Jackgram Configuration" | |
| 644 | msgstr "Configurează Jackgram" | |
| 645 | ||
| 646 | msgctxt "#30768" | |
| 647 | msgid "Host" | |
| 648 | msgstr "Host" | |
| 649 | ||
| 650 | msgctxt "#30769" | |
| 651 | msgid "Elfhosted/KnightCrawler Configuration" | |
| 652 | msgstr "Configurează Elfhosted/KnightCrawler" | |
| 653 | ||
| 654 | msgctxt "#30770" | |
| 655 | msgid "Language" | |
| 656 | msgstr "Limbă" | |
| 657 | ||
| 658 | msgctxt "#30771" | |
| 659 | msgid "Zilean Configuration" | |
| 660 | msgstr "Configurează Zilean" | |
| 661 | ||
| 662 | msgctxt "#30772" | |
| 663 | msgid "Zilean Host" | |
| 664 | msgstr "Host Zilean" | |
| 665 | ||
| 666 | msgctxt "#30773" | |
| 667 | msgid "Number of consecutive auto-played episodes before asking 'Still Watching?'. Set to 0 to disable." | |
| 668 | msgstr "Numărul de episoade redate automat consecutiv înainte de întrebarea 'Încă vizionezi?'. Setează la 0 pentru dezactivare." | |
| 669 | ||
| 670 | msgctxt "#30774" | |
| 671 | msgid "Zilean Timeout" | |
| 672 | msgstr "Timp de expirare Zilean" | |
| 673 | ||
| 674 | msgctxt "#30775" | |
| 675 | msgid "Jackett Configuration" | |
| 676 | msgstr "Configurează Jackett" | |
| 677 | ||
| 678 | msgctxt "#30776" | |
| 679 | msgid "Prowlarr Configuration" | |
| 680 | msgstr "Configurează Prowlarr" | |
| 681 | ||
| 682 | msgctxt "#30777" | |
| 683 | msgid "MediaFusion configuration" | |
| 684 | msgstr "Configurează MediaFusion" | |
| 685 | ||
| 686 | msgctxt "#30779" | |
| 687 | msgid "Manifest Url" | |
| 688 | msgstr "URL manifest" | |
| 689 | ||
| 690 | msgctxt "#30780" | |
| 691 | msgid "Jacktook Burst Configuration" | |
| 692 | msgstr "Configurează Jacktook Burst" | |
| 693 | ||
| 694 | msgctxt "#30781" | |
| 695 | msgid "Burst Configuration" | |
| 696 | msgstr "Configurează Burst" | |
| 697 | ||
| 698 | msgctxt "#30782" | |
| 699 | msgid "User" | |
| 700 | msgstr "Utilizator" | |
| 701 | ||
| 702 | msgctxt "#30783" | |
| 703 | msgid "Authorize" | |
| 704 | msgstr "Autorizează" | |
| 705 | ||
| 706 | msgctxt "#30784" | |
| 707 | msgid "Remove Authorization" | |
| 708 | msgstr "Elimină autorizarea" | |
| 709 | ||
| 710 | msgctxt "#30785" | |
| 711 | msgid "Premiumize Configuration" | |
| 712 | msgstr "Configurează Premiumize" | |
| 713 | ||
| 714 | msgctxt "#30786" | |
| 715 | msgid "Enabled" | |
| 716 | msgstr "Activat" | |
| 717 | ||
| 718 | msgctxt "#30787" | |
| 719 | msgid "Token" | |
| 720 | msgstr "Token" | |
| 721 | ||
| 722 | msgctxt "#30788" | |
| 723 | msgid "TorBox Configuration" | |
| 724 | msgstr "Configurează TorBox" | |
| 725 | ||
| 726 | msgctxt "#30789" | |
| 727 | msgid "Debrider Configuration" | |
| 728 | msgstr "Configurează Debrider" | |
| 729 | ||
| 730 | msgctxt "#30790" | |
| 731 | msgid "Uncached" | |
| 732 | msgstr "Fără cache" | |
| 733 | ||
| 734 | msgctxt "#30791" | |
| 735 | msgid "Show uncached torrents" | |
| 736 | msgstr "Afișează torrente fără cache" | |
| 737 | ||
| 738 | msgctxt "#30792" | |
| 739 | msgid "Use Kodi Language" | |
| 740 | msgstr "Folosește limba Kodi" | |
| 741 | ||
| 742 | msgctxt "#30793" | |
| 743 | msgid "Trakt" | |
| 744 | msgstr "Trakt" | |
| 745 | ||
| 746 | msgctxt "#30794" | |
| 747 | msgid "Trakt Configuration" | |
| 748 | msgstr "Configurează Trakt" | |
| 749 | ||
| 750 | msgctxt "#30795" | |
| 751 | msgid "Expires" | |
| 752 | msgstr "Expiră" | |
| 753 | ||
| 754 | msgctxt "#30796" | |
| 755 | msgid "Refresh" | |
| 756 | msgstr "Reîmprospătează" | |
| 757 | ||
| 758 | msgctxt "#30797" | |
| 759 | msgid "Token" | |
| 760 | msgstr "Token" | |
| 761 | ||
| 762 | msgctxt "#30798" | |
| 763 | msgid "Trakt Client" | |
| 764 | msgstr "Client Trakt" | |
| 765 | ||
| 766 | msgctxt "#30800" | |
| 767 | msgid "Trakt Secret" | |
| 768 | msgstr "Secret Trakt" | |
| 769 | ||
| 770 | msgctxt "#30802" | |
| 771 | msgid "Log out" | |
| 772 | msgstr "Deconectează-te" | |
| 773 | ||
| 774 | msgctxt "#30804" | |
| 775 | msgid "Menus" | |
| 776 | msgstr "Meniuri" | |
| 777 | ||
| 778 | msgctxt "#30805" | |
| 779 | msgid "Show Telegram Menu" | |
| 780 | msgstr "Afișează meniul Telegram" | |
| 781 | ||
| 782 | msgctxt "#30806" | |
| 783 | msgid "Clear cache when update" | |
| 784 | msgstr "Șterge cache la actualizare" | |
| 785 | ||
| 786 | msgctxt "#30807" | |
| 787 | msgid "Peerfix Configuration" | |
| 788 | msgstr "Configurează Peerfix" | |
| 789 | ||
| 790 | msgctxt "#30894" | |
| 791 | msgid "Show Changelog" | |
| 792 | msgstr "Afișează jurnalul modificărilor" | |
| 793 | ||
| 794 | msgctxt "#30895" | |
| 795 | msgid "View the latest changes and updates to the addon." | |
| 796 | msgstr "Vezi ultimele modificări și actualizări ale add-on-ului." | |
| 797 | ||
| 798 | msgctxt "#30896" | |
| 799 | msgid "Calendar" | |
| 800 | msgstr "Calendar" | |
| 801 | ||
| 802 | msgctxt "#30897" | |
| 803 | msgid "Use broadcast day adjustment" | |
| 804 | msgstr "Folosește ajustarea zilei de difuzare" | |
| 805 | ||
| 806 | msgctxt "#30898" | |
| 807 | msgid "Adjust broadcast day based on manual offset" | |
| 808 | msgstr "Ajustează ziua de difuzare pe baza unui offset manual" | |
| 809 | ||
| 810 | msgctxt "#30899" | |
| 811 | msgid "Broadcast Offset (Hours)" | |
| 812 | msgstr "Offset difuzare (ore)" | |
| 813 | ||
| 814 | msgctxt "#30900" | |
| 815 | msgid "Manual hour adjustment for broadcast day simulation" | |
| 816 | msgstr "Ajustare manuală a orei pentru simularea zilei de difuzare" | |
| 817 | ||
| 818 | msgctxt "#30905" | |
| 819 | msgid "Trakt Calendar Previous Days" | |
| 820 | msgstr "Zile anterioare în calendarul Trakt" | |
| 821 | ||
| 822 | msgctxt "#30906" | |
| 823 | msgid "Number of days in the past to show in the Trakt calendar." | |
| 824 | msgstr "Numărul de zile din trecut afișate în calendarul Trakt." | |
| 825 | ||
| 826 | msgctxt "#30907" | |
| 827 | msgid "Trakt Calendar Future Days" | |
| 828 | msgstr "Zile viitoare în calendarul Trakt" | |
| 829 | ||
| 830 | msgctxt "#30908" | |
| 831 | msgid "Number of days in the future to show in the Trakt calendar." | |
| 832 | msgstr "Numărul de zile din viitor afișate în calendarul Trakt." | |
| 833 | ||
| 834 | msgctxt "#30909" | |
| 835 | msgid "Show All Shows in Calendar" | |
| 836 | msgstr "Afișează toate serialele în calendar" | |
| 837 | ||
| 838 | msgctxt "#30918" | |
| 839 | msgid "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead." | |
| 840 | msgstr "{provider} nu poate reda direct arhive (.rar/.zip). Alege o sursă cu fișiere video." | |
| 841 | ||
| 842 | msgctxt "#30910" | |
| 843 | msgid "Show all airing shows instead of just your watchlist/collection." | |
| 844 | msgstr "Afișează toate serialele difuzate, nu doar lista ta/colecția." | |
| 845 | ||
| 846 | msgctxt "#30911" | |
| 847 | msgid "Your Torbox account username/email." | |
| 848 | msgstr "Numele de utilizator/email cont Torbox." | |
| 849 | ||
| 850 | msgctxt "#30912" | |
| 851 | msgid "Authorize add-on to access Torbox account." | |
| 852 | msgstr "Autorizează add-on-ul să acceseze contul Torbox." | |
| 853 | ||
| 854 | msgctxt "#30913" | |
| 855 | msgid "Revoke Torbox account access." | |
| 856 | msgstr "Revocă accesul la contul Torbox." | |
| 857 | ||
| 858 | msgctxt "#30914" | |
| 859 | msgid "Trakt Sync Interval (minutes)" | |
| 860 | msgstr "Interval sincronizare Trakt (minute)" | |
| 861 | ||
| 862 | msgctxt "#30915" | |
| 863 | msgid "How often Jacktook checks Trakt for remote activity changes while Kodi is running." | |
| 864 | msgstr "Cât de des verifică Jacktook Trakt pentru modificări de activitate de la distanță în timp ce Kodi rulează." | |
| 865 | ||
| 866 | msgctxt "#30916" | |
| 867 | msgid "Account Info" | |
| 868 | msgstr "Informații cont" | |
| 869 | ||
| 870 | msgctxt "#30917" | |
| 871 | msgid "View your Trakt account profile and library stats." | |
| 872 | msgstr "Vezi profilul contului Trakt și statisticile bibliotecii." | |
| 873 | ||
| 874 | msgctxt "#30924" | |
| 875 | msgid "Trakt List Name" | |
| 876 | msgstr "Nume listă Trakt" | |
| 877 | ||
| 878 | msgctxt "#30925" | |
| 879 | msgid "Trakt List Description" | |
| 880 | msgstr "Descriere listă Trakt" | |
| 881 | ||
| 882 | msgctxt "#30926" | |
| 883 | msgid "Create Trakt List" | |
| 884 | msgstr "Creează listă Trakt" | |
| 885 | ||
| 886 | msgctxt "#30927" | |
| 887 | msgid "Choose Trakt List" | |
| 888 | msgstr "Alege lista Trakt" | |
| 889 | ||
| 890 | msgctxt "#30936" | |
| 891 | msgid "Enable or disable Trakt scrobbling and resume playback from the last tracked position." | |
| 892 | msgstr "Activează sau dezactivează scrobbling-ul Trakt și reluarea de la ultima poziție." | |
| 893 | ||
| 894 | msgctxt "#30937" | |
| 895 | msgid "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view." | |
| 896 | msgstr "Șterge datele Trakt din cache și forțează actualizarea la următoarea deschidere." | |
| 897 | ||
| 898 | msgctxt "#40000" | |
| 899 | msgid "Arabic (United Arab Emirates)" | |
| 900 | msgstr "Arabă (Emiratele Arabe Unite)" | |
| 901 | ||
| 902 | msgctxt "#40001" | |
| 903 | msgid "Arabic (Saudi Arabia)" | |
| 904 | msgstr "Arabă (Arabia Saudită)" | |
| 905 | ||
| 906 | msgctxt "#40002" | |
| 907 | msgid "Belarusian (Belarus)" | |
| 908 | msgstr "Belarusă (Belarus)" | |
| 909 | ||
| 910 | msgctxt "#40003" | |
| 911 | msgid "Bulgarian (Bulgaria)" | |
| 912 | msgstr "Bulgară (Bulgaria)" | |
| 913 | ||
| 914 | msgctxt "#40004" | |
| 915 | msgid "Bengali (Bangladesh)" | |
| 916 | msgstr "Bengaleză (Bangladesh)" | |
| 917 | ||
| 918 | msgctxt "#40005" | |
| 919 | msgid "Catalan (Spain)" | |
| 920 | msgstr "Catalană (Spania)" | |
| 921 | ||
| 922 | msgctxt "#40006" | |
| 923 | msgid "Chamorro (Guam)" | |
| 924 | msgstr "Chamorro (Guam)" | |
| 925 | ||
| 926 | msgctxt "#40007" | |
| 927 | msgid "Czech (Czech Republic)" | |
| 928 | msgstr "Cehă (Cehia)" | |
| 929 | ||
| 930 | msgctxt "#40008" | |
| 931 | msgid "Danish (Denmark)" | |
| 932 | msgstr "Daneză (Danemarca)" | |
| 933 | ||
| 934 | msgctxt "#40009" | |
| 935 | msgid "German (Austria)" | |
| 936 | msgstr "Germană (Austria)" | |
| 937 | ||
| 938 | msgctxt "#40010" | |
| 939 | msgid "German (Switzerland)" | |
| 940 | msgstr "Germană (Elveția)" | |
| 941 | ||
| 942 | msgctxt "#40011" | |
| 943 | msgid "German (Germany)" | |
| 944 | msgstr "Germană (Germania)" | |
| 945 | ||
| 946 | msgctxt "#40012" | |
| 947 | msgid "Greek (Greece)" | |
| 948 | msgstr "Greacă (Grecia)" | |
| 949 | ||
| 950 | msgctxt "#40013" | |
| 951 | msgid "English (Australia)" | |
| 952 | msgstr "Engleză (Australia)" | |
| 953 | ||
| 954 | msgctxt "#40014" | |
| 955 | msgid "English (Canada)" | |
| 956 | msgstr "Engleză (Canada)" | |
| 957 | ||
| 958 | msgctxt "#40015" | |
| 959 | msgid "English (United Kingdom)" | |
| 960 | msgstr "Engleză (Regatul Unit)" | |
| 961 | ||
| 962 | msgctxt "#40016" | |
| 963 | msgid "English (Ireland)" | |
| 964 | msgstr "Engleză (Irlanda)" | |
| 965 | ||
| 966 | msgctxt "#40017" | |
| 967 | msgid "English (New Zealand)" | |
| 968 | msgstr "Engleză (Noua Zeelandă)" | |
| 969 | ||
| 970 | msgctxt "#40018" | |
| 971 | msgid "English (United States of America)" | |
| 972 | msgstr "Engleză (Statele Unite ale Americii)" | |
| 973 | ||
| 974 | msgctxt "#40019" | |
| 975 | msgid "Esperanto (None)" | |
| 976 | msgstr "Esperanto" | |
| 977 | ||
| 978 | msgctxt "#40020" | |
| 979 | msgid "Spanish (Spain)" | |
| 980 | msgstr "Spaniolă (Spania)" | |
| 981 | ||
| 982 | msgctxt "#40021" | |
| 983 | msgid "Spanish (Mexico)" | |
| 984 | msgstr "Spaniolă (Mexic)" | |
| 985 | ||
| 986 | msgctxt "#40022" | |
| 987 | msgid "Estonian (Estonia)" | |
| 988 | msgstr "Estoniană (Estonia)" | |
| 989 | ||
| 990 | msgctxt "#40023" | |
| 991 | msgid "Basque (Spain)" | |
| 992 | msgstr "Bască (Spania)" | |
| 993 | ||
| 994 | msgctxt "#40024" | |
| 995 | msgid "Persian (Iran)" | |
| 996 | msgstr "Persană (Iran)" | |
| 997 | ||
| 998 | msgctxt "#40025" | |
| 999 | msgid "Finnish (Finland)" | |
| 1000 | msgstr "Finlandeză (Finlanda)" | |
| 1001 | ||
| 1002 | msgctxt "#40026" | |
| 1003 | msgid "French (Canada)" | |
| 1004 | msgstr "Franceză (Canada)" | |
| 1005 | ||
| 1006 | msgctxt "#40027" | |
| 1007 | msgid "French (France)" | |
| 1008 | msgstr "Franceză (Franța)" | |
| 1009 | ||
| 1010 | msgctxt "#40028" | |
| 1011 | msgid "Galician (Spain)" | |
| 1012 | msgstr "Galiciană (Spania)" | |
| 1013 | ||
| 1014 | msgctxt "#40029" | |
| 1015 | msgid "Hebrew (Israel)" | |
| 1016 | msgstr "Ebraică (Israel)" | |
| 1017 | ||
| 1018 | msgctxt "#40030" | |
| 1019 | msgid "Hindi (India)" | |
| 1020 | msgstr "Hindi (India)" | |
| 1021 | ||
| 1022 | msgctxt "#40031" | |
| 1023 | msgid "Hungarian (Hungary)" | |
| 1024 | msgstr "Maghiară (Ungaria)" | |
| 1025 | ||
| 1026 | msgctxt "#40032" | |
| 1027 | msgid "Indonesian (Indonesia)" | |
| 1028 | msgstr "Indoneziană (Indonezia)" | |
| 1029 | ||
| 1030 | msgctxt "#40033" | |
| 1031 | msgid "Italian (Italy)" | |
| 1032 | msgstr "Italiană (Italia)" | |
| 1033 | ||
| 1034 | msgctxt "#40034" | |
| 1035 | msgid "Japanese (Japan)" | |
| 1036 | msgstr "Japoneză (Japonia)" | |
| 1037 | ||
| 1038 | msgctxt "#40035" | |
| 1039 | msgid "Georgian (Georgia)" | |
| 1040 | msgstr "Georgiană (Georgia)" | |
| 1041 | ||
| 1042 | msgctxt "#40036" | |
| 1043 | msgid "Kazakh (Kazakhstan)" | |
| 1044 | msgstr "Kazahă (Kazahstan)" | |
| 1045 | ||
| 1046 | msgctxt "#40037" | |
| 1047 | msgid "Kannada (India)" | |
| 1048 | msgstr "Kannada (India)" | |
| 1049 | ||
| 1050 | msgctxt "#40038" | |
| 1051 | msgid "Korean (South Korea)" | |
| 1052 | msgstr "Coreeană (Coreea de Sud)" | |
| 1053 | ||
| 1054 | msgctxt "#40039" | |
| 1055 | msgid "Lithuanian (Lithuania)" | |
| 1056 | msgstr "Lituaniană (Lituania)" | |
| 1057 | ||
| 1058 | msgctxt "#40040" | |
| 1059 | msgid "Latvian (Latvia)" | |
| 1060 | msgstr "Letonă (Letonia)" | |
| 1061 | ||
| 1062 | msgctxt "#40041" | |
| 1063 | msgid "Malayalam (India)" | |
| 1064 | msgstr "Malayalam (India)" | |
| 1065 | ||
| 1066 | msgctxt "#40042" | |
| 1067 | msgid "Malay (Malaysia)" | |
| 1068 | msgstr "Malaieză (Malaysia)" | |
| 1069 | ||
| 1070 | msgctxt "#40043" | |
| 1071 | msgid "Malay (Singapore)" | |
| 1072 | msgstr "Malaieză (Singapore)" | |
| 1073 | ||
| 1074 | msgctxt "#40044" | |
| 1075 | msgid "Norwegian Bokmål (Norway)" | |
| 1076 | msgstr "Norvegiană Bokmål (Norvegia)" | |
| 1077 | ||
| 1078 | msgctxt "#40045" | |
| 1079 | msgid "Dutch (Netherlands)" | |
| 1080 | msgstr "Olandeză (Țările de Jos)" | |
| 1081 | ||
| 1082 | msgctxt "#40046" | |
| 1083 | msgid "Norwegian (Norway)" | |
| 1084 | msgstr "Norvegiană (Norvegia)" | |
| 1085 | ||
| 1086 | msgctxt "#40047" | |
| 1087 | msgid "Polish (Poland)" | |
| 1088 | msgstr "Poloneză (Polonia)" | |
| 1089 | ||
| 1090 | msgctxt "#40048" | |
| 1091 | msgid "Portuguese (Brazil)" | |
| 1092 | msgstr "Portugheză (Brazilia)" | |
| 1093 | ||
| 1094 | msgctxt "#40049" | |
| 1095 | msgid "Portuguese (Portugal)" | |
| 1096 | msgstr "Portugheză (Portugalia)" | |
| 1097 | ||
| 1098 | msgctxt "#40050" | |
| 1099 | msgid "Romanian (Romania)" | |
| 1100 | msgstr "Română (România)" | |
| 1101 | ||
| 1102 | msgctxt "#40051" | |
| 1103 | msgid "Russian (Russia)" | |
| 1104 | msgstr "Rusă (Rusia)" | |
| 1105 | ||
| 1106 | msgctxt "#40052" | |
| 1107 | msgid "Sinhalese (Sri Lanka)" | |
| 1108 | msgstr "Sinhaleză (Sri Lanka)" | |
| 1109 | ||
| 1110 | msgctxt "#40053" | |
| 1111 | msgid "Slovak (Slovakia)" | |
| 1112 | msgstr "Slovacă (Slovacia)" | |
| 1113 | ||
| 1114 | msgctxt "#40054" | |
| 1115 | msgid "Slovenian (Slovenia)" | |
| 1116 | msgstr "Slovenă (Slovenia)" | |
| 1117 | ||
| 1118 | msgctxt "#40055" | |
| 1119 | msgid "Serbian (Serbia)" | |
| 1120 | msgstr "Sârbă (Serbia)" | |
| 1121 | ||
| 1122 | msgctxt "#40056" | |
| 1123 | msgid "Swedish (Sweden)" | |
| 1124 | msgstr "Suedeză (Suedia)" | |
| 1125 | ||
| 1126 | msgctxt "#40057" | |
| 1127 | msgid "Tamil (India)" | |
| 1128 | msgstr "Tamilă (India)" | |
| 1129 | ||
| 1130 | msgctxt "#40058" | |
| 1131 | msgid "Telugu (India)" | |
| 1132 | msgstr "Telugu (India)" | |
| 1133 | ||
| 1134 | msgctxt "#40059" | |
| 1135 | msgid "Thai (Thailand)" | |
| 1136 | msgstr "Thailandeză (Thailanda)" | |
| 1137 | ||
| 1138 | msgctxt "#40060" | |
| 1139 | msgid "Tagalog (Philippines)" | |
| 1140 | msgstr "Tagalog (Filipine)" | |
| 1141 | ||
| 1142 | msgctxt "#40061" | |
| 1143 | msgid "Turkish (Turkey)" | |
| 1144 | msgstr "Turcă (Turcia)" | |
| 1145 | ||
| 1146 | msgctxt "#40062" | |
| 1147 | msgid "Ukrainian (Ukraine)" | |
| 1148 | msgstr "Ucraineană (Ucraina)" | |
| 1149 | ||
| 1150 | msgctxt "#40063" | |
| 1151 | msgid "Vietnamese (Vietnam)" | |
| 1152 | msgstr "Vietnameză (Vietnam)" | |
| 1153 | ||
| 1154 | msgctxt "#40064" | |
| 1155 | msgid "Mandarin (China)" | |
| 1156 | msgstr "Mandarină (China)" | |
| 1157 | ||
| 1158 | msgctxt "#40065" | |
| 1159 | msgid "Mandarin (Hong Kong)" | |
| 1160 | msgstr "Mandarină (Hong Kong)" | |
| 1161 | ||
| 1162 | msgctxt "#40066" | |
| 1163 | msgid "Mandarin (Taiwan)" | |
| 1164 | msgstr "Mandarină (Taiwan)" | |
| 1165 | ||
| 1166 | msgctxt "#40067" | |
| 1167 | msgid "Zulu (South Africa)" | |
| 1168 | msgstr "Zulu (Africa de Sud)" | |
| 1169 | ||
| 1170 | msgctxt "#42418" | |
| 1171 | msgid "Language and country" | |
| 1172 | msgstr "Limbă și țară" | |
| 1173 | ||
| 1174 | msgctxt "#42419" | |
| 1175 | msgid "The language and country settings used when looking up data from TMDb" | |
| 1176 | msgstr "Setările de limbă și țară folosite pentru căutarea datelor din TMDb" | |
| 1177 | ||
| 1178 | msgctxt "#42420" | |
| 1179 | msgid "EN" | |
| 1180 | msgstr "EN" | |
| 1181 | ||
| 1182 | msgctxt "#30801" | |
| 1183 | msgid "Zilean server address (include port if required)." | |
| 1184 | msgstr "Adresa serverului Zilean (include portul dacă este necesar)." | |
| 1185 | ||
| 1186 | msgctxt "#30803" | |
| 1187 | msgid "Display torrents not yet cached on your debrid service." | |
| 1188 | msgstr "Afișează torrentele care nu sunt încă în cache în serviciul tău de debrid." | |
| 1189 | ||
| 1190 | msgctxt "#30808" | |
| 1191 | msgid "Fanart.tv Client Key (for artwork)." | |
| 1192 | msgstr "Cheie client Fanart.tv (pentru imagini)." | |
| 1193 | ||
| 1194 | msgctxt "#30809" | |
| 1195 | msgid "Improve performance by caching metadata." | |
| 1196 | msgstr "Îmbunătățește performanța prin cache pentru metadate." | |
| 1197 | ||
| 1198 | msgctxt "#30810" | |
| 1199 | msgid "Seconds before Jackett requests time out (e.g., 30)." | |
| 1200 | msgstr "Secunde până la expirarea cererilor Jackett (ex.: 30)." | |
| 1201 | ||
| 1202 | msgctxt "#30811" | |
| 1203 | msgid "Trakt client secret from OAuth application settings." | |
| 1204 | msgstr "Secret client Trakt din setările aplicației OAuth." | |
| 1205 | ||
| 1206 | msgctxt "#30812" | |
| 1207 | msgid "Limit TV show results to specific seasons/episodes." | |
| 1208 | msgstr "Limitează rezultatele serialelor la anumite sezoane/episoade." | |
| 1209 | ||
| 1210 | msgctxt "#30813" | |
| 1211 | msgid "Hours before cached data is automatically refreshed (e.g., 24)." | |
| 1212 | msgstr "Ore până la reîmprospătarea automată a cache-ului (ex.: 24)." | |
| 1213 | ||
| 1214 | msgctxt "#30814" | |
| 1215 | msgid "Premiumize Client Token (get from account page)." | |
| 1216 | msgstr "Token client Premiumize (ia-l din pagina contului)." | |
| 1217 | ||
| 1218 | msgctxt "#30815" | |
| 1219 | msgid "Jackett server URL (e.g., http://localhost)." | |
| 1220 | msgstr "URL server Jackett (ex.: http://localhost)." | |
| 1221 | ||
| 1222 | msgctxt "#30816" | |
| 1223 | msgid "Display Telegram integration options in UI." | |
| 1224 | msgstr "Afișează opțiunile de integrare Telegram în interfață." | |
| 1225 | ||
| 1226 | msgctxt "#30817" | |
| 1227 | msgid "Your Real-Debrid account username/email." | |
| 1228 | msgstr "Numele de utilizator/email cont Real-Debrid." | |
| 1229 | ||
| 1230 | msgctxt "#30818" | |
| 1231 | msgid "Trakt client ID from https://trakt.tv/oauth/apps." | |
| 1232 | msgstr "ID client Trakt din https://trakt.tv/oauth/apps." | |
| 1233 | ||
| 1234 | msgctxt "#30819" | |
| 1235 | msgid "Connect Trakt.tv account to sync watch progress." | |
| 1236 | msgstr "Conectează contul Trakt.tv pentru sincronizarea progresului." | |
| 1237 | ||
| 1238 | msgctxt "#30820" | |
| 1239 | msgid "Seconds before Zilean requests time out (e.g., 30)." | |
| 1240 | msgstr "Secunde până la expirarea cererilor Zilean (ex.: 30)." | |
| 1241 | ||
| 1242 | msgctxt "#30821" | |
| 1243 | msgid "Your Jackett Client Key (found in dashboard)." | |
| 1244 | msgstr "Cheie client Jackett (din dashboard)." | |
| 1245 | ||
| 1246 | msgctxt "#30822" | |
| 1247 | msgid "Sort results by criteria (e.g., 'date', 'title', 'size')." | |
| 1248 | msgstr "Sortează rezultatele după criterii (ex: „dată”, „titlu”, „mărime”)." | |
| 1249 | ||
| 1250 | msgctxt "#30823" | |
| 1251 | msgid "Configure Burst mode settings and providers." | |
| 1252 | msgstr "Configurează modul Burst și furnizorii." | |
| 1253 | ||
| 1254 | msgctxt "#30824" | |
| 1255 | msgid "Enable Premiumize.me cloud service." | |
| 1256 | msgstr "Activează serviciul cloud Premiumize.me." | |
| 1257 | ||
| 1258 | msgctxt "#30825" | |
| 1259 | msgid "Truncate descriptions after [X] characters (0 for full text)." | |
| 1260 | msgstr "Trunchiază descrierile după [X] caractere (0 pentru text complet)." | |
| 1261 | ||
| 1262 | msgctxt "#30826" | |
| 1263 | msgid "Enable Prowlarr indexer management." | |
| 1264 | msgstr "Activează gestionarea indexatorilor Prowlarr." | |
| 1265 | ||
| 1266 | msgctxt "#30827" | |
| 1267 | msgid "Jackgram server URL (e.g., http://localhost:9117)." | |
| 1268 | msgstr "URL server Jackgram (ex.: http://localhost:9117)." | |
| 1269 | ||
| 1270 | msgctxt "#30828" | |
| 1271 | msgid "Debrider Client Key for authentication." | |
| 1272 | msgstr "Cheie client Debrider pentru autentificare." | |
| 1273 | ||
| 1274 | msgctxt "#30829" | |
| 1275 | msgid "Delay (in seconds) before next episode dialog appears (e.g., 10)." | |
| 1276 | msgstr "Întârziere (în secunde) înainte ca dialogul pentru episodul următor să apară (ex: 10)" | |
| 1277 | ||
| 1278 | msgctxt "#30830" | |
| 1279 | msgid "Specify the torrent client." | |
| 1280 | msgstr "Specifică clientul torrent." | |
| 1281 | ||
| 1282 | msgctxt "#30831" | |
| 1283 | msgid "Enable Debrider cached torrent support." | |
| 1284 | msgstr "Activează suportul pentru torrente în cache Debrider." | |
| 1285 | ||
| 1286 | msgctxt "#30832" | |
| 1287 | msgid "Torbox Client Token (found in account settings)." | |
| 1288 | msgstr "Token client Torbox (din setările contului)." | |
| 1289 | ||
| 1290 | msgctxt "#30833" | |
| 1291 | msgid "Trakt refresh token (auto-managed)." | |
| 1292 | msgstr "Token refresh Trakt (gestionat automat)." | |
| 1293 | ||
| 1294 | msgctxt "#30834" | |
| 1295 | msgid "Show confirmation dialog before playing next episode." | |
| 1296 | msgstr "Afișează confirmare înainte de episodul următor." | |
| 1297 | ||
| 1298 | msgctxt "#30835" | |
| 1299 | msgid "Authorize add-on to access Real-Debrid account." | |
| 1300 | msgstr "Autorizează add-on-ul să acceseze contul Real-Debrid." | |
| 1301 | ||
| 1302 | msgctxt "#30836" | |
| 1303 | msgid "Enable torrent-based media content" | |
| 1304 | msgstr "Activează conținut media bazat pe torrent" | |
| 1305 | ||
| 1306 | msgctxt "#30837" | |
| 1307 | msgid "Indicates if Real-Debrid account is linked." | |
| 1308 | msgstr "Indică dacă contul Real-Debrid este conectat." | |
| 1309 | ||
| 1310 | msgctxt "#30838" | |
| 1311 | msgid "Automatically play next media item when current playback ends." | |
| 1312 | msgstr "Redă automat următorul element media când se termină redarea curentă." | |
| 1313 | ||
| 1314 | msgctxt "#30839" | |
| 1315 | msgid "Check for and install the latest add-on updates." | |
| 1316 | msgstr "Verifică și instalează ultimele actualizări." | |
| 1317 | ||
| 1318 | msgctxt "#30840" | |
| 1319 | msgid "Comma-separated Prowlarr indexer IDs to use (e.g., 1,5,7)." | |
| 1320 | msgstr "ID-uri indexatori Prowlarr separate prin virgulă (ex.: 1,5,7)." | |
| 1321 | ||
| 1322 | msgctxt "#30841" | |
| 1323 | msgid "Timeout (seconds) for Prowlarr requests (e.g., 20)." | |
| 1324 | msgstr "Timeout (secunde) pentru cereri Prowlarr (ex.: 20)." | |
| 1325 | ||
| 1326 | msgctxt "#30842" | |
| 1327 | msgid "Trakt authorization expiration date (read-only)." | |
| 1328 | msgstr "Data expirării autorizării Trakt (doar citire)." | |
| 1329 | ||
| 1330 | msgctxt "#30843" | |
| 1331 | msgid "Enable Torbox.de cloud torrent integration." | |
| 1332 | msgstr "Activează integrarea cloud pentru torrent Torbox.de." | |
| 1333 | ||
| 1334 | msgctxt "#30844" | |
| 1335 | msgid "Trakt Access Token (auto-filled after auth)." | |
| 1336 | msgstr "Token de acces Trakt (completat automat după autentificare)." | |
| 1337 | ||
| 1338 | msgctxt "#30845" | |
| 1339 | msgid "Link Premiumize.me account to add-on." | |
| 1340 | msgstr "Conectează contul Premiumize.me." | |
| 1341 | ||
| 1342 | msgctxt "#30846" | |
| 1343 | msgid "Clear cached data automatically after updating the add-on." | |
| 1344 | msgstr "Șterge automat datele din cache după actualizarea add-on-ului." | |
| 1345 | ||
| 1346 | msgctxt "#30847" | |
| 1347 | msgid "Click to delete all cached data immediately." | |
| 1348 | msgstr "Apasă pentru a șterge imediat tot cache-ul." | |
| 1349 | ||
| 1350 | msgctxt "#30848" | |
| 1351 | msgid "Real-Debrid Client Token (auto-filled after auth)." | |
| 1352 | msgstr "Token client Real-Debrid (completat automat după autentificare)." | |
| 1353 | ||
| 1354 | msgctxt "#30849" | |
| 1355 | msgid "Enable Jackett torrent proxy support." | |
| 1356 | msgstr "Activează proxy torrent Jackett." | |
| 1357 | ||
| 1358 | msgctxt "#30850" | |
| 1359 | msgid "Enable Jackgram integration for enhanced search." | |
| 1360 | msgstr "Activează integrarea Jackgram." | |
| 1361 | ||
| 1362 | msgctxt "#30851" | |
| 1363 | msgid "Prowlarr instance URL (e.g., http://localhost)." | |
| 1364 | msgstr "URL instanță Prowlarr (ex.: http://localhost)." | |
| 1365 | ||
| 1366 | msgctxt "#30852" | |
| 1367 | msgid "Maximum number of search results to display (e.g., 50)." | |
| 1368 | msgstr "Număr maxim de rezultate afișate (ex.: 50)." | |
| 1369 | ||
| 1370 | msgctxt "#30853" | |
| 1371 | msgid "Revoke Real-Debrid account access." | |
| 1372 | msgstr "Revocă accesul Real-Debrid." | |
| 1373 | ||
| 1374 | msgctxt "#30854" | |
| 1375 | msgid "Prowlarr Client Key (found in Settings > General)." | |
| 1376 | msgstr "Cheie client Prowlarr (din Settings > General)." | |
| 1377 | ||
| 1378 | msgctxt "#30855" | |
| 1379 | msgid "Trakt.tv username (auto-populated after auth)." | |
| 1380 | msgstr "Nume utilizator Trakt.tv (completat automat după autentificare)." | |
| 1381 | ||
| 1382 | msgctxt "#30856" | |
| 1383 | msgid "Disconnect Trakt.tv account from add-on." | |
| 1384 | msgstr "Deconectează contul Trakt.tv." | |
| 1385 | ||
| 1386 | msgctxt "#30857" | |
| 1387 | msgid "Enable Zilean integration for metadata fetching." | |
| 1388 | msgstr "Activează integrarea Zilean pentru metadate." | |
| 1389 | ||
| 1390 | msgctxt "#30858" | |
| 1391 | msgid "Your Client Key (required for metadata)" | |
| 1392 | msgstr "Cheia ta client (necesară pentru metadate)" | |
| 1393 | ||
| 1394 | msgctxt "#30859" | |
| 1395 | msgid "Enable Burst mode for accelerated searches." | |
| 1396 | msgstr "Activează modul Burst pentru căutări rapide." | |
| 1397 | ||
| 1398 | msgctxt "#30860" | |
| 1399 | msgid "Others" | |
| 1400 | msgstr "Altele" | |
| 1401 | ||
| 1402 | msgctxt "#30861" | |
| 1403 | msgid "Filters" | |
| 1404 | msgstr "Filtre" | |
| 1405 | ||
| 1406 | msgctxt "#30862" | |
| 1407 | msgid "Torrent (P2P) Configuration" | |
| 1408 | msgstr "Configurează torrent (P2P)" | |
| 1409 | ||
| 1410 | msgctxt "#30863" | |
| 1411 | msgid "Specify the torrent client" | |
| 1412 | msgstr "Specifică clientul torrent" | |
| 1413 | ||
| 1414 | msgctxt "#30864" | |
| 1415 | msgid "Sources" | |
| 1416 | msgstr "Surse" | |
| 1417 | ||
| 1418 | msgctxt "#30865" | |
| 1419 | msgid "General Filters" | |
| 1420 | msgstr "Filtre generale" | |
| 1421 | ||
| 1422 | msgctxt "#30866" | |
| 1423 | msgid "Sign in to your Stremio account to access add-ons and personalized features." | |
| 1424 | msgstr "Autentifică-te în contul Stremio pentru add-on-uri și funcții personalizate." | |
| 1425 | ||
| 1426 | msgctxt "#30867" | |
| 1427 | msgid "Sign out of your Stremio account. This will disconnect your account but won't delete your data." | |
| 1428 | msgstr "Deconectează-te din contul tău Stremio. Acest lucru va deconecta contul, dar nu îți va șterge datele." | |
| 1429 | ||
| 1430 | msgctxt "#30868" | |
| 1431 | msgid "Log Out of Stremio" | |
| 1432 | msgstr "Deconectare din Stremio" | |
| 1433 | ||
| 1434 | msgctxt "#30869" | |
| 1435 | msgid "Enable Stremio add-ons integration." | |
| 1436 | msgstr "Activează integrarea add-on-urilor Stremio." | |
| 1437 | ||
| 1438 | msgctxt "#30870" | |
| 1439 | msgid "Select Stream Addons" | |
| 1440 | msgstr "Selectează add-on-uri de streaming" | |
| 1441 | ||
| 1442 | msgctxt "#30871" | |
| 1443 | msgid "Log In to Stremio" | |
| 1444 | msgstr "Autentificare în Stremio" | |
| 1445 | ||
| 1446 | msgctxt "#30872" | |
| 1447 | msgid "Enable or disable Stremio add-ons." | |
| 1448 | msgstr "Activează sau dezactivează add-on-urile Stremio." | |
| 1449 | ||
| 1450 | msgctxt "#30873" | |
| 1451 | msgid "Select language for player subtitle selection" | |
| 1452 | msgstr "Alege limba subtitrărilor" | |
| 1453 | ||
| 1454 | msgctxt "#30874" | |
| 1455 | msgid "Update Addons" | |
| 1456 | msgstr "Actualizează add-on-urile" | |
| 1457 | ||
| 1458 | msgctxt "#30875" | |
| 1459 | msgid "Select Catalogs Addons" | |
| 1460 | msgstr "Selectează add-on-uri de cataloage" | |
| 1461 | ||
| 1462 | msgctxt "#30876" | |
| 1463 | msgid "Download directory" | |
| 1464 | msgstr "Director descărcări" | |
| 1465 | ||
| 1466 | msgctxt "#30877" | |
| 1467 | msgid "Enable Trakt.tv Scrobbling" | |
| 1468 | msgstr "Activează scrobbling Trakt.tv" | |
| 1469 | ||
| 1470 | msgctxt "#30878" | |
| 1471 | msgid "Auto select subtitle when playing" | |
| 1472 | msgstr "Selectează automat subtitrarea" | |
| 1473 | ||
| 1474 | msgctxt "#30879" | |
| 1475 | msgid "Auto-play quality" | |
| 1476 | msgstr "Calitate redare automată" | |
| 1477 | ||
| 1478 | msgctxt "#30880" | |
| 1479 | msgid "1080p" | |
| 1480 | msgstr "1080p" | |
| 1481 | ||
| 1482 | msgctxt "#30881" | |
| 1483 | msgid "720p" | |
| 1484 | msgstr "720p" | |
| 1485 | ||
| 1486 | msgctxt "#30882" | |
| 1487 | msgid "4k" | |
| 1488 | msgstr "4K" | |
| 1489 | ||
| 1490 | msgctxt "#30883" | |
| 1491 | msgid "480p" | |
| 1492 | msgstr "480p" | |
| 1493 | ||
| 1494 | msgctxt "#30884" | |
| 1495 | msgid "Subtitles Configuration" | |
| 1496 | msgstr "Configurează subtitrările" | |
| 1497 | ||
| 1498 | msgctxt "#30885" | |
| 1499 | msgid "Addon Host" | |
| 1500 | msgstr "Host add-on" | |
| 1501 | ||
| 1502 | msgctxt "#30886" | |
| 1503 | msgid "Subtitles" | |
| 1504 | msgstr "Subtitrări" | |
| 1505 | ||
| 1506 | msgctxt "#30887" | |
| 1507 | msgid "Primary Language" | |
| 1508 | msgstr "Limbă principală" | |
| 1509 | ||
| 1510 | msgctxt "#30888" | |
| 1511 | msgid "Secondary Language" | |
| 1512 | msgstr "Limbă secundară" | |
| 1513 | ||
| 1514 | msgctxt "#30889" | |
| 1515 | msgid "Deepl Enabled" | |
| 1516 | msgstr "Deepl activat" | |
| 1517 | ||
| 1518 | msgctxt "#30890" | |
| 1519 | msgid "Deepl Client Key" | |
| 1520 | msgstr "Cheie client Deepl" | |
| 1521 | ||
| 1522 | msgctxt "#30891" | |
| 1523 | msgid "Translator Configuration" | |
| 1524 | msgstr "Configurează traducătorul" | |
| 1525 | ||
| 1526 | msgctxt "#30892" | |
| 1527 | msgid "Deepl Target Language" | |
| 1528 | msgstr "Limbă țintă Deepl" | |
| 1529 | ||
| 1530 | msgctxt "#30893" | |
| 1531 | msgid "Stremio Subtitle Configuration" | |
| 1532 | msgstr "Configurează subtitrările Stremio" | |
| 1533 | ||
| 1534 | msgctxt "#90001" | |
| 1535 | msgid "Add Custom Addon" | |
| 1536 | msgstr "Adaugă add-on personalizat" | |
| 1537 | ||
| 1538 | msgctxt "#90002" | |
| 1539 | msgid "Add a custom Stremio addon by entering its URL" | |
| 1540 | msgstr "Adaugă un add-on Stremio introducând URL-ul" | |
| 1541 | ||
| 1542 | msgctxt "#90003" | |
| 1543 | msgid "Auto-select audio when playing" | |
| 1544 | msgstr "Selectează automat audio" | |
| 1545 | ||
| 1546 | msgctxt "#90004" | |
| 1547 | msgid "Audio Language" | |
| 1548 | msgstr "Limbă audio" | |
| 1549 | ||
| 1550 | msgctxt "#90005" | |
| 1551 | msgid "Select audio language for playing the video" | |
| 1552 | msgstr "Alege limba audio pentru redare" | |
| 1553 | ||
| 1554 | msgctxt "#90006" | |
| 1555 | msgid "Search" | |
| 1556 | msgstr "Caută" | |
| 1557 | ||
| 1558 | msgctxt "#90007" | |
| 1559 | msgid "TV Shows" | |
| 1560 | msgstr "Seriale" | |
| 1561 | ||
| 1562 | msgctxt "#90008" | |
| 1563 | msgid "Movies" | |
| 1564 | msgstr "Filme" | |
| 1565 | ||
| 1566 | msgctxt "#90009" | |
| 1567 | msgid "Anime" | |
| 1568 | msgstr "Anime" | |
| 1569 | ||
| 1570 | msgctxt "#90010" | |
| 1571 | msgid "Live TV" | |
| 1572 | msgstr "TV Live" | |
| 1573 | ||
| 1574 | msgctxt "#90011" | |
| 1575 | msgid "Direct Search" | |
| 1576 | msgstr "Căutare directă" | |
| 1577 | ||
| 1578 | msgctxt "#90012" | |
| 1579 | msgid "Torrents" | |
| 1580 | msgstr "Torrente" | |
| 1581 | ||
| 1582 | msgctxt "#90013" | |
| 1583 | msgid "Telegram" | |
| 1584 | msgstr "Telegram" | |
| 1585 | ||
| 1586 | msgctxt "#90014" | |
| 1587 | msgid "Cloud" | |
| 1588 | msgstr "Cloud" | |
| 1589 | ||
| 1590 | msgctxt "#90015" | |
| 1591 | msgid "Downloads" | |
| 1592 | msgstr "Descărcări" | |
| 1593 | ||
| 1594 | msgctxt "#90016" | |
| 1595 | msgid "Settings" | |
| 1596 | msgstr "Setări" | |
| 1597 | ||
| 1598 | msgctxt "#90017" | |
| 1599 | msgid "History" | |
| 1600 | msgstr "Istoric" | |
| 1601 | ||
| 1602 | msgctxt "#90018" | |
| 1603 | msgid "Donate" | |
| 1604 | msgstr "Donează" | |
| 1605 | ||
| 1606 | msgctxt "#90019" | |
| 1607 | msgid "Recently Watched Files" | |
| 1608 | msgstr "Fișiere vizionate recent" | |
| 1609 | ||
| 1610 | msgctxt "#90020" | |
| 1611 | msgid "Recently Watched Titles" | |
| 1612 | msgstr "Titluri vizionate recent" | |
| 1613 | ||
| 1614 | msgctxt "#90021" | |
| 1615 | msgid "TV Calendar" | |
| 1616 | msgstr "Calendar TV" | |
| 1617 | ||
| 1618 | ||
| 1619 | msgctxt "#90022" | |
| 1620 | msgid "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." | |
| 1621 | msgstr "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 | ||
| 1623 | msgctxt "#90023" | |
| 1624 | msgid "Support Jacktook" | |
| 1625 | msgstr "Susține Jacktook" | |
| 1626 | ||
| 1627 | msgctxt "#90024" | |
| 1628 | msgid "Trending Recent" | |
| 1629 | msgstr "Tendințe recente" | |
| 1630 | ||
| 1631 | msgctxt "#90025" | |
| 1632 | msgid "Genres" | |
| 1633 | msgstr "Genuri" | |
| 1634 | ||
| 1635 | msgctxt "#90026" | |
| 1636 | msgid "Calendar" | |
| 1637 | msgstr "Calendar" | |
| 1638 | ||
| 1639 | msgctxt "#90027" | |
| 1640 | msgid "Years" | |
| 1641 | msgstr "Ani" | |
| 1642 | ||
| 1643 | msgctxt "#90028" | |
| 1644 | msgid "Trending" | |
| 1645 | msgstr "Tendințe" | |
| 1646 | ||
| 1647 | msgctxt "#90029" | |
| 1648 | msgid "Most Watched" | |
| 1649 | msgstr "Cele mai vizionate" | |
| 1650 | ||
| 1651 | msgctxt "#90030" | |
| 1652 | msgid "Most Favorited" | |
| 1653 | msgstr "Cele mai apreciate" | |
| 1654 | ||
| 1655 | msgctxt "#90031" | |
| 1656 | msgid "Popular Lists" | |
| 1657 | msgstr "Liste populare" | |
| 1658 | ||
| 1659 | msgctxt "#90032" | |
| 1660 | msgid "Trending Lists" | |
| 1661 | msgstr "Liste în tendințe" | |
| 1662 | ||
| 1663 | msgctxt "#90033" | |
| 1664 | msgid "Recomendations" | |
| 1665 | msgstr "Recomandări" | |
| 1666 | ||
| 1667 | msgctxt "#90034" | |
| 1668 | msgid "Watched History" | |
| 1669 | msgstr "Istoric vizionări" | |
| 1670 | ||
| 1671 | msgctxt "#90035" | |
| 1672 | msgid "Watchlist" | |
| 1673 | msgstr "Listă de urmărire" | |
| 1674 | ||
| 1675 | msgctxt "#90036" | |
| 1676 | msgid "Top 10 BoxOffice" | |
| 1677 | msgstr "Top 10 Box Office" | |
| 1678 | ||
| 1679 | msgctxt "#90037" | |
| 1680 | msgid "Popular" | |
| 1681 | msgstr "Populare" | |
| 1682 | ||
| 1683 | msgctxt "#90038" | |
| 1684 | msgid "Popular Recent" | |
| 1685 | msgstr "Populare recent" | |
| 1686 | ||
| 1687 | msgctxt "#90039" | |
| 1688 | msgid "Airing" | |
| 1689 | msgstr "În difuzare" | |
| 1690 | ||
| 1691 | msgctxt "#90040" | |
| 1692 | msgid "Years" | |
| 1693 | msgstr "Ani" | |
| 1694 | ||
| 1695 | msgctxt "#90041" | |
| 1696 | msgid "Genres" | |
| 1697 | msgstr "Genuri" | |
| 1698 | ||
| 1699 | msgctxt "#90042" | |
| 1700 | msgid "Top Rated" | |
| 1701 | msgstr "Cele mai bine evaluate" | |
| 1702 | ||
| 1703 | msgctxt "#90043" | |
| 1704 | msgid "Most Watched" | |
| 1705 | msgstr "Cele mai vizionate" | |
| 1706 | ||
| 1707 | msgctxt "#90044" | |
| 1708 | msgid "Torrentio Configuration" | |
| 1709 | msgstr "Configurare Torrentio" | |
| 1710 | ||
| 1711 | msgctxt "#90045" | |
| 1712 | msgid "Enable Torrentio Settings" | |
| 1713 | msgstr "Activează setările Torrentio" | |
| 1714 | ||
| 1715 | msgctxt "#90046" | |
| 1716 | msgid "Enable or disable the Torrentio Configuration" | |
| 1717 | msgstr "Activează sau dezactivează configurarea Torrentio" | |
| 1718 | ||
| 1719 | msgctxt "#90047" | |
| 1720 | msgid "Select Torrentio Providers" | |
| 1721 | msgstr "Selectează furnizorii Torrentio" | |
| 1722 | ||
| 1723 | msgctxt "#90048" | |
| 1724 | msgid "Choose which providers to include in the stream request." | |
| 1725 | msgstr "Alege ce furnizori să incluzi în cererea de stream" | |
| 1726 | ||
| 1727 | # Context menus | |
| 1728 | ||
| 1729 | msgctxt "#90049" | |
| 1730 | msgid "Scrape again" | |
| 1731 | msgstr "Caută din nou" | |
| 1732 | ||
| 1733 | msgctxt "#90050" | |
| 1734 | msgid "Recommendations" | |
| 1735 | msgstr "Recomandări" | |
| 1736 | ||
| 1737 | msgctxt "#90051" | |
| 1738 | msgid "Similar" | |
| 1739 | msgstr "Similare" | |
| 1740 | ||
| 1741 | msgctxt "#90052" | |
| 1742 | msgid "Thread Number" | |
| 1743 | msgstr "Număr thread" | |
| 1744 | ||
| 1745 | msgctxt "#90053" | |
| 1746 | msgid "Select the number of threads (1-30)" | |
| 1747 | msgstr "Selectează numărul de thread-uri (1-30)" | |
| 1748 | ||
| 1749 | msgctxt "#90054" | |
| 1750 | msgid "Kodi logs" | |
| 1751 | msgstr "Log-uri Kodi" | |
| 1752 | ||
| 1753 | msgctxt "#90055" | |
| 1754 | msgid "Kodi old logs" | |
| 1755 | msgstr "Log-uri vechi Kodi" | |
| 1756 | ||
| 1757 | msgctxt "#90056" | |
| 1758 | msgid "Displays Kodi logs to help diagnose issues." | |
| 1759 | msgstr "Afișează log-urile Kodi pentru a ajuta la diagnosticarea problemelor" | |
| 1760 | ||
| 1761 | msgctxt "#90057" | |
| 1762 | msgid "Logs" | |
| 1763 | msgstr "Log-uri" | |
| 1764 | ||
| 1765 | msgctxt "#90058" | |
| 1766 | msgid "Filter by Size" | |
| 1767 | msgstr "Filtrează după mărime" | |
| 1768 | ||
| 1769 | msgctxt "#90059" | |
| 1770 | msgid "Enable Size Filter" | |
| 1771 | msgstr "Activează filtrul de mărime" | |
| 1772 | ||
| 1773 | msgctxt "#90060" | |
| 1774 | msgid "Minimum Size (MB)" | |
| 1775 | msgstr "Dimensiune mărime (MB)" | |
| 1776 | ||
| 1777 | msgctxt "#90061" | |
| 1778 | msgid "Maximum Size (MB)" | |
| 1779 | msgstr "Mărime maximă (MB)" | |
| 1780 | ||
| 1781 | msgctxt "#90062" | |
| 1782 | msgid "Enable Cached Search" | |
| 1783 | msgstr "Activează căutarea în cache" | |
| 1784 | ||
| 1785 | msgctxt "#90063" | |
| 1786 | msgid "Only show cached streams from external services" | |
| 1787 | msgstr "Afișează doar stream-uri din cache de la servicii externe" | |
| 1788 | ||
| 1789 | msgctxt "#90064" | |
| 1790 | msgid "Popular" | |
| 1791 | msgstr "Populare" | |
| 1792 | ||
| 1793 | msgctxt "#90065" | |
| 1794 | msgid "Languages" | |
| 1795 | msgstr "Limbi" | |
| 1796 | ||
| 1797 | msgctxt "#90066" | |
| 1798 | msgid "Networks" | |
| 1799 | msgstr "Rețele" | |
| 1800 | ||
| 1801 | msgctxt "#90067" | |
| 1802 | msgid "Collections" | |
| 1803 | msgstr "Colecții" | |
| 1804 | ||
| 1805 | msgctxt "#90068" | |
| 1806 | msgid "Search for collections:" | |
| 1807 | msgstr "Caută colecții" | |
| 1808 | ||
| 1809 | msgctxt "#90069" | |
| 1810 | msgid "Main Menu" | |
| 1811 | msgstr "Meniu principal" | |
| 1812 | ||
| 1813 | msgctxt "#90070" | |
| 1814 | msgid "Last Titles" | |
| 1815 | msgstr "Ultimele titluri" | |
| 1816 | ||
| 1817 | msgctxt "#90071" | |
| 1818 | msgid "Last Files" | |
| 1819 | msgstr "Ultimele fișiere" | |
| 1820 | ||
| 1821 | msgctxt "#90072" | |
| 1822 | msgid "Trakt Lists" | |
| 1823 | msgstr "Liste Trakt" | |
| 1824 | ||
| 1825 | msgctxt "#90073" | |
| 1826 | msgid "Metadata" | |
| 1827 | msgstr "Metadate" | |
| 1828 | ||
| 1829 | msgctxt "#90074" | |
| 1830 | msgid "Top Lists" | |
| 1831 | msgstr "Top liste" | |
| 1832 | ||
| 1833 | msgctxt "#90075" | |
| 1834 | msgid "User Lists" | |
| 1835 | msgstr "Liste utilizator" | |
| 1836 | ||
| 1837 | msgctxt "#90076" | |
| 1838 | msgid "Search Lists" | |
| 1839 | msgstr "Caută liste" | |
| 1840 | ||
| 1841 | msgctxt "#90077" | |
| 1842 | msgid "MDbList Configuration" | |
| 1843 | msgstr "Configurare MDbList" | |
| 1844 | ||
| 1845 | msgctxt "#90078" | |
| 1846 | msgid "People" | |
| 1847 | msgstr "Persoane" | |
| 1848 | ||
| 1849 | msgctxt "#90079" | |
| 1850 | msgid "Popular People" | |
| 1851 | msgstr "Persoane populare" | |
| 1852 | ||
| 1853 | msgctxt "#90080" | |
| 1854 | msgid "Latest People" | |
| 1855 | msgstr "Persoane recente" | |
| 1856 | ||
| 1857 | msgctxt "#90081" | |
| 1858 | msgid "Search People" | |
| 1859 | msgstr "Caută persoane" | |
| 1860 | ||
| 1861 | msgctxt "#90082" | |
| 1862 | msgid "Download subtitles" | |
| 1863 | msgstr "Descarcă subtitrări" | |
| 1864 | ||
| 1865 | msgctxt "#90083" | |
| 1866 | msgid "Download video" | |
| 1867 | msgstr "Descarcă video" | |
| 1868 | ||
| 1869 | msgctxt "#90084" | |
| 1870 | msgid "Browse into" | |
| 1871 | msgstr "Răsfoiește în" | |
| 1872 | ||
| 1873 | msgctxt "#90085" | |
| 1874 | msgid "Include TV show Specials" | |
| 1875 | msgstr "Include episoade speciale" | |
| 1876 | ||
| 1877 | msgctxt "#90086" | |
| 1878 | msgid "Airing Today" | |
| 1879 | msgstr "Difuzate azi" | |
| 1880 | ||
| 1881 | msgctxt "#90099" | |
| 1882 | msgid "Filter by Source Type" | |
| 1883 | msgstr "Filtrează după tipul sursei" | |
| 1884 | ||
| 1885 | # BluRay/UHD | |
| 1886 | msgctxt "#90100" | |
| 1887 | msgid "Include BluRay/UHD sources" | |
| 1888 | msgstr "Include surse BluRay/UHD" | |
| 1889 | ||
| 1890 | msgctxt "#90105" | |
| 1891 | msgid "Includes releases such as: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX" | |
| 1892 | msgstr "Include versiuni precum: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX" | |
| 1893 | ||
| 1894 | # WEB/HD | |
| 1895 | msgctxt "#90101" | |
| 1896 | msgid "Include WEB/HD sources" | |
| 1897 | msgstr "Include surse WEB/HD" | |
| 1898 | ||
| 1899 | msgctxt "#90106" | |
| 1900 | msgid "Includes releases such as: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux" | |
| 1901 | msgstr "Include versiuni precum: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux" | |
| 1902 | ||
| 1903 | # DVD/TV/SAT | |
| 1904 | msgctxt "#90102" | |
| 1905 | msgid "Include DVD/TV/SAT sources" | |
| 1906 | msgstr "Include surse DVD/TV/SAT" | |
| 1907 | ||
| 1908 | msgctxt "#90107" | |
| 1909 | msgid "Includes releases such as: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV" | |
| 1910 | msgstr "Include versiuni precum: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV" | |
| 1911 | ||
| 1912 | # CAM/Screener | |
| 1913 | msgctxt "#90103" | |
| 1914 | msgid "Include CAM/Screener sources" | |
| 1915 | msgstr "Include surse CAM/Screener" | |
| 1916 | ||
| 1917 | msgctxt "#90108" | |
| 1918 | msgid "Includes releases such as: CAM, TeleSync, TeleCine, SCR" | |
| 1919 | msgstr "Include versiuni precum: CAM, TeleSync, TeleCine, SCR" | |
| 1920 | ||
| 1921 | # Unknown | |
| 1922 | msgctxt "#90104" | |
| 1923 | msgid "Include Unknown sources" | |
| 1924 | msgstr "Include surse necunoscute" | |
| 1925 | ||
| 1926 | msgctxt "#90109" | |
| 1927 | msgid "Includes uncategorized or unidentified sources" | |
| 1928 | msgstr "Include surse necategorizate sau neidentificate" | |
| 1929 | ||
| 1930 | msgctxt "#90110" | |
| 1931 | msgid "Do you want to clear your watched history?." | |
| 1932 | msgstr "Vrei să îți ștergi istoricul de vizionări?" | |
| 1933 | ||
| 1934 | msgctxt "#90111" | |
| 1935 | msgid "Do you want to clear your history?." | |
| 1936 | msgstr "Vrei să îți ștergi istoricul?" | |
| 1937 | ||
| 1938 | msgctxt "#90112" | |
| 1939 | msgid "Clear History" | |
| 1940 | msgstr "Șterge istoricul" | |
| 1941 | ||
| 1942 | msgctxt "#90113" | |
| 1943 | msgid "Click to delete all watched history immediately." | |
| 1944 | msgstr "Apasă pentru a șterge imediat tot istoricul de vizionări" | |
| 1945 | ||
| 1946 | msgctxt "#90114" | |
| 1947 | msgid "History cleared" | |
| 1948 | msgstr "Istoric șters" | |
| 1949 | ||
| 1950 | msgctxt "#90115" | |
| 1951 | msgid "Source Select" | |
| 1952 | msgstr "Selectare sursă" | |
| 1953 | ||
| 1954 | msgctxt "#90116" | |
| 1955 | msgid "Open Settings" | |
| 1956 | msgstr "Deschide setările" | |
| 1957 | ||
| 1958 | msgctxt "#90117" | |
| 1959 | msgid "Select Torrentio Providers" | |
| 1960 | msgstr "Selectează providerii Torrentio" | |
| 1961 | ||
| 1962 | msgctxt "#90118" | |
| 1963 | msgid "Port" | |
| 1964 | msgstr "Port" | |
| 1965 | ||
| 1966 | msgctxt "#90119" | |
| 1967 | msgid "Jackett port (default:9117)" | |
| 1968 | msgstr "Port Jackett (implicit: 9117)" | |
| 1969 | ||
| 1970 | msgctxt "#90120" | |
| 1971 | msgid "Prowlarr port (default:9696)" | |
| 1972 | msgstr "Port Prowlarr (implicit: 9696)" | |
| 1973 | ||
| 1974 | msgctxt "#90121" | |
| 1975 | msgid "All Debrid Configuration" | |
| 1976 | msgstr "Configurare All Debrid" | |
| 1977 | ||
| 1978 | msgctxt "#90122" | |
| 1979 | msgid "Auto download subtitles" | |
| 1980 | msgstr "Descarcă automat subtitrări" | |
| 1981 | ||
| 1982 | msgctxt "#90123" | |
| 1983 | msgid "Stremio Timeout" | |
| 1984 | msgstr "Timeout Stremio" | |
| 1985 | ||
| 1986 | msgctxt "#90124" | |
| 1987 | msgid "Webdav Client Configuration" | |
| 1988 | msgstr "Configurare client WebDAV" | |
| 1989 | ||
| 1990 | msgctxt "#90126" | |
| 1991 | msgid "Host" | |
| 1992 | msgstr "Host" | |
| 1993 | ||
| 1994 | msgctxt "#90127" | |
| 1995 | msgid "Username" | |
| 1996 | msgstr "Utilizator" | |
| 1997 | ||
| 1998 | msgctxt "#90128" | |
| 1999 | msgid "Password" | |
| 2000 | msgstr "Parolă" | |
| 2001 | ||
| 2002 | msgctxt "#90129" | |
| 2003 | msgid "Test Connection" | |
| 2004 | msgstr "Testează conexiunea" | |
| 2005 | ||
| 2006 | msgctxt "#90131" | |
| 2007 | msgid "Webdav" | |
| 2008 | msgstr "WebDAV" | |
| 2009 | ||
| 2010 | msgctxt "#90132" | |
| 2011 | msgid "Remote Path" | |
| 2012 | msgstr "Cale remote" | |
| 2013 | ||
| 2014 | msgctxt "#90133" | |
| 2015 | msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)." | |
| 2016 | msgstr "Numele hostului sau adresa IP a serverului WebDAV (ex: http://mywebdav.com sau https://192.168.1.100)" | |
| 2017 | ||
| 2018 | msgctxt "#90134" | |
| 2019 | msgid "The username required for authenticating with your WebDAV server" | |
| 2020 | msgstr "Numele de utilizator necesar pentru autentificarea pe serverul WebDAV." | |
| 2021 | ||
| 2022 | msgctxt "#90135" | |
| 2023 | msgid "The password for the specified username to authenticate with your WebDAV server" | |
| 2024 | msgstr "Parola pentru numele de utilizator specificat, pentru autentificarea pe serverul WebDAV" | |
| 2025 | ||
| 2026 | msgctxt "#90136" | |
| 2027 | msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)." | |
| 2028 | msgstr "Calea pe serverul WebDAV unde se află fișierele media (ex: /movies)" | |
| 2029 | ||
| 2030 | msgctxt "#90137" | |
| 2031 | msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials." | |
| 2032 | msgstr "Apasă pentru a verifica dacă clientul WebDAV se poate conecta cu succes la serverul configurat folosind datele de autentificare furnizate." | |
| 2033 | ||
| 2034 | msgctxt "#90138" | |
| 2035 | msgid "Remove Custom Addon" | |
| 2036 | msgstr "Elimină add-on-ul personalizat" | |
| 2037 | ||
| 2038 | msgctxt "#90139" | |
| 2039 | msgid "Playback" | |
| 2040 | msgstr "Redare" | |
| 2041 | ||
| 2042 | msgctxt "#90140" | |
| 2043 | msgid "Super quick play" | |
| 2044 | msgstr "Redare foarte rapidă" | |
| 2045 | ||
| 2046 | msgctxt "#90141" | |
| 2047 | msgid "Re-ask to play last played video" | |
| 2048 | msgstr "Întreabă din nou dacă vrei să redai ultimul video" | |
| 2049 | ||
| 2050 | msgctxt "#90142" | |
| 2051 | msgid "Replay Dialog" | |
| 2052 | msgstr "Dialog reluare" | |
| 2053 | ||
| 2054 | msgctxt "#90143" | |
| 2055 | msgid "Do you want to play last watched?" | |
| 2056 | msgstr "Vrei să redai ultimul videoclip vizionat?" | |
| 2057 | ||
| 2058 | msgctxt "#90144" | |
| 2059 | msgid "Could not resolve playback URL" | |
| 2060 | msgstr "Nu s-a putut obține adresa de redare" | |
| 2061 | ||
| 2062 | msgctxt "#90171" | |
| 2063 | msgid "Autoscrape Next Episode" | |
| 2064 | msgstr "Autoscrape următorul episod" | |
| 2065 | ||
| 2066 | msgctxt "#90172" | |
| 2067 | msgid "Pre-resolve the next episode's playback URL in the background while watching." | |
| 2068 | msgstr "Pre-rezolvă în fundal URL-ul de redare al următorului episod în timp ce te uiți." | |
| 2069 | ||
| 2070 | msgctxt "#90173" | |
| 2071 | msgid "Autoscrape Threshold" | |
| 2072 | msgstr "Prag Autoscrape" | |
| 2073 | ||
| 2074 | msgctxt "#90174" | |
| 2075 | msgid "Percentage of playback to trigger background pre-resolution." | |
| 2076 | msgstr "Procentul de redare la care se declanșează pre-rezoluția în fundal." | |
| 2077 | ||
| 2078 | msgctxt "#90175" | |
| 2079 | msgid "Autoscrape Episode Count" | |
| 2080 | msgstr "Număr episoade Autoscrape" | |
| 2081 | ||
| 2082 | msgctxt "#90176" | |
| 2083 | msgid "Number of upcoming episodes to pre-resolve." | |
| 2084 | msgstr "Numărul de episoade următoare care se pre-rezolvă." | |
| 2085 | ||
| 2086 | msgctxt "#90177" | |
| 2087 | msgid "Autoscrape Cache TTL" | |
| 2088 | msgstr "TTL cache Autoscrape" | |
| 2089 | ||
| 2090 | msgctxt "#90178" | |
| 2091 | msgid "Hours to keep resolved playback data in cache." | |
| 2092 | msgstr "Numărul de ore în care datele de redare rezolvate sunt păstrate în cache." | |
| 2093 | ||
| 2094 | msgctxt "#30901" | |
| 2095 | msgid "Show Only Stremio Catalogs" | |
| 2096 | msgstr "Afișează doar cataloage Stremio" | |
| 2097 | ||
| 2098 | msgctxt "#30902" | |
| 2099 | msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus." | |
| 2100 | msgstr "Ascunde elementele integrate (TMDb, Trakt etc.) și afișează doar cataloagele Stremio în meniuri." | |
| 2101 | ||
| 2102 | msgctxt "#30903" | |
| 2103 | msgid "Select Live TV Addons" | |
| 2104 | msgstr "Selectează add-on-uri TV Live" | |
| 2105 | ||
| 2106 | msgctxt "#30904" | |
| 2107 | msgid "Choose specific Stremio addons for Live TV content" | |
| 2108 | msgstr "Alege add-on-uri Stremio specifice pentru conținut TV Live" | |
| 2109 | ||
| 2110 | msgctxt "#30920" | |
| 2111 | msgid "Clear Trakt Cache" | |
| 2112 | msgstr "Șterge cache Trakt" | |
| 2113 | ||
| 2114 | msgctxt "#30921" | |
| 2115 | msgid "Clear TMDB Cache" | |
| 2116 | msgstr "Șterge cache TMDB" | |
| 2117 | ||
| 2118 | msgctxt "#30922" | |
| 2119 | msgid "Clear Providers Cache" | |
| 2120 | msgstr "Șterge cache provideri" | |
| 2121 | ||
| 2122 | msgctxt "#30923" | |
| 2123 | msgid "Clear Settings Cache" | |
| 2124 | msgstr "Șterge cache setări" | |
| 2125 | ||
| 2126 | msgctxt "#90149" | |
| 2127 | msgid "Maintenance" | |
| 2128 | msgstr "Mentenanță" | |
| 2129 | ||
| 2130 | msgctxt "#90150" | |
| 2131 | msgid "Stremio: General" | |
| 2132 | msgstr "Stremio: General" | |
| 2133 | ||
| 2134 | msgctxt "#90151" | |
| 2135 | msgid "Stremio: Account" | |
| 2136 | msgstr "Stremio: Cont" | |
| 2137 | ||
| 2138 | msgctxt "#90152" | |
| 2139 | msgid "Stremio: Addons & Catalogs" | |
| 2140 | msgstr "Stremio: Add-on-uri și cataloage" | |
| 2141 | ||
| 2142 | msgctxt "#90153" | |
| 2143 | msgid "Stremio: Torrentio" | |
| 2144 | msgstr "Stremio: Torrentio" | |
| 2145 | ||
| 2146 | msgctxt "#90154" | |
| 2147 | msgid "Trending Recent" | |
| 2148 | msgstr "Tendințe recente" | |
| 2149 | ||
| 2150 | msgctxt "#90155" | |
| 2151 | msgid "Stremio: Filters" | |
| 2152 | msgstr "Stremio: Filtre" | |
| 2153 | ||
| 2154 | msgctxt "#32300" | |
| 2155 | msgid "Filter by Quality" | |
| 2156 | msgstr "Filtrează după calitate" | |
| 2157 | ||
| 2158 | msgctxt "#32301" | |
| 2159 | msgid "Filter by Provider" | |
| 2160 | msgstr "Filtrează după provider" | |
| 2161 | ||
| 2162 | msgctxt "#32302" | |
| 2163 | msgid "Filter by Source" | |
| 2164 | msgstr "Filtrează după sursă" | |
| 2165 | ||
| 2166 | msgctxt "#32303" | |
| 2167 | msgid "Filter by Language" | |
| 2168 | msgstr "Filtrează după limbă" | |
| 2169 | ||
| 2170 | msgctxt "#32304" | |
| 2171 | msgid "Filter by Torrents" | |
| 2172 | msgstr "Filtrează după torrente" | |
| 2173 | ||
| 2174 | msgctxt "#32305" | |
| 2175 | msgid "Filter by Debrid" | |
| 2176 | msgstr "Filtrează după Debrid" | |
| 2177 | ||
| 2178 | msgctxt "#32306" | |
| 2179 | msgid "Reset Filter" | |
| 2180 | msgstr "Resetează filtrul" | |
| 2181 | ||
| 2182 | msgctxt "#30930" | |
| 2183 | msgid "Ping Addons" | |
| 2184 | msgstr "Testează add-on-uri" | |
| 2185 | ||
| 2186 | msgctxt "#30931" | |
| 2187 | msgid "Check availability of addons and show only reachable ones" | |
| 2188 | msgstr "Verifică disponibilitatea add-on-urilor și afișează doar cele accesibile" | |
| 2189 | ||
| 2190 | msgctxt "#90164" | |
| 2191 | msgid "Skip Intro / Recap" | |
| 2192 | msgstr "Omite intro / recapitulare" | |
| 2193 | ||
| 2194 | msgctxt "#90165" | |
| 2195 | msgid "Enable Skip Intro" | |
| 2196 | msgstr "Activează Omite Intro" | |
| 2197 | ||
| 2198 | msgctxt "#90166" | |
| 2199 | msgid "Detect intros and recaps using IntroDB and offer to skip them during playback" | |
| 2200 | msgstr "Detectează intro-urile și recapitulările folosind IntroDB și oferă opțiunea de omitere în timpul redării" | |
| 2201 | ||
| 2202 | msgctxt "#90167" | |
| 2203 | msgid "Auto-skip" | |
| 2204 | msgstr "Omite automat" | |
| 2205 | ||
| 2206 | msgctxt "#90168" | |
| 2207 | msgid "Automatically skip intros and recaps without showing the button" | |
| 2208 | msgstr "Omite automat intro-urile și rezumatele, fără afișarea butonului." | |
| 2209 | ||
| 2210 | msgctxt "#90160" | |
| 2211 | msgid "Skip Intro" | |
| 2212 | msgstr "Omite intro" | |
| 2213 | ||
| 2214 | msgctxt "#90161" | |
| 2215 | msgid "Skip Recap" | |
| 2216 | msgstr "Omite recapitulare" | |
| 2217 | ||
| 2218 | msgctxt "#90162" | |
| 2219 | msgid "Manage from Phone" | |
| 2220 | msgstr "Gestionează de pe telefon" | |
| 2221 | ||
| 2222 | msgctxt "#90163" | |
| 2223 | msgid "Start a local web server and show a QR code to manage Stremio sources from your phone" | |
| 2224 | msgstr "Pornește un server web local și afișează un cod QR pentru a gestiona sursele Stremio de pe telefon" | |
| 2225 | ||
| 2226 | msgctxt "#90200" | |
| 2227 | msgid "Continue Watching" | |
| 2228 | msgstr "Continuă vizionarea" | |
| 2229 | ||
| 2230 | msgctxt "#90201" | |
| 2231 | msgid "Library" | |
| 2232 | msgstr "Bibliotecă" | |
| 2233 | ||
| 2234 | msgctxt "#90202" | |
| 2235 | msgid "My Shows" | |
| 2236 | msgstr "Serialele mele" | |
| 2237 | ||
| 2238 | msgctxt "#90203" | |
| 2239 | msgid "My Movies" | |
| 2240 | msgstr "Filmele mele" | |
| 2241 | ||
| 2242 | msgctxt "#90204" | |
| 2243 | msgid "Remove from Library" | |
| 2244 | msgstr "Elimină din bibliotecă" | |
| 2245 | ||
| 2246 | msgctxt "#90205" | |
| 2247 | msgid "Add to Library" | |
| 2248 | msgstr "Adaugă în bibliotecă" | |
| 2249 | ||
| 2250 | msgctxt "#90206" | |
| 2251 | msgid "Remove" | |
| 2252 | msgstr "Elimină" | |
| 2253 | ||
| 2254 | msgctxt "#90207" | |
| 2255 | msgid "Search Movies & TV" | |
| 2256 | msgstr "Caută filme și TV" | |
| 2257 | ||
| 2258 | msgctxt "#90208" | |
| 2259 | msgid "Recent Searches" | |
| 2260 | msgstr "Căutări recente" | |
| 2261 | ||
| 2262 | msgctxt "#90209" | |
| 2263 | msgid "Recent Direct Searches" | |
| 2264 | msgstr "Căutări directe recente" | |
| 2265 | ||
| 2266 | msgctxt "#90210" | |
| 2267 | msgid "Clear All Search History" | |
| 2268 | msgstr "Șterge tot istoricul de căutări" | |
| 2269 | ||
| 2270 | msgctxt "#90211" | |
| 2271 | msgid "Bypass Stremio Addons Native Sorting" | |
| 2272 | msgstr "Ocolește sortarea nativă a add-on-urilor Stremio" | |
| 2273 | ||
| 2274 | msgctxt "#90212" | |
| 2275 | msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds." | |
| 2276 | msgstr "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 | ||
| 2278 | msgctxt "#90213" | |
| 2279 | msgid "Select Bypassed Addons" | |
| 2280 | msgstr "Selectează add-on-uri excluse" | |
| 2281 | ||
| 2282 | msgctxt "#90214" | |
| 2283 | msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering." | |
| 2284 | msgstr "Alege ce add-on-uri Stremio ocolesc filtrarea de mărime și calitate a Jacktook." | |
| 2285 | ||
| 2286 | msgctxt "#90215" | |
| 2287 | msgid "Downgrade" | |
| 2288 | msgstr "Retrogradează" | |
| 2289 | ||
| 2290 | msgctxt "#90671" | |
| 2291 | msgid "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails." | |
| 2292 | msgstr "Instalează o versiune mai veche Jacktook. Versiunea curentă este salvată și restaurată automat dacă retrogradarea eșuează." | |
| 2293 | ||
| 2294 | msgctxt "#30932" | |
| 2295 | msgid "Update Action" | |
| 2296 | msgstr "Acțiune actualizare" | |
| 2297 | ||
| 2298 | msgctxt "#30933" | |
| 2299 | msgid "Choose how to handle background update checks." | |
| 2300 | msgstr "Alege cum se gestionează verificările de actualizare în fundal." | |
| 2301 | ||
| 2302 | msgctxt "#30934" | |
| 2303 | msgid "Ask" | |
| 2304 | msgstr "Întreabă" | |
| 2305 | ||
| 2306 | msgctxt "#30935" | |
| 2307 | msgid "Notify" | |
| 2308 | msgstr "Notifică" | |
| 2309 | ||
| 2310 | msgctxt "#90216" | |
| 2311 | msgid "Volume Check Enabled" | |
| 2312 | msgstr "Verificare volum activată" | |
| 2313 | ||
| 2314 | msgctxt "#90217" | |
| 2315 | msgid "Automatically limits volume if it is above the threshold before playback." | |
| 2316 | msgstr "Limitează automat volumul dacă este peste prag înainte de redare." | |
| 2317 | ||
| 2318 | msgctxt "#90218" | |
| 2319 | msgid "Volume Threshold" | |
| 2320 | msgstr "Prag volum" | |
| 2321 | ||
| 2322 | msgctxt "#90219" | |
| 2323 | msgid "The volume percentage above which the warning triggers." | |
| 2324 | msgstr "Procentul de volum peste care se declanșează avertizarea." | |
| 2325 | ||
| 2326 | msgctxt "#90220" | |
| 2327 | msgid "High Volume Warning" | |
| 2328 | msgstr "Avertizare volum ridicat" | |
| 2329 | ||
| 2330 | msgctxt "#90221" | |
| 2331 | msgid "High volume detected. Adjusting to %s%% for safety." | |
| 2332 | msgstr "Volum ridicat detectat. Se ajustează la %s%% pentru siguranță." | |
| 2333 | ||
| 2334 | msgctxt "#90222" | |
| 2335 | msgid "Fanart.tv Configuration" | |
| 2336 | msgstr "Configurare Fanart.tv" | |
| 2337 | ||
| 2338 | msgctxt "#90223" | |
| 2339 | msgid "Client Key" | |
| 2340 | msgstr "Cheie client" | |
| 2341 | ||
| 2342 | msgctxt "#90224" | |
| 2343 | msgid "Your personal Fanart.tv client key for higher quality assets." | |
| 2344 | msgstr "Cheia ta personală Fanart.tv pentru resurse de calitate mai bună." | |
| 2345 | ||
| 2346 | msgctxt "#90225" | |
| 2347 | msgid "Debrid Notifications" | |
| 2348 | msgstr "Notificări Debrid" | |
| 2349 | ||
| 2350 | msgctxt "#90226" | |
| 2351 | msgid "Debrid Expiration Notification" | |
| 2352 | msgstr "Notificare expirare Debrid" | |
| 2353 | ||
| 2354 | msgctxt "#90227" | |
| 2355 | msgid "Enable or disable notifications when your Debrid subscription is about to expire." | |
| 2356 | msgstr "Activează sau dezactivează notificările când abonamentul tău Debrid este pe cale să expire." | |
| 2357 | ||
| 2358 | msgctxt "#90228" | |
| 2359 | msgid "Expiration Threshold (Days)" | |
| 2360 | msgstr "Prag expirare (zile)" | |
| 2361 | ||
| 2362 | msgctxt "#90229" | |
| 2363 | msgid "How many days before expiration to start notifying." | |
| 2364 | msgstr "Cu câte zile înainte de expirare să înceapă notificările." | |
| 2365 | ||
| 2366 | msgctxt "#90230" | |
| 2367 | msgid "%s expires in %d days." | |
| 2368 | msgstr "%s expiră în %d zile." | |
| 2369 | ||
| 2370 | msgctxt "#90231" | |
| 2371 | msgid "%s expires today!" | |
| 2372 | msgstr "%s expiră astăzi!" | |
| 2373 | ||
| 2374 | msgctxt "#90232" | |
| 2375 | msgid "Easynews" | |
| 2376 | msgstr "Easynews" | |
| 2377 | ||
| 2378 | msgctxt "#90233" | |
| 2379 | msgid "Enable EasyNews as a direct stream provider." | |
| 2380 | msgstr "Activează EasyNews ca provider direct de stream." | |
| 2381 | ||
| 2382 | msgctxt "#90234" | |
| 2383 | msgid "Your EasyNews username." | |
| 2384 | msgstr "Numele tău de utilizator EasyNews." | |
| 2385 | ||
| 2386 | msgctxt "#90235" | |
| 2387 | msgid "Your EasyNews password." | |
| 2388 | msgstr "Parola ta EasyNews." | |
| 2389 | ||
| 2390 | msgctxt "#90236" | |
| 2391 | msgid "Request timeout in seconds." | |
| 2392 | msgstr "Timp de așteptare cerere în secunde." | |
| 2393 | ||
| 2394 | msgctxt "#90237" | |
| 2395 | msgid "Account Information" | |
| 2396 | msgstr "Informații cont" | |
| 2397 | ||
| 2398 | msgctxt "#90238" | |
| 2399 | msgid "Search Dialog Style" | |
| 2400 | msgstr "Stil dialog căutare" | |
| 2401 | ||
| 2402 | msgctxt "#90239" | |
| 2403 | msgid "Background" | |
| 2404 | msgstr "Fundal" | |
| 2405 | ||
| 2406 | msgctxt "#90240" | |
| 2407 | msgid "Detailed Status" | |
| 2408 | msgstr "Stare detaliată" | |
| 2409 | ||
| 2410 | msgctxt "#90241" | |
| 2411 | msgid "Search Status" | |
| 2412 | msgstr "Stare căutare" | |
| 2413 | ||
| 2414 | msgctxt "#90242" | |
| 2415 | msgid "Cancel" | |
| 2416 | msgstr "Anulează" | |
| 2417 | ||
| 2418 | msgctxt "#90243" | |
| 2419 | msgid "Results found: " | |
| 2420 | msgstr "Rezultate găsite: " | |
| 2421 | ||
| 2422 | msgctxt "#90244" | |
| 2423 | msgid "Pending" | |
| 2424 | msgstr "În așteptare" | |
| 2425 | ||
| 2426 | msgctxt "#90245" | |
| 2427 | msgid "In Progress" | |
| 2428 | msgstr "În desfășurare" | |
| 2429 | ||
| 2430 | msgctxt "#90246" | |
| 2431 | msgid "Completed" | |
| 2432 | msgstr "Finalizat" | |
| 2433 | ||
| 2434 | msgctxt "#90247" | |
| 2435 | msgid "Failed" | |
| 2436 | msgstr "Eșuat" | |
| 2437 | ||
| 2438 | msgctxt "#90248" | |
| 2439 | msgid "Cancelled" | |
| 2440 | msgstr "Anulat" | |
| 2441 | ||
| 2442 | msgctxt "#90249" | |
| 2443 | msgid "Your personal Jackgram API token for authentication." | |
| 2444 | msgstr "Tokenul tău personal Jackgram pentru autentificare." | |
| 2445 | ||
| 2446 | msgctxt "#90250" | |
| 2447 | msgid "Subtitles Found" | |
| 2448 | msgstr "Subtitrări găsite" | |
| 2449 | ||
| 2450 | msgctxt "#90251" | |
| 2451 | msgid "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?" | |
| 2452 | msgstr "Există deja subtitrări descărcate pentru acest video.\nVrei să folosești subtitrările existente?" | |
| 2453 | ||
| 2454 | msgctxt "#90252" | |
| 2455 | msgid "No subtitles found" | |
| 2456 | msgstr "Nu s-au găsit subtitrări" | |
| 2457 | ||
| 2458 | msgctxt "#90253" | |
| 2459 | msgid "No subtitles selected" | |
| 2460 | msgstr "Nu au fost selectate subtitrări" | |
| 2461 | ||
| 2462 | msgctxt "#90254" | |
| 2463 | msgid "Translate Subtitles" | |
| 2464 | msgstr "Tradu subtitrările" | |
| 2465 | ||
| 2466 | msgctxt "#90255" | |
| 2467 | msgid "Do you want to also translate the selected subtitles?" | |
| 2468 | msgstr "Vrei să traduci și subtitrările selectate?" | |
| 2469 | ||
| 2470 | msgctxt "#90256" | |
| 2471 | msgid "Select Subtitle to Download" | |
| 2472 | msgstr "Selectează subtitrarea de descărcat" | |
| 2473 | ||
| 2474 | msgctxt "#90257" | |
| 2475 | msgid "Subtitles Loaded" | |
| 2476 | msgstr "Subtitrări încărcate" | |
| 2477 | ||
| 2478 | msgctxt "#90258" | |
| 2479 | msgid "Backup" | |
| 2480 | msgstr "Backup" | |
| 2481 | ||
| 2482 | msgctxt "#90259" | |
| 2483 | msgid "Export Backup" | |
| 2484 | msgstr "Exportă backup" | |
| 2485 | ||
| 2486 | msgctxt "#90260" | |
| 2487 | msgid "Export addon settings and custom Stremio addons to a JSON backup file." | |
| 2488 | msgstr "Exportă setările add-on-ului și add-on-urile Stremio personalizate într-un fișier JSON de backup." | |
| 2489 | ||
| 2490 | msgctxt "#90261" | |
| 2491 | msgid "Restore Backup" | |
| 2492 | msgstr "Restaurează backup" | |
| 2493 | ||
| 2494 | msgctxt "#90262" | |
| 2495 | msgid "Restore addon settings and replace custom Stremio addons from a JSON backup file." | |
| 2496 | msgstr "Restaurează setările add-on-ului și înlocuiește add-on-urile Stremio personalizate dintr-un fișier backup JSON." | |
| 2497 | ||
| 2498 | msgctxt "#90263" | |
| 2499 | msgid "Remove Sensitive Data" | |
| 2500 | msgstr "Elimină date sensibile" | |
| 2501 | ||
| 2502 | msgctxt "#90264" | |
| 2503 | msgid "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups." | |
| 2504 | msgstr "Elimină cheile API, parolele, numele de utilizator, email-urile, starea de autentificare și URL-urile add-on-urilor Stremio din backup-uri exportate." | |
| 2505 | ||
| 2506 | msgctxt "#90265" | |
| 2507 | msgid "Settings backup exported" | |
| 2508 | msgstr "Backup setări exportat" | |
| 2509 | ||
| 2510 | msgctxt "#90266" | |
| 2511 | msgid "Settings backup restored" | |
| 2512 | msgstr "Backup setări restaurat" | |
| 2513 | ||
| 2514 | msgctxt "#90267" | |
| 2515 | msgid "Settings Backup Failed" | |
| 2516 | msgstr "Backup setări eșuat" | |
| 2517 | ||
| 2518 | msgctxt "#90268" | |
| 2519 | msgid "Select export folder" | |
| 2520 | msgstr "Selectează folderul de export" | |
| 2521 | ||
| 2522 | msgctxt "#90269" | |
| 2523 | msgid "Select backup file" | |
| 2524 | msgstr "Selectează fișierul backup" | |
| 2525 | ||
| 2526 | msgctxt "#90270" | |
| 2527 | msgid "Backup file is invalid." | |
| 2528 | msgstr "Fișierul backup este invalid." | |
| 2529 | ||
| 2530 | msgctxt "#90271" | |
| 2531 | msgid "No export folder selected." | |
| 2532 | msgstr "Nu a fost selectat niciun folder de export." | |
| 2533 | ||
| 2534 | msgctxt "#90272" | |
| 2535 | msgid "No backup file selected." | |
| 2536 | msgstr "Nu a fost selectat niciun fișier backup." | |
| 2537 | ||
| 2538 | msgctxt "#90273" | |
| 2539 | msgid "Unable to export settings backup." | |
| 2540 | msgstr "Nu s-a putut exporta backup-ul setărilor." | |
| 2541 | ||
| 2542 | msgctxt "#90274" | |
| 2543 | msgid "Unable to restore settings backup." | |
| 2544 | msgstr "Nu s-a putut restaura backup-ul setărilor." | |
| 2545 | ||
| 2546 | msgctxt "#90275" | |
| 2547 | msgid "Restore Backup" | |
| 2548 | msgstr "Restaurează backup" | |
| 2549 | ||
| 2550 | msgctxt "#90276" | |
| 2551 | msgid "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?" | |
| 2552 | msgstr "Aceasta va suprascrie setările curente ale add-on-ului și va înlocui add-on-urile tale Stremio cu cele din backup. Continui?" | |
| 2553 | ||
| 2554 | msgctxt "#90277" | |
| 2555 | msgid "Restore cancelled" | |
| 2556 | msgstr "Restaurare anulată" | |
| 2557 | ||
| 2558 | msgctxt "#90278" | |
| 2559 | msgid "Reset All Settings" | |
| 2560 | msgstr "Resetează toate setările" | |
| 2561 | ||
| 2562 | msgctxt "#90279" | |
| 2563 | msgid "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?" | |
| 2564 | msgstr "Aceasta va reseta toate setările add-on-ului la valorile implicite și va elimina add-on-urile tale Stremio personalizate. Continui?" | |
| 2565 | ||
| 2566 | msgctxt "#90280" | |
| 2567 | msgid "Reset cancelled" | |
| 2568 | msgstr "Resetare anulată" | |
| 2569 | ||
| 2570 | msgctxt "#90281" | |
| 2571 | msgid "All addon settings reset" | |
| 2572 | msgstr "Toate setările add-on-ului au fost resetate" | |
| 2573 | ||
| 2574 | msgctxt "#90282" | |
| 2575 | msgid "Reset Failed" | |
| 2576 | msgstr "Resetarea a eșuat" | |
| 2577 | ||
| 2578 | msgctxt "#90283" | |
| 2579 | msgid "Unable to reset addon settings." | |
| 2580 | msgstr "Nu se pot reseta setările add-on-ului." | |
| 2581 | ||
| 2582 | msgctxt "#90284" | |
| 2583 | msgid "Reset every addon setting back to its default value and clear custom Stremio addons." | |
| 2584 | msgstr "Resetează fiecare setare a add-on-ului la valoarea implicită și șterge add-on-urile Stremio personalizate." | |
| 2585 | ||
| 2586 | msgctxt "#90285" | |
| 2587 | msgid "Factory Reset" | |
| 2588 | msgstr "Resetare la setările din fabrică" | |
| 2589 | ||
| 2590 | msgctxt "#90286" | |
| 2591 | msgid "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?" | |
| 2592 | msgstr "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 | ||
| 2594 | msgctxt "#90287" | |
| 2595 | msgid "Factory reset cancelled" | |
| 2596 | msgstr "Resetarea la setările din fabrică a fost anulată" | |
| 2597 | ||
| 2598 | msgctxt "#90288" | |
| 2599 | msgid "Factory reset completed" | |
| 2600 | msgstr "Resetarea la setările din fabrică a fost finalizată" | |
| 2601 | ||
| 2602 | msgctxt "#90289" | |
| 2603 | msgid "Factory Reset Failed" | |
| 2604 | msgstr "Resetarea la setările din fabrică a eșuat" | |
| 2605 | ||
| 2606 | msgctxt "#90290" | |
| 2607 | msgid "Unable to complete factory reset." | |
| 2608 | msgstr "Nu se poate finaliza resetarea la setările din fabrică." | |
| 2609 | ||
| 2610 | msgctxt "#90291" | |
| 2611 | msgid "Reset settings to defaults, clear caches, and wipe local addon history/database data." | |
| 2612 | msgstr "Resetează setările la valorile implicite, golește cache-ul și șterge istoricul add-on-ului și datele din baza de date." | |
| 2613 | ||
| 2614 | msgctxt "#90292" | |
| 2615 | msgid "Trakt Library" | |
| 2616 | msgstr "Biblioteca Trakt" | |
| 2617 | ||
| 2618 | msgctxt "#90293" | |
| 2619 | msgid "Trakt Discovery" | |
| 2620 | msgstr "Descoperă Trakt" | |
| 2621 | ||
| 2622 | msgctxt "#90294" | |
| 2623 | msgid "Collection" | |
| 2624 | msgstr "Colecție" | |
| 2625 | ||
| 2626 | msgctxt "#90295" | |
| 2627 | msgid "Favorites" | |
| 2628 | msgstr "Favorite" | |
| 2629 | ||
| 2630 | msgctxt "#90296" | |
| 2631 | msgid "Up Next" | |
| 2632 | msgstr "Urmează" | |
| 2633 | ||
| 2634 | msgctxt "#90297" | |
| 2635 | msgid "My Lists" | |
| 2636 | msgstr "Listele mele" | |
| 2637 | ||
| 2638 | msgctxt "#90298" | |
| 2639 | msgid "Liked Lists" | |
| 2640 | msgstr "Liste apreciate" | |
| 2641 | ||
| 2642 | msgctxt "#90299" | |
| 2643 | msgid "No metadata found for this torrent" | |
| 2644 | msgstr "Nu au fost găsite metadate pentru acest torrent" | |
| 2645 | ||
| 2646 | msgctxt "#90331" | |
| 2647 | msgid "Stremio Addons" | |
| 2648 | msgstr "Add-on-uri Stremio" | |
| 2649 | ||
| 2650 | msgctxt "#90332" | |
| 2651 | msgid "No addons available yet. Use Log In to Stremio or Add Custom Addon first." | |
| 2652 | msgstr "Nu există încă add-on-uri disponibile. Autentifică-te în Stremio sau adaugă mai întâi un addon personalizat." | |
| 2653 | ||
| 2654 | msgctxt "#90333" | |
| 2655 | msgid "Stremio Stream Addons" | |
| 2656 | msgstr "Add-on-uri stream Stremio" | |
| 2657 | ||
| 2658 | msgctxt "#90334" | |
| 2659 | msgid "Stremio Catalog Addons" | |
| 2660 | msgstr "Add-on-uri catalog Stremio" | |
| 2661 | ||
| 2662 | msgctxt "#90335" | |
| 2663 | msgid "Stremio Live TV Addons" | |
| 2664 | msgstr "Add-on-uri TV Live Stremio" | |
| 2665 | ||
| 2666 | msgctxt "#90336" | |
| 2667 | msgid "Refresh addons imported from your Stremio account. Custom addons remain unchanged." | |
| 2668 | msgstr "Actualizează add-on-urile importate din contul tău Stremio. Add-on-urile personalizate rămân neschimbate." | |
| 2669 | ||
| 2670 | msgctxt "#90337" | |
| 2671 | msgid "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first." | |
| 2672 | msgstr "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 | ||
| 2674 | msgctxt "#90338" | |
| 2675 | msgid "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first." | |
| 2676 | msgstr "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 | ||
| 2678 | msgctxt "#90339" | |
| 2679 | msgid "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first." | |
| 2680 | msgstr "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 | ||
| 2682 | msgctxt "#90340" | |
| 2683 | msgid "Remove custom Stremio addons you previously added manually." | |
| 2684 | msgstr "Elimină add-on-urile Stremio personalizate pe care le-ai adăugat manual anterior." | |
| 2685 | ||
| 2686 | msgctxt "#90341" | |
| 2687 | msgid "Choose Torrent Client" | |
| 2688 | msgstr "Alege clientul torrent" | |
| 2689 | ||
| 2690 | msgctxt "#90342" | |
| 2691 | msgid "Clear Database Cache" | |
| 2692 | msgstr "Șterge cache-ul bazei de date" | |
| 2693 | ||
| 2694 | msgctxt "#90343" | |
| 2695 | msgid "Clear Stremio Cache" | |
| 2696 | msgstr "Șterge cache-ul Stremio" | |
| 2697 | ||
| 2698 | msgctxt "#90344" | |
| 2699 | msgid "Clear Debrid Cache" | |
| 2700 | msgstr "Șterge cache-ul Debrid" | |
| 2701 | ||
| 2702 | msgctxt "#90345" | |
| 2703 | msgid "Clear MDBList Cache" | |
| 2704 | msgstr "Șterge cache-ul MDBList" | |
| 2705 | ||
| 2706 | msgctxt "#90346" | |
| 2707 | msgid "Restore backup from" | |
| 2708 | msgstr "Restaurează backup din" | |
| 2709 | ||
| 2710 | msgctxt "#90347" | |
| 2711 | msgid "File" | |
| 2712 | msgstr "Fișier" | |
| 2713 | ||
| 2714 | msgctxt "#90348" | |
| 2715 | msgid "URL" | |
| 2716 | msgstr "URL" | |
| 2717 | ||
| 2718 | msgctxt "#90349" | |
| 2719 | msgid "Enter backup URL" | |
| 2720 | msgstr "Introdu URL-ul de backup" | |
| 2721 | ||
| 2722 | msgctxt "#90350" | |
| 2723 | msgid "No backup URL provided." | |
| 2724 | msgstr "Nu a fost furnizat niciun URL de backup." | |
| 2725 | ||
| 2726 | msgctxt "#90351" | |
| 2727 | msgid "Unable to download settings backup." | |
| 2728 | msgstr "Nu se poate descărca backup-ul setărilor." | |
| 2729 | ||
| 2730 | msgctxt "#90352" | |
| 2731 | msgid "Source Select Style" | |
| 2732 | msgstr "Stil selecție sursă" | |
| 2733 | ||
| 2734 | msgctxt "#90353" | |
| 2735 | msgid "Modern" | |
| 2736 | msgstr "Modern" | |
| 2737 | ||
| 2738 | msgctxt "#90354" | |
| 2739 | msgid "OLED" | |
| 2740 | msgstr "OLED" | |
| 2741 | ||
| 2742 | msgctxt "#90355" | |
| 2743 | msgid "Cyberpunk" | |
| 2744 | msgstr "Cyberpunk" | |
| 2745 | ||
| 2746 | msgctxt "#90356" | |
| 2747 | msgid "Forest" | |
| 2748 | msgstr "Pădure" | |
| 2749 | ||
| 2750 | msgctxt "#90357" | |
| 2751 | msgid "Gold" | |
| 2752 | msgstr "Aur" | |
| 2753 | ||
| 2754 | msgctxt "#90358" | |
| 2755 | msgid "No playable results found" | |
| 2756 | msgstr "Nu s-au găsit rezultate care pot fi redate" | |
| 2757 | ||
| 2758 | msgctxt "#90359" | |
| 2759 | msgid "Add to TorrServer" | |
| 2760 | msgstr "Adaugă în TorrServer" | |
| 2761 | ||
| 2762 | msgctxt "#90360" | |
| 2763 | msgid "Added to TorrServer" | |
| 2764 | msgstr "Adăugat în TorrServer" | |
| 2765 | ||
| 2766 | msgctxt "#90361" | |
| 2767 | msgid "Could not resolve torrent details" | |
| 2768 | msgstr "Nu s-au putut obține detaliile torrentului" | |
| 2769 | ||
| 2770 | msgctxt "#90362" | |
| 2771 | msgid "Added to %s cloud" | |
| 2772 | msgstr "Adăugat în cloud-ul %s" | |
| 2773 | ||
| 2774 | msgctxt "#90363" | |
| 2775 | msgid "Choose Debrid Service" | |
| 2776 | msgstr "Alege serviciul Debrid" | |
| 2777 | ||
| 2778 | msgctxt "#90364" | |
| 2779 | msgid "No supported debrid service is enabled" | |
| 2780 | msgstr "Niciun serviciu debrid compatibil nu este activat" | |
| 2781 | ||
| 2782 | msgctxt "#90365" | |
| 2783 | msgid "Download to Debrid" | |
| 2784 | msgstr "Descarcă în Debrid" | |
| 2785 | ||
| 2786 | msgctxt "#90366" | |
| 2787 | msgid "Include Unknown quality" | |
| 2788 | msgstr "Include calitate necunoscută" | |
| 2789 | ||
| 2790 | msgctxt "#90367" | |
| 2791 | msgid "Includes releases where video quality could not be identified" | |
| 2792 | msgstr "Include versiuni în care calitatea video nu a putut fi identificată" | |
| 2793 | ||
| 2794 | msgctxt "#90368" | |
| 2795 | msgid "Search by Keyword" | |
| 2796 | msgstr "Caută după cuvânt cheie" | |
| 2797 | ||
| 2798 | msgctxt "#90369" | |
| 2799 | msgid "Latest Movies" | |
| 2800 | msgstr "Cele mai recente filme" | |
| 2801 | ||
| 2802 | msgctxt "#90370" | |
| 2803 | msgid "Latest Series" | |
| 2804 | msgstr "Cele mai recente seriale" | |
| 2805 | ||
| 2806 | msgctxt "#90371" | |
| 2807 | msgid "Raw Files" | |
| 2808 | msgstr "Fișiere brute" | |
| 2809 | ||
| 2810 | msgctxt "#90372" | |
| 2811 | msgid "MDblist" | |
| 2812 | msgstr "MDblist" | |
| 2813 | ||
| 2814 | msgctxt "#90373" | |
| 2815 | msgid "Active Downloads" | |
| 2816 | msgstr "Descărcări active" | |
| 2817 | ||
| 2818 | msgctxt "#90374" | |
| 2819 | msgid "Cancel Download" | |
| 2820 | msgstr "Anulează descărcarea" | |
| 2821 | ||
| 2822 | msgctxt "#90375" | |
| 2823 | msgid "Delete File" | |
| 2824 | msgstr "Șterge fișierul" | |
| 2825 | ||
| 2826 | msgctxt "#90376" | |
| 2827 | msgid "Search Collections" | |
| 2828 | msgstr "Caută colecții" | |
| 2829 | ||
| 2830 | msgctxt "#90377" | |
| 2831 | msgid "Popular Collections" | |
| 2832 | msgstr "Colecții populare" | |
| 2833 | ||
| 2834 | msgctxt "#90378" | |
| 2835 | msgid "Top Rated Collections" | |
| 2836 | msgstr "Colecții cu rating mare" | |
| 2837 | ||
| 2838 | msgctxt "#90379" | |
| 2839 | msgid "Recommendations" | |
| 2840 | msgstr "Recomandări" | |
| 2841 | ||
| 2842 | msgctxt "#90380" | |
| 2843 | msgid "Similar" | |
| 2844 | msgstr "Similare" | |
| 2845 | ||
| 2846 | msgctxt "#90381" | |
| 2847 | msgid "Keyword: %s" | |
| 2848 | msgstr "Cuvânt cheie: %s" | |
| 2849 | ||
| 2850 | msgctxt "#90382" | |
| 2851 | msgid "Please set a valid Trakt Client ID Key" | |
| 2852 | msgstr "Setează un Client ID valid pentru Trakt" | |
| 2853 | ||
| 2854 | msgctxt "#90383" | |
| 2855 | msgid "Please set a valid Trakt Client Secret Key" | |
| 2856 | msgstr "Setează o cheie secretă validă pentru Trakt" | |
| 2857 | ||
| 2858 | msgctxt "#90384" | |
| 2859 | msgid "Trakt Error Authorizing" | |
| 2860 | msgstr "Eroare la autorizarea Trakt" | |
| 2861 | ||
| 2862 | msgctxt "#90385" | |
| 2863 | msgid "Trakt Account Authorized" | |
| 2864 | msgstr "Contul Trakt a fost autorizat" | |
| 2865 | ||
| 2866 | msgctxt "#90386" | |
| 2867 | msgid "You are now logged out from Trakt.tv" | |
| 2868 | msgstr "Ești deconectat de la Trakt.tv" | |
| 2869 | ||
| 2870 | msgctxt "#90387" | |
| 2871 | msgid "Failed to mark as watched" | |
| 2872 | msgstr "Marcarea ca vizionat a eșuat" | |
| 2873 | ||
| 2874 | msgctxt "#90388" | |
| 2875 | msgid "Marked as watched" | |
| 2876 | msgstr "Marcat ca vizionat" | |
| 2877 | ||
| 2878 | msgctxt "#90389" | |
| 2879 | msgid "No results found" | |
| 2880 | msgstr "Nu au fost găsite rezultate" | |
| 2881 | ||
| 2882 | msgctxt "#90390" | |
| 2883 | msgid "Invalid mode" | |
| 2884 | msgstr "Mod invalid" | |
| 2885 | ||
| 2886 | msgctxt "#90391" | |
| 2887 | msgid "No recommendations found" | |
| 2888 | msgstr "Nu au fost găsite recomandări" | |
| 2889 | ||
| 2890 | msgctxt "#90392" | |
| 2891 | msgid "No similar items found" | |
| 2892 | msgstr "Nu au fost găsite elemente similare" | |
| 2893 | ||
| 2894 | msgctxt "#90393" | |
| 2895 | msgid "TMDB metadata not found" | |
| 2896 | msgstr "Metadate TMDB negăsite" | |
| 2897 | ||
| 2898 | msgctxt "#90394" | |
| 2899 | msgid "No TV shows found" | |
| 2900 | msgstr "Nu au fost găsite seriale" | |
| 2901 | ||
| 2902 | msgctxt "#90395" | |
| 2903 | msgid "No shows airing today" | |
| 2904 | msgstr "Nu sunt seriale difuzate astăzi" | |
| 2905 | ||
| 2906 | msgctxt "#90396" | |
| 2907 | msgid "Invalid collection query" | |
| 2908 | msgstr "Interogare de colecție invalidă" | |
| 2909 | ||
| 2910 | msgctxt "#90397" | |
| 2911 | msgid "No keywords found" | |
| 2912 | msgstr "Nu au fost găsite cuvinte cheie" | |
| 2913 | ||
| 2914 | msgctxt "#90398" | |
| 2915 | msgid "No keyword ID provided" | |
| 2916 | msgstr "Nu a fost furnizat ID de cuvânt cheie" | |
| 2917 | ||
| 2918 | msgctxt "#90399" | |
| 2919 | msgid "No results found for this keyword" | |
| 2920 | msgstr "Nu au fost găsite rezultate pentru acest cuvânt cheie" | |
| 2921 | ||
| 2922 | msgctxt "#90400" | |
| 2923 | msgid "No TMDB ID found" | |
| 2924 | msgstr "Nu a fost găsit ID TMDB" | |
| 2925 | ||
| 2926 | msgctxt "#90401" | |
| 2927 | msgid "Unsupported media type or missing external_ids" | |
| 2928 | msgstr "Tip media nesuportat sau lipsesc external_ids" | |
| 2929 | ||
| 2930 | msgctxt "#90402" | |
| 2931 | msgid "Refreshed Metadata" | |
| 2932 | msgstr "Metadate actualizate" | |
| 2933 | ||
| 2934 | msgctxt "#90403" | |
| 2935 | msgid "No Stremio addons selected" | |
| 2936 | msgstr "Nu au fost selectate add-on-uri Stremio" | |
| 2937 | ||
| 2938 | msgctxt "#90404" | |
| 2939 | msgid "No suitable source found for auto play." | |
| 2940 | msgstr "Nu a fost găsită nicio sursă potrivită pentru redare automată." | |
| 2941 | ||
| 2942 | msgctxt "#90405" | |
| 2943 | msgid "No sources found with the preferred quality." | |
| 2944 | msgstr "Nu au fost găsite surse cu calitatea preferată." | |
| 2945 | ||
| 2946 | msgctxt "#90406" | |
| 2947 | msgid "No languages found" | |
| 2948 | msgstr "Nu au fost găsite limbi" | |
| 2949 | ||
| 2950 | msgctxt "#90407" | |
| 2951 | msgid "Failed to resolve playback source" | |
| 2952 | msgstr "Nu s-a putut găsi sursa de redare" | |
| 2953 | ||
| 2954 | msgctxt "#90408" | |
| 2955 | msgid "Trakt not authorized. Please authenticate." | |
| 2956 | msgstr "Trakt nu este autorizat. Te rog autentifică-te." | |
| 2957 | ||
| 2958 | msgctxt "#90409" | |
| 2959 | msgid "Added to Trakt Watchlist" | |
| 2960 | msgstr "Adăugat în Watchlist Trakt" | |
| 2961 | ||
| 2962 | msgctxt "#90410" | |
| 2963 | msgid "Error modifying Trakt Watchlist" | |
| 2964 | msgstr "Eroare la modificarea Watchlist-ului Trakt" | |
| 2965 | ||
| 2966 | msgctxt "#90411" | |
| 2967 | msgid "Marked as Watched on Trakt" | |
| 2968 | msgstr "Marcat ca vizionat pe Trakt" | |
| 2969 | ||
| 2970 | msgctxt "#90412" | |
| 2971 | msgid "Error modifying Trakt Watched status" | |
| 2972 | msgstr "Eroare la modificarea statusului „vizionat” pe Trakt" | |
| 2973 | ||
| 2974 | msgctxt "#90413" | |
| 2975 | msgid "Play" | |
| 2976 | msgstr "Redare" | |
| 2977 | ||
| 2978 | msgctxt "#90414" | |
| 2979 | msgid "Trailer" | |
| 2980 | msgstr "Trailer" | |
| 2981 | ||
| 2982 | msgctxt "#90415" | |
| 2983 | msgid "Watched" | |
| 2984 | msgstr "Vizionat" | |
| 2985 | ||
| 2986 | msgctxt "#90416" | |
| 2987 | msgid "Refresh" | |
| 2988 | msgstr "Reîmprospătează" | |
| 2989 | ||
| 2990 | msgctxt "#90418" | |
| 2991 | msgid "No credits found for this ID" | |
| 2992 | msgstr "Nu au fost găsite credite pentru acest ID" | |
| 2993 | ||
| 2994 | msgctxt "#90419" | |
| 2995 | msgid "Credited People" | |
| 2996 | msgstr "Persoane creditate" | |
| 2997 | ||
| 2998 | msgctxt "#90420" | |
| 2999 | msgid "Person Details" | |
| 3000 | msgstr "Detalii persoană" | |
| 3001 | ||
| 3002 | msgctxt "#90421" | |
| 3003 | msgid "Person not found" | |
| 3004 | msgstr "Persoană negăsită" | |
| 3005 | ||
| 3006 | msgctxt "#90422" | |
| 3007 | msgid "Jackgram not activated" | |
| 3008 | msgstr "Jackgram nu este activat" | |
| 3009 | ||
| 3010 | msgctxt "#90423" | |
| 3011 | msgid "Not yet implemented" | |
| 3012 | msgstr "Încă neimplementat" | |
| 3013 | ||
| 3014 | msgctxt "#90424" | |
| 3015 | msgid "Unsupported debrid type" | |
| 3016 | msgstr "Tip debrid nesuportat" | |
| 3017 | ||
| 3018 | msgctxt "#90425" | |
| 3019 | msgid "Easynews credentials required" | |
| 3020 | msgstr "Sunt necesare credențiale Easynews" | |
| 3021 | ||
| 3022 | msgctxt "#90426" | |
| 3023 | msgid "No Corrupt or Missing Databases" | |
| 3024 | msgstr "Nu există baze de date corupte sau lipsă" | |
| 3025 | ||
| 3026 | msgctxt "#90427" | |
| 3027 | msgid "Otaku (script.otaku.mappings) not found" | |
| 3028 | msgstr "Otaku (script.otaku.mappings) nu a fost găsit" | |
| 3029 | ||
| 3030 | msgctxt "#90428" | |
| 3031 | msgid "Database read error. File may be corrupted." | |
| 3032 | msgstr "Eroare la citirea bazei de date. Fișierul poate fi corupt." | |
| 3033 | ||
| 3034 | msgctxt "#90429" | |
| 3035 | msgid "Failed to delete data. Database error." | |
| 3036 | msgstr "Ștergerea datelor a eșuat. Eroare de bază de date." | |
| 3037 | ||
| 3038 | msgctxt "#90430" | |
| 3039 | msgid "Failed to clear cache. Database in use." | |
| 3040 | msgstr "Ștergerea cache-ului a eșuat. Baza de date este în uz." | |
| 3041 | ||
| 3042 | msgctxt "#90431" | |
| 3043 | msgid "No items found in this list" | |
| 3044 | msgstr "Nu au fost găsite elemente în această listă" | |
| 3045 | ||
| 3046 | msgctxt "#90432" | |
| 3047 | msgid "Databases Rebuilt" | |
| 3048 | msgstr "Bazele de date au fost reconstruite" | |
| 3049 | ||
| 3050 | msgctxt "#90433" | |
| 3051 | msgid "[B]Following Databases Rebuilt:[/B][CR][CR]%s" | |
| 3052 | msgstr "[B]Următoarele baze de date au fost reconstruite:[/B][CR][CR]%s" | |
| 3053 | ||
| 3054 | msgctxt "#90434" | |
| 3055 | msgid "Added to Trakt watchlist" | |
| 3056 | msgstr "Adăugat în watchlist Trakt" | |
| 3057 | ||
| 3058 | msgctxt "#90435" | |
| 3059 | msgid "Failed to add to Trakt watchlist" | |
| 3060 | msgstr "Adăugarea în watchlist Trakt a eșuat" | |
| 3061 | ||
| 3062 | msgctxt "#90436" | |
| 3063 | msgid "Removed from Trakt watchlist" | |
| 3064 | msgstr "Eliminat din lista de vizionare Trakt" | |
| 3065 | ||
| 3066 | msgctxt "#90437" | |
| 3067 | msgid "Failed to remove from Trakt watchlist" | |
| 3068 | msgstr "Nu s-a putut elimina din lista de vizionare Trakt" | |
| 3069 | ||
| 3070 | msgctxt "#90438" | |
| 3071 | msgid "Failed to mark as watched on Trakt" | |
| 3072 | msgstr "Nu s-a putut marca drept vizionat pe Trakt" | |
| 3073 | ||
| 3074 | msgctxt "#90439" | |
| 3075 | msgid "Marked as unwatched on Trakt" | |
| 3076 | msgstr "Marcat ca nevizionat pe Trakt" | |
| 3077 | ||
| 3078 | msgctxt "#90440" | |
| 3079 | msgid "Failed to mark as unwatched on Trakt" | |
| 3080 | msgstr "Nu s-a putut marca ca nevizionat pe Trakt" | |
| 3081 | ||
| 3082 | msgctxt "#90441" | |
| 3083 | msgid "Added to Trakt collection" | |
| 3084 | msgstr "Adăugat la colecția Trakt" | |
| 3085 | ||
| 3086 | msgctxt "#90442" | |
| 3087 | msgid "Removed from Trakt collection" | |
| 3088 | msgstr "Eliminat din colecția Trakt" | |
| 3089 | ||
| 3090 | msgctxt "#90443" | |
| 3091 | msgid "Failed to add to Trakt collection" | |
| 3092 | msgstr "Nu s-a putut adăuga la colecția Trakt" | |
| 3093 | ||
| 3094 | msgctxt "#90444" | |
| 3095 | msgid "Failed to remove from Trakt collection" | |
| 3096 | msgstr "Nu s-a putut elimina din colecția Trakt" | |
| 3097 | ||
| 3098 | msgctxt "#90445" | |
| 3099 | msgid "Added to Trakt favorites" | |
| 3100 | msgstr "Adăugat la favorite Trakt" | |
| 3101 | ||
| 3102 | msgctxt "#90446" | |
| 3103 | msgid "Removed from Trakt favorites" | |
| 3104 | msgstr "Eliminat din favorite Trakt" | |
| 3105 | ||
| 3106 | msgctxt "#90447" | |
| 3107 | msgid "Failed to add to Trakt favorites" | |
| 3108 | msgstr "Nu s-a putut adăuga la favorite Trakt" | |
| 3109 | ||
| 3110 | msgctxt "#90448" | |
| 3111 | msgid "Failed to remove from Trakt favorites" | |
| 3112 | msgstr "Nu s-a putut elimina din favorite Trakt" | |
| 3113 | ||
| 3114 | msgctxt "#90449" | |
| 3115 | msgid "Created Trakt list" | |
| 3116 | msgstr "Listă Trakt creată" | |
| 3117 | ||
| 3118 | msgctxt "#90450" | |
| 3119 | msgid "Failed to create Trakt list" | |
| 3120 | msgstr "Nu s-a putut crea lista Trakt" | |
| 3121 | ||
| 3122 | msgctxt "#90451" | |
| 3123 | msgid "Deleted Trakt list" | |
| 3124 | msgstr "Listă Trakt ștearsă" | |
| 3125 | ||
| 3126 | msgctxt "#90452" | |
| 3127 | msgid "Failed to delete Trakt list" | |
| 3128 | msgstr "Nu s-a putut șterge lista Trakt" | |
| 3129 | ||
| 3130 | msgctxt "#90453" | |
| 3131 | msgid "Liked Trakt list" | |
| 3132 | msgstr "Lista Trakt apreciată" | |
| 3133 | ||
| 3134 | msgctxt "#90454" | |
| 3135 | msgid "Failed to like Trakt list" | |
| 3136 | msgstr "Nu s-a putut aprecia lista Trakt" | |
| 3137 | ||
| 3138 | msgctxt "#90455" | |
| 3139 | msgid "Unliked Trakt list" | |
| 3140 | msgstr "Aprecierea pentru lista Trakt a fost eliminată" | |
| 3141 | ||
| 3142 | msgctxt "#90456" | |
| 3143 | msgid "Failed to unlike Trakt list" | |
| 3144 | msgstr "Nu s-a putut elimina aprecierea pentru lista Trakt" | |
| 3145 | ||
| 3146 | msgctxt "#90457" | |
| 3147 | msgid "Added to Trakt list" | |
| 3148 | msgstr "Adăugat la lista Trakt" | |
| 3149 | ||
| 3150 | msgctxt "#90458" | |
| 3151 | msgid "OpenSubtitles Stremio: No results found." | |
| 3152 | msgstr "OpenSubtitles Stremio: Nu s-au găsit rezultate." | |
| 3153 | ||
| 3154 | msgctxt "#90459" | |
| 3155 | msgid "Failed to add to Trakt list" | |
| 3156 | msgstr "Nu s-a putut adăuga la lista Trakt" | |
| 3157 | ||
| 3158 | msgctxt "#90460" | |
| 3159 | msgid "Subtitle Manager: No subtitles available for this video." | |
| 3160 | msgstr "Manager subtitrări: Nu sunt subtitrări disponibile pentru acest video." | |
| 3161 | ||
| 3162 | msgctxt "#90461" | |
| 3163 | msgid "No Trakt lists found" | |
| 3164 | msgstr "Nu s-au găsit liste Trakt" | |
| 3165 | ||
| 3166 | msgctxt "#90462" | |
| 3167 | msgid "Item not found in Trakt collection" | |
| 3168 | msgstr "Elementul nu a fost găsit în colecția Trakt" | |
| 3169 | ||
| 3170 | msgctxt "#90463" | |
| 3171 | msgid "Trakt could not match this item for collection" | |
| 3172 | msgstr "Trakt nu a putut asocia acest element pentru colecție" | |
| 3173 | ||
| 3174 | msgctxt "#90464" | |
| 3175 | msgid "No Trakt collection changes were applied" | |
| 3176 | msgstr "Nu au fost aplicate modificări în colecția Trakt" | |
| 3177 | ||
| 3178 | msgctxt "#90465" | |
| 3179 | msgid "Could not build Trakt collection payload for show" | |
| 3180 | msgstr "Nu s-a putut construi payload-ul colecției Trakt pentru serial" | |
| 3181 | ||
| 3182 | msgctxt "#90466" | |
| 3183 | msgid "Delete this Trakt list?" | |
| 3184 | msgstr "Ștergeți această listă Trakt?" | |
| 3185 | ||
| 3186 | msgctxt "#90468" | |
| 3187 | msgid "Search menu" | |
| 3188 | msgstr "Meniu de căutare" | |
| 3189 | ||
| 3190 | msgctxt "#90469" | |
| 3191 | msgid "Continue watching menu" | |
| 3192 | msgstr "Meniu „Continuă vizionarea”" | |
| 3193 | ||
| 3194 | msgctxt "#90470" | |
| 3195 | msgid "TV Shows Items" | |
| 3196 | msgstr "Elemente seriale TV" | |
| 3197 | ||
| 3198 | msgctxt "#90471" | |
| 3199 | msgid "Movies Items" | |
| 3200 | msgstr "Elemente filme" | |
| 3201 | ||
| 3202 | msgctxt "#90472" | |
| 3203 | msgid "Anime menu" | |
| 3204 | msgstr "Meniu anime" | |
| 3205 | ||
| 3206 | msgctxt "#90473" | |
| 3207 | msgid "TV Menu" | |
| 3208 | msgstr "Meniu TV" | |
| 3209 | ||
| 3210 | msgctxt "#90474" | |
| 3211 | msgid "Library menu" | |
| 3212 | msgstr "Meniu bibliotecă" | |
| 3213 | ||
| 3214 | msgctxt "#90475" | |
| 3215 | msgid "Torrents Management" | |
| 3216 | msgstr "Gestionare torrente" | |
| 3217 | ||
| 3218 | msgctxt "#90476" | |
| 3219 | msgid "Telegram menu" | |
| 3220 | msgstr "Meniu Telegram" | |
| 3221 | ||
| 3222 | msgctxt "#90477" | |
| 3223 | msgid "Cloud Storage" | |
| 3224 | msgstr "Stocare în cloud" | |
| 3225 | ||
| 3226 | msgctxt "#90478" | |
| 3227 | msgid "Downloads Management" | |
| 3228 | msgstr "Gestionare descărcări" | |
| 3229 | ||
| 3230 | msgctxt "#90479" | |
| 3231 | msgid "Settings Management" | |
| 3232 | msgstr "Gestionare setări" | |
| 3233 | ||
| 3234 | msgctxt "#90480" | |
| 3235 | msgid "History Menu" | |
| 3236 | msgstr "Meniu istoric" | |
| 3237 | ||
| 3238 | msgctxt "#90481" | |
| 3239 | msgid "Files History" | |
| 3240 | msgstr "Istoric fișiere" | |
| 3241 | ||
| 3242 | msgctxt "#90482" | |
| 3243 | msgid "Titles History" | |
| 3244 | msgstr "Istoric titluri" | |
| 3245 | ||
| 3246 | msgctxt "#90483" | |
| 3247 | msgid "Titles Calendar" | |
| 3248 | msgstr "Calendar titluri" | |
| 3249 | ||
| 3250 | msgctxt "#90484" | |
| 3251 | msgid "My Shows" | |
| 3252 | msgstr "Serialele mele" | |
| 3253 | ||
| 3254 | msgctxt "#90485" | |
| 3255 | msgid "My Movies" | |
| 3256 | msgstr "Filmele mele" | |
| 3257 | ||
| 3258 | msgctxt "#90486" | |
| 3259 | msgid "Upcoming Episodes" | |
| 3260 | msgstr "Episoade viitoare" | |
| 3261 | ||
| 3262 | msgctxt "#90487" | |
| 3263 | msgid "Discovery List" | |
| 3264 | msgstr "Listă discovery" | |
| 3265 | ||
| 3266 | msgctxt "#90488" | |
| 3267 | msgid "Library List" | |
| 3268 | msgstr "Listă bibliotecă" | |
| 3269 | ||
| 3270 | msgctxt "#90489" | |
| 3271 | msgid "Trakt Recommendations" | |
| 3272 | msgstr "Recomandări Trakt" | |
| 3273 | ||
| 3274 | msgctxt "#90490" | |
| 3275 | msgid "Trending Lists" | |
| 3276 | msgstr "Liste în tendințe" | |
| 3277 | ||
| 3278 | msgctxt "#90491" | |
| 3279 | msgid "Popular Lists" | |
| 3280 | msgstr "Liste populare" | |
| 3281 | ||
| 3282 | msgctxt "#90492" | |
| 3283 | msgid "Search Lists" | |
| 3284 | msgstr "Căutare liste" | |
| 3285 | ||
| 3286 | msgctxt "#90493" | |
| 3287 | msgid "Recent Activity" | |
| 3288 | msgstr "Activitate recentă" | |
| 3289 | ||
| 3290 | msgctxt "#90494" | |
| 3291 | msgid "Downloads List" | |
| 3292 | msgstr "Listă descărcări" | |
| 3293 | ||
| 3294 | msgctxt "#90495" | |
| 3295 | msgid "Download New" | |
| 3296 | msgstr "Descarcă nou" | |
| 3297 | ||
| 3298 | msgctxt "#90496" | |
| 3299 | msgid "Cartoons Popular" | |
| 3300 | msgstr "Desene animate populare" | |
| 3301 | ||
| 3302 | msgctxt "#90497" | |
| 3303 | msgid "Animation Popular" | |
| 3304 | msgstr "Animații populare" | |
| 3305 | ||
| 3306 | msgctxt "#90500" | |
| 3307 | msgid "Translating subtitle..." | |
| 3308 | msgstr "Se traduce subtitrarea..." | |
| 3309 | ||
| 3310 | msgctxt "#90501" | |
| 3311 | msgid "Translation done." | |
| 3312 | msgstr "Traducere finalizată." | |
| 3313 | ||
| 3314 | msgctxt "#90502" | |
| 3315 | msgid "Searching subtitles..." | |
| 3316 | msgstr "Se caută subtitrări..." | |
| 3317 | ||
| 3318 | msgctxt "#90503" | |
| 3319 | msgid "No subtitles found" | |
| 3320 | msgstr "Nu s-au găsit subtitrări" | |
| 3321 | ||
| 3322 | msgctxt "#90504" | |
| 3323 | msgid "Failed to authenticate with DeepL. Please check your API key." | |
| 3324 | msgstr "Nu s-a putut autentifica cu DeepL. Verificați cheia API." | |
| 3325 | ||
| 3326 | msgctxt "#90505" | |
| 3327 | msgid "DeepL API key is missing or invalid." | |
| 3328 | msgstr "Cheia API DeepL lipsește sau este invalidă." | |
| 3329 | ||
| 3330 | msgctxt "#90510" | |
| 3331 | msgid "No meta available" | |
| 3332 | msgstr "Nu sunt metadate disponibile" | |
| 3333 | ||
| 3334 | msgctxt "#90511" | |
| 3335 | msgid "No content available for" | |
| 3336 | msgstr "Nu există conținut disponibil pentru" | |
| 3337 | ||
| 3338 | msgctxt "#90512" | |
| 3339 | msgid "Season" | |
| 3340 | msgstr "Sezon" | |
| 3341 | ||
| 3342 | msgctxt "#90513" | |
| 3343 | msgid "No episodes available" | |
| 3344 | msgstr "Nu sunt episoade disponibile" | |
| 3345 | ||
| 3346 | msgctxt "#90514" | |
| 3347 | msgid "No videos available" | |
| 3348 | msgstr "Nu sunt videoclipuri disponibile" | |
| 3349 | ||
| 3350 | msgctxt "#90515" | |
| 3351 | msgid "Next Page" | |
| 3352 | msgstr "Pagina următoare" | |
| 3353 | ||
| 3354 | msgctxt "#90516" | |
| 3355 | msgid "No metas available" | |
| 3356 | msgstr "Nu sunt metadate disponibile" | |
| 3357 | ||
| 3358 | msgctxt "#90517" | |
| 3359 | msgid "No seasons available" | |
| 3360 | msgstr "Nu sunt sezoane disponibile" | |
| 3361 | ||
| 3362 | msgctxt "#90518" | |
| 3363 | msgid "Invalid query" | |
| 3364 | msgstr "Interogare invalidă" | |
| 3365 | ||
| 3366 | msgctxt "#90519" | |
| 3367 | msgid "No lists found" | |
| 3368 | msgstr "Nu s-au găsit liste" | |
| 3369 | ||
| 3370 | msgctxt "#90520" | |
| 3371 | msgid "No user lists found" | |
| 3372 | msgstr "Nu s-au găsit liste de la utilizatori" | |
| 3373 | ||
| 3374 | msgctxt "#90521" | |
| 3375 | msgid "No top lists found" | |
| 3376 | msgstr "Nu s-au găsit liste de top" | |
| 3377 | ||
| 3378 | msgctxt "#90522" | |
| 3379 | msgid "Custom Addon" | |
| 3380 | msgstr "Addon personalizat" | |
| 3381 | ||
| 3382 | msgctxt "#90523" | |
| 3383 | msgid "Enter the custom Stremio addon URL" | |
| 3384 | msgstr "Introduceți URL-ul add-on-ului Stremio personalizat" | |
| 3385 | ||
| 3386 | msgctxt "#90524" | |
| 3387 | msgid "No URL provided." | |
| 3388 | msgstr "Nu a fost furnizat niciun URL." | |
| 3389 | ||
| 3390 | msgctxt "#90525" | |
| 3391 | msgid "Failed to fetch manifest: %s" | |
| 3392 | msgstr "Nu s-a putut prelua manifestul: %s" | |
| 3393 | ||
| 3394 | msgctxt "#90526" | |
| 3395 | msgid "Manifest missing 'id' or 'name'." | |
| 3396 | msgstr "Manifestul nu conține 'id' sau 'nume'." | |
| 3397 | ||
| 3398 | msgctxt "#90527" | |
| 3399 | msgid "Custom Stremio addon added successfully!" | |
| 3400 | msgstr "Addon Stremio personalizat adăugat cu succes!" | |
| 3401 | ||
| 3402 | msgctxt "#90528" | |
| 3403 | msgid "Addon does not provide 'stream' or 'catalog' resources." | |
| 3404 | msgstr "Add-on-ul nu oferă resurse 'stream' sau 'catalog'." | |
| 3405 | ||
| 3406 | msgctxt "#90529" | |
| 3407 | msgid "This addon is already added to your list." | |
| 3408 | msgstr "Acest addon este deja adăugat în listă." | |
| 3409 | ||
| 3410 | msgctxt "#90530" | |
| 3411 | msgid "Failed to add custom addon: %s" | |
| 3412 | msgstr "Nu s-a putut adăuga add-on-ul personalizat: %s" | |
| 3413 | ||
| 3414 | msgctxt "#90531" | |
| 3415 | msgid "Remove Addon" | |
| 3416 | msgstr "Elimină addon" | |
| 3417 | ||
| 3418 | msgctxt "#90532" | |
| 3419 | msgid "No custom Stremio addons to remove." | |
| 3420 | msgstr "Nu există add-on-uri Stremio personalizate de eliminat." | |
| 3421 | ||
| 3422 | msgctxt "#90533" | |
| 3423 | msgid "Remove Stremio Addons" | |
| 3424 | msgstr "Elimină add-on-uri Stremio" | |
| 3425 | ||
| 3426 | msgctxt "#90534" | |
| 3427 | msgid "Selected addon(s) removed." | |
| 3428 | msgstr "Add-on-ul/urile selectat(e) au fost eliminate." | |
| 3429 | ||
| 3430 | msgctxt "#90535" | |
| 3431 | msgid "Untitled List" | |
| 3432 | msgstr "Listă fără titlu" | |
| 3433 | ||
| 3434 | msgctxt "#90536" | |
| 3435 | msgid "Removed from Trakt list" | |
| 3436 | msgstr "Eliminat din lista Trakt" | |
| 3437 | ||
| 3438 | msgctxt "#90537" | |
| 3439 | msgid "Failed to remove from Trakt list" | |
| 3440 | msgstr "Nu s-a putut elimina din lista Trakt" | |
| 3441 | ||
| 3442 | msgctxt "#90538" | |
| 3443 | msgid "Resume from %.1f%%" | |
| 3444 | msgstr "Reia de la %.1f%%" | |
| 3445 | ||
| 3446 | msgctxt "#90539" | |
| 3447 | msgid "Start from Beginning" | |
| 3448 | msgstr "Începe de la început" | |
| 3449 | ||
| 3450 | msgctxt "#90540" | |
| 3451 | msgid "Authorize Debrid Services" | |
| 3452 | msgstr "Autorizează serviciile Debrid" | |
| 3453 | ||
| 3454 | msgctxt "#90541" | |
| 3455 | msgid "Navigate to: [B]%s[/B]" | |
| 3456 | msgstr "Accesați: [B]%s[/B]" | |
| 3457 | ||
| 3458 | msgctxt "#90542" | |
| 3459 | msgid "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]" | |
| 3460 | msgstr "Introduceți codul: [COLOR orangered][B]%s[/B][/COLOR]" | |
| 3461 | ||
| 3462 | msgctxt "#90543" | |
| 3463 | msgid "Premiumize Auth" | |
| 3464 | msgstr "Autentificare Premiumize" | |
| 3465 | ||
| 3466 | msgctxt "#90544" | |
| 3467 | msgid "Success" | |
| 3468 | msgstr "Succes" | |
| 3469 | ||
| 3470 | msgctxt "#90545" | |
| 3471 | msgid "Authentication completed." | |
| 3472 | msgstr "Autentificare finalizată." | |
| 3473 | ||
| 3474 | msgctxt "#90546" | |
| 3475 | msgid "Error:" | |
| 3476 | msgstr "Eroare:" | |
| 3477 | ||
| 3478 | msgctxt "#90547" | |
| 3479 | msgid "Error: %s" | |
| 3480 | msgstr "Eroare: %s" | |
| 3481 | ||
| 3482 | msgctxt "#90548" | |
| 3483 | msgid "Auth Error" | |
| 3484 | msgstr "Eroare de autentificare" | |
| 3485 | ||
| 3486 | msgctxt "#90549" | |
| 3487 | msgid "Torbox authentication successful." | |
| 3488 | msgstr "Autentificare Torbox reușită." | |
| 3489 | ||
| 3490 | msgctxt "#90554" | |
| 3491 | msgid "Please wait..." | |
| 3492 | msgstr "Vă rugăm așteptați..." | |
| 3493 | ||
| 3494 | msgctxt "#90555" | |
| 3495 | msgid "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3496 | msgstr "Accesați:\n[COLOR cyan]%s[/COLOR]\nIntroduceți codul: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3497 | ||
| 3498 | msgctxt "#90556" | |
| 3499 | msgid "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3500 | msgstr "Accesați:\n[COLOR cyan]%s[/COLOR]\n\nIntroduceți codul: [COLOR seagreen][B]%s[/B][/COLOR]" | |
| 3501 | ||
| 3502 | msgctxt "#90557" | |
| 3503 | msgid "Pastebin Link:\n[COLOR cyan]%s[/COLOR]" | |
| 3504 | msgstr "Link Pastebin:\n[COLOR cyan]%s[/COLOR]" | |
| 3505 | ||
| 3506 | msgctxt "#90670" | |
| 3507 | msgid "Local URL:\n[COLOR cyan]%s[/COLOR]" | |
| 3508 | msgstr "URL local:\n[COLOR cyan]%s[/COLOR]" | |
| 3509 | ||
| 3510 | msgctxt "#90558" | |
| 3511 | msgid "QR Code Authentication" | |
| 3512 | msgstr "Autentificare cod QR" | |
| 3513 | ||
| 3514 | msgctxt "#90559" | |
| 3515 | msgid "Kodi 20 or above required. Please update Kodi to use this addon." | |
| 3516 | msgstr "Este necesar Kodi 20 sau mai nou. Actualizați Kodi pentru a folosi acest addon." | |
| 3517 | ||
| 3518 | msgctxt "#90560" | |
| 3519 | msgid "Failed to start web server. Port may be in use." | |
| 3520 | msgstr "Nu s-a putut porni serverul web. Portul poate fi ocupat." | |
| 3521 | ||
| 3522 | msgctxt "#90561" | |
| 3523 | msgid "Authentification Removed." | |
| 3524 | msgstr "Autentificare eliminată." | |
| 3525 | ||
| 3526 | msgctxt "#90562" | |
| 3527 | msgid "WebDAV Error" | |
| 3528 | msgstr "Eroare WebDAV" | |
| 3529 | ||
| 3530 | msgctxt "#90563" | |
| 3531 | msgid "Trakt Authorization" | |
| 3532 | msgstr "Autorizare Trakt" | |
| 3533 | ||
| 3534 | msgctxt "#90564" | |
| 3535 | msgid "Do you want to continue transfer in background?" | |
| 3536 | msgstr "Doriți să continuați transferul în fundal?" | |
| 3537 | ||
| 3538 | msgctxt "#90565" | |
| 3539 | msgid "Cloud Transfer" | |
| 3540 | msgstr "Transfer în cloud" | |
| 3541 | ||
| 3542 | msgctxt "#90566" | |
| 3543 | msgid "Kodi Logs" | |
| 3544 | msgstr "Log-uri Kodi" | |
| 3545 | ||
| 3546 | msgctxt "#90567" | |
| 3547 | msgid "Show Logs" | |
| 3548 | msgstr "Afișează log-urile" | |
| 3549 | ||
| 3550 | msgctxt "#90568" | |
| 3551 | msgid "Export to paste.kodi.tv" | |
| 3552 | msgstr "Exportă pe paste.kodi.tv" | |
| 3553 | ||
| 3554 | msgctxt "#90569" | |
| 3555 | msgid "Kodi Logs Exported" | |
| 3556 | msgstr "Log-urile Kodi au fost exportate" | |
| 3557 | ||
| 3558 | msgctxt "#90570" | |
| 3559 | msgid "Providers Selection Dialog" | |
| 3560 | msgstr "Dialog selectare provideri" | |
| 3561 | ||
| 3562 | msgctxt "#90571" | |
| 3563 | msgid "Your current Providers are: \n%s\n\nDo you want to change?" | |
| 3564 | msgstr "Providerii actuali sunt: \n%s\n\nDoriți să îi schimbați?" | |
| 3565 | ||
| 3566 | msgctxt "#90572" | |
| 3567 | msgid "Select Providers" | |
| 3568 | msgstr "Selectează provideri" | |
| 3569 | ||
| 3570 | msgctxt "#90573" | |
| 3571 | msgid "Selection Dialog" | |
| 3572 | msgstr "Dialog de selecție" | |
| 3573 | ||
| 3574 | msgctxt "#90574" | |
| 3575 | msgid "Successfully selected: %s" | |
| 3576 | msgstr "Selectat cu succes: %s" | |
| 3577 | ||
| 3578 | msgctxt "#90575" | |
| 3579 | msgid "Selection" | |
| 3580 | msgstr "Selecție" | |
| 3581 | ||
| 3582 | msgctxt "#90576" | |
| 3583 | msgid "No providers selected" | |
| 3584 | msgstr "Niciun provider selectat" | |
| 3585 | ||
| 3586 | msgctxt "#90577" | |
| 3587 | msgid "Changelog" | |
| 3588 | msgstr "Listă modificări" | |
| 3589 | ||
| 3590 | msgctxt "#90578" | |
| 3591 | msgid "[B]Unable to check for updates[/B]" | |
| 3592 | msgstr "[B]Nu s-au putut verifica actualizările[/B]" | |
| 3593 | ||
| 3594 | msgctxt "#90579" | |
| 3595 | msgid "[B]No update available[/B]" | |
| 3596 | msgstr "[B]Nu există actualizări disponibile[/B]" | |
| 3597 | ||
| 3598 | msgctxt "#90580" | |
| 3599 | msgid "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]" | |
| 3600 | msgstr "Instalat: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]" | |
| 3601 | ||
| 3602 | msgctxt "#90581" | |
| 3603 | msgid "[B]Update available. Do you want to update?[/B]" | |
| 3604 | msgstr "[B]Actualizare disponibilă. Doriți să actualizați?[/B]" | |
| 3605 | ||
| 3606 | msgctxt "#90582" | |
| 3607 | msgid "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]" | |
| 3608 | msgstr "[B]Actualizare disponibilă (v%s).[/B] [I]Verificați setările pentru actualizare.[/I]" | |
| 3609 | ||
| 3610 | msgctxt "#90583" | |
| 3611 | msgid "Error fetching versions: %s" | |
| 3612 | msgstr "Eroare la obținerea versiunilor: %s" | |
| 3613 | ||
| 3614 | msgctxt "#90584" | |
| 3615 | msgid "No older/different versions found." | |
| 3616 | msgstr "Nu au fost găsite versiuni mai vechi/diferite." | |
| 3617 | ||
| 3618 | msgctxt "#90585" | |
| 3619 | msgid "Select Version to Downgrade" | |
| 3620 | msgstr "Selectează versiunea pentru downgrade" | |
| 3621 | ||
| 3622 | msgctxt "#90586" | |
| 3623 | msgid "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?" | |
| 3624 | msgstr "Sigur doriți să faceți downgrade/reinstalare la versiunea [B]%s[/B]?" | |
| 3625 | ||
| 3626 | msgctxt "#90587" | |
| 3627 | msgid "[B]Downloading update...[/B]" | |
| 3628 | msgstr "[B]Se descarcă actualizarea...[/B]" | |
| 3629 | ||
| 3630 | msgctxt "#90588" | |
| 3631 | msgid "Error: Unable to download update." | |
| 3632 | msgstr "Eroare: Nu s-a putut descărca actualizarea." | |
| 3633 | ||
| 3634 | msgctxt "#90589" | |
| 3635 | msgid "Error saving update file: %s" | |
| 3636 | msgstr "Eroare la salvarea fișierului de actualizare: %s" | |
| 3637 | ||
| 3638 | msgctxt "#90590" | |
| 3639 | msgid "Error extracting update. Please install manually." | |
| 3640 | msgstr "Eroare la extragerea actualizării. Vă rugăm instalați manual." | |
| 3641 | ||
| 3642 | msgctxt "#90591" | |
| 3643 | msgid "Do you want to view the changelog for the new version?" | |
| 3644 | msgstr "Doriți să vedeți modificările pentru noua versiune?" | |
| 3645 | ||
| 3646 | msgctxt "#90592" | |
| 3647 | msgid "New Release v%s - Changelog" | |
| 3648 | msgstr "Versiune nouă v%s - listă de modificări" | |
| 3649 | ||
| 3650 | msgctxt "#90593" | |
| 3651 | msgid "[B]Update complete.[/B]" | |
| 3652 | msgstr "[B]Actualizare finalizată.[/B]" | |
| 3653 | ||
| 3654 | msgctxt "#90594" | |
| 3655 | msgid "Real Debrid Auth" | |
| 3656 | msgstr "Autentificare Real Debrid" | |
| 3657 | ||
| 3658 | msgctxt "#90595" | |
| 3659 | msgid "Torbox Auth" | |
| 3660 | msgstr "Autentificare Torbox" | |
| 3661 | ||
| 3662 | msgctxt "#90596" | |
| 3663 | msgid "Debrider Auth" | |
| 3664 | msgstr "Autentificare Debrider" | |
| 3665 | ||
| 3666 | msgctxt "#90597" | |
| 3667 | msgid "Download Error" | |
| 3668 | msgstr "Eroare de descărcare" | |
| 3669 | ||
| 3670 | msgctxt "#90598" | |
| 3671 | msgid "Failed to download text: %s" | |
| 3672 | msgstr "Nu s-a putut descărca textul: %s" | |
| 3673 | ||
| 3674 | msgctxt "#90599" | |
| 3675 | msgid "General Error" | |
| 3676 | msgstr "Eroare generală" | |
| 3677 | ||
| 3678 | msgctxt "#90600" | |
| 3679 | msgid "Could not display text file." | |
| 3680 | msgstr "Nu s-a putut afișa fișierul text." | |
| 3681 | ||
| 3682 | msgctxt "#90601" | |
| 3683 | msgid "Failed to export logs." | |
| 3684 | msgstr "Nu s-au putut exporta log-urile." | |
| 3685 | ||
| 3686 | msgctxt "#90602" | |
| 3687 | msgid "Error reading log: %s" | |
| 3688 | msgstr "Eroare la citirea log-ului: %s" | |
| 3689 | ||
| 3690 | msgctxt "#90603" | |
| 3691 | msgid "Kodi log file not found." | |
| 3692 | msgstr "Fișierul log Kodi nu a fost găsit." | |
| 3693 | ||
| 3694 | msgctxt "#90604" | |
| 3695 | msgid "%s Auth" | |
| 3696 | msgstr "Autentificare %s" | |
| 3697 | ||
| 3698 | msgctxt "#90605" | |
| 3699 | msgid "Ok" | |
| 3700 | msgstr "Ok" | |
| 3701 | ||
| 3702 | msgctxt "#90606" | |
| 3703 | msgid "No" | |
| 3704 | msgstr "Nu" | |
| 3705 | ||
| 3706 | msgctxt "#90607" | |
| 3707 | msgid "Pinging addons for availability..." | |
| 3708 | msgstr "Se verifică disponibilitatea add-on-urilor..." | |
| 3709 | ||
| 3710 | msgctxt "#90608" | |
| 3711 | msgid "Pinging addons... %d/%d" | |
| 3712 | msgstr "Se verifică add-on-urile... %d/%d" | |
| 3713 | ||
| 3714 | msgctxt "#90609" | |
| 3715 | msgid "Ping Results" | |
| 3716 | msgstr "Rezultate ping" | |
| 3717 | ||
| 3718 | msgctxt "#90610" | |
| 3719 | msgid "Reachable: %d\nUnreachable: %d" | |
| 3720 | msgstr "Disponibile: %d\nIndisponibile: %d" | |
| 3721 | ||
| 3722 | msgctxt "#90611" | |
| 3723 | msgid "Select Category to Filter" | |
| 3724 | msgstr "Selectează categoria pentru filtrare" | |
| 3725 | ||
| 3726 | msgctxt "#90612" | |
| 3727 | msgid "Stremio Add-ons Import" | |
| 3728 | msgstr "Import add-on-uri Stremio" | |
| 3729 | ||
| 3730 | msgctxt "#90613" | |
| 3731 | msgid "To import your add-ons, please log in with your Stremio email and password." | |
| 3732 | msgstr "Pentru a importa add-on-urile, autentificați-vă cu email-ul și parola Stremio." | |
| 3733 | ||
| 3734 | msgctxt "#90614" | |
| 3735 | msgid "Enter your Email" | |
| 3736 | msgstr "Introduceți email-ul" | |
| 3737 | ||
| 3738 | msgctxt "#90615" | |
| 3739 | msgid "Enter your Password" | |
| 3740 | msgstr "Introduceți parola" | |
| 3741 | ||
| 3742 | msgctxt "#90616" | |
| 3743 | msgid "Login Failed" | |
| 3744 | msgstr "Autentificare eșuată" | |
| 3745 | ||
| 3746 | msgctxt "#90617" | |
| 3747 | msgid "Failed to login: %s" | |
| 3748 | msgstr "Autentificare eșuată: %s" | |
| 3749 | ||
| 3750 | msgctxt "#90618" | |
| 3751 | msgid "Add-ons Import Failed" | |
| 3752 | msgstr "Importul add-on-urilor a eșuat" | |
| 3753 | ||
| 3754 | msgctxt "#90619" | |
| 3755 | msgid "Please try again later and report the issue if the problem persists. For more details, check the log file." | |
| 3756 | msgstr "Încercați din nou mai târziu și raportați problema dacă persistă. Pentru detalii, consultați fișierul log." | |
| 3757 | ||
| 3758 | msgctxt "#90620" | |
| 3759 | msgid "Addons Imported" | |
| 3760 | msgstr "Add-on-uri importate" | |
| 3761 | ||
| 3762 | msgctxt "#90621" | |
| 3763 | msgid "Successfully imported addons from your account." | |
| 3764 | msgstr "Add-on-urile au fost importate cu succes din contul dvs." | |
| 3765 | ||
| 3766 | msgctxt "#90622" | |
| 3767 | msgid "Update Stremio Addons" | |
| 3768 | msgstr "Actualizează add-on-urile Stremio" | |
| 3769 | ||
| 3770 | msgctxt "#90623" | |
| 3771 | msgid "Do you want to update the Addons from your account?" | |
| 3772 | msgstr "Doriți să actualizați add-on-urile din contul dvs?" | |
| 3773 | ||
| 3774 | msgctxt "#90624" | |
| 3775 | msgid "Log Out from Stremio" | |
| 3776 | msgstr "Deconectare din Stremio" | |
| 3777 | ||
| 3778 | msgctxt "#90625" | |
| 3779 | msgid "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." | |
| 3780 | msgstr "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 | ||
| 3782 | msgctxt "#90626" | |
| 3783 | msgid "Log Out" | |
| 3784 | msgstr "Deconectare" | |
| 3785 | ||
| 3786 | msgctxt "#90627" | |
| 3787 | msgid "Use Existing" | |
| 3788 | msgstr "Folosește cel existent" | |
| 3789 | ||
| 3790 | msgctxt "#90628" | |
| 3791 | msgid "Download New" | |
| 3792 | msgstr "Descarcă nou" | |
| 3793 | ||
| 3794 | msgctxt "#90629" | |
| 3795 | msgid "Playback Failed" | |
| 3796 | msgstr "Redarea a eșuat" | |
| 3797 | ||
| 3798 | msgctxt "#90630" | |
| 3799 | msgid "The video could not be played.\nDo you want to retry?" | |
| 3800 | msgstr "Videoclipul nu a putut fi redat.\nDoriți să reîncercați?" | |
| 3801 | ||
| 3802 | msgctxt "#90631" | |
| 3803 | msgid "Retry" | |
| 3804 | msgstr "Reîncearcă" | |
| 3805 | ||
| 3806 | msgctxt "#90632" | |
| 3807 | msgid "Information" | |
| 3808 | msgstr "Informații" | |
| 3809 | ||
| 3810 | msgctxt "#90633" | |
| 3811 | msgid "Review" | |
| 3812 | msgstr "Recenzie" | |
| 3813 | ||
| 3814 | msgctxt "#90634" | |
| 3815 | msgid "Comment" | |
| 3816 | msgstr "Comentariu" | |
| 3817 | ||
| 3818 | msgctxt "#90635" | |
| 3819 | msgid "Trivia" | |
| 3820 | msgstr "Curiozități" | |
| 3821 | ||
| 3822 | msgctxt "#90636" | |
| 3823 | msgid "Goof" | |
| 3824 | msgstr "Gafă" | |
| 3825 | ||
| 3826 | msgctxt "#90637" | |
| 3827 | msgid "Parental Guide" | |
| 3828 | msgstr "Ghid parental" | |
| 3829 | ||
| 3830 | msgctxt "#90638" | |
| 3831 | msgid "Converting Magnet...\n\n%s" | |
| 3832 | msgstr "Se convertește magnetul...\n\n%s" | |
| 3833 | ||
| 3834 | msgctxt "#90639" | |
| 3835 | msgid "Real Debrid Error." | |
| 3836 | msgstr "Eroare Real Debrid." | |
| 3837 | ||
| 3838 | msgctxt "#90640" | |
| 3839 | msgid "File already cached" | |
| 3840 | msgstr "Fișier deja în cache" | |
| 3841 | ||
| 3842 | msgctxt "#90641" | |
| 3843 | msgid "Saving file to the Real Debrid Cloud" | |
| 3844 | msgstr "Se salvează fișierul în cloud-ul Real Debrid" | |
| 3845 | ||
| 3846 | msgctxt "#90642" | |
| 3847 | msgid "File cached" | |
| 3848 | msgstr "Fișier în cache" | |
| 3849 | ||
| 3850 | msgctxt "#90643" | |
| 3851 | msgid "Saving File to the Real Debrid Cloud...\n%s\n\n" | |
| 3852 | msgstr "Se salvează fișierul în cloud-ul Real Debrid...\n%s\n\n" | |
| 3853 | ||
| 3854 | msgctxt "#90644" | |
| 3855 | msgid "Downloading %s GB @ %s mbps from %s peers, %s %% completed" | |
| 3856 | msgstr "Se descarcă %s GB @ %s mbps de la %s peers, %s %% finalizat" | |
| 3857 | ||
| 3858 | msgctxt "#90645" | |
| 3859 | msgid "Trakt" | |
| 3860 | msgstr "Trakt" | |
| 3861 | ||
| 3862 | msgctxt "#90651" | |
| 3863 | msgid "Easynews Account Info" | |
| 3864 | msgstr "Informații cont Easynews" | |
| 3865 | ||
| 3866 | msgctxt "#90652" | |
| 3867 | msgid "DeepL API Error" | |
| 3868 | msgstr "Eroare API DeepL" | |
| 3869 | ||
| 3870 | msgctxt "#90653" | |
| 3871 | msgid "Download" | |
| 3872 | msgstr "Descărcare" | |
| 3873 | ||
| 3874 | msgctxt "#90654" | |
| 3875 | msgid "Failed to start download: %s" | |
| 3876 | msgstr "Nu s-a putut porni descărcarea: %s" | |
| 3877 | ||
| 3878 | msgctxt "#90655" | |
| 3879 | msgid "Modify Search" | |
| 3880 | msgstr "Modifică căutarea" | |
| 3881 | ||
| 3882 | msgctxt "#90656" | |
| 3883 | msgid "Torbox" | |
| 3884 | msgstr "Torbox" | |
| 3885 | ||
| 3886 | msgctxt "#90657" | |
| 3887 | msgid "Easy-Debrid" | |
| 3888 | msgstr "Easy-Debrid" | |
| 3889 | ||
| 3890 | msgctxt "#90658" | |
| 3891 | msgid "All-Debrid" | |
| 3892 | msgstr "All-Debrid" | |
| 3893 | ||
| 3894 | msgctxt "#90659" | |
| 3895 | msgid "Real-Debrid" | |
| 3896 | msgstr "Real-Debrid" | |
| 3897 | ||
| 3898 | msgctxt "#90660" | |
| 3899 | msgid "Getting results from providers..." | |
| 3900 | msgstr "Se obțin rezultate de la provideri..." | |
| 3901 | ||
| 3902 | msgctxt "#90661" | |
| 3903 | msgid "Jacktook [COLOR FFFF6B00]%s[/COLOR]" | |
| 3904 | msgstr "Jacktook [COLOR FFFF6B00]%s[/COLOR]" | |
| 3905 | ||
| 3906 | msgctxt "#90662" | |
| 3907 | msgid "Downloads Viewer" | |
| 3908 | msgstr "Vizualizator descărcări" | |
| 3909 | ||
| 3910 | msgctxt "#90663" | |
| 3911 | msgid "WebDAV Text Viewer" | |
| 3912 | msgstr "Vizualizator text WebDAV" | |
| 3913 | ||
| 3914 | msgctxt "#90664" | |
| 3915 | msgid "Selected Item" | |
| 3916 | msgstr "Element selectat" | |
| 3917 | ||
| 3918 | msgctxt "#90665" | |
| 3919 | msgid "Subtitle No. %d" | |
| 3920 | msgstr "Subtitrare nr. %d" | |
| 3921 | ||
| 3922 | msgctxt "#90666" | |
| 3923 | msgid "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?" | |
| 3924 | msgstr "Utilizare gratuită DeepL disponibilă!\nCaractere rămase în cota gratuită: %s\nCaractere de tradus: %s\n\nDoriți să continuați traducerea?" | |
| 3925 | ||
| 3926 | msgctxt "#90667" | |
| 3927 | msgid "Free DeepL Usage" | |
| 3928 | msgstr "Utilizare gratuită DeepL" | |
| 3929 | ||
| 3930 | msgctxt "#90668" | |
| 3931 | msgid "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?" | |
| 3932 | msgstr "Total caractere de tradus: %s\nCost estimat: $%.2f USD\n\nDoriți să continuați traducerea?" | |
| 3933 | ||
| 3934 | msgctxt "#90669" | |
| 3935 | msgid "Translation Cost" | |
| 3936 | msgstr "Cost traducere" | |
| 3937 | ||
| 3938 | msgctxt "#90672" | |
| 3939 | msgid "Play Trailer" | |
| 3940 | msgstr "Redă trailer" | |
| 3941 | ||
| 3942 | msgctxt "#90673" | |
| 3943 | msgid "Trailer unavailable" | |
| 3944 | msgstr "Trailer indisponibil" | |
| 3945 | ||
| 3946 | msgctxt "#90690" | |
| 3947 | msgid "Clear All Items" | |
| 3948 | msgstr "Șterge toate elementele" | |
| 3949 | ||
| 3950 | msgctxt "#90691" | |
| 3951 | msgid "Remove all items from this library?" | |
| 3952 | msgstr "Ștergeți toate elementele din această bibliotecă?" | |
| 3953 | ||
| 3954 | msgctxt "#90692" | |
| 3955 | msgid "Library cleared" | |
| 3956 | msgstr "Bibliotecă ștearsă" | |
| 3957 | ||
| 3958 | msgctxt "#90693" | |
| 3959 | msgid "No items to remove" | |
| 3960 | msgstr "Nu există elemente de șters" | |
| 3961 | ||
| 3962 | msgctxt "#90694" | |
| 3963 | msgid "Added to %s cloud but still downloading. Please wait before playing." | |
| 3964 | msgstr "Adăugat în cloud %s, dar încă se descarcă. Așteptați înainte de redare." | |
| 3965 | ||
| 3966 | msgctxt "#90695" | |
| 3967 | msgid "Torrent Not Ready" | |
| 3968 | msgstr "Torrentul nu este gata" | |
| 3969 | ||
| 3970 | msgctxt "#90696" | |
| 3971 | msgid "This torrent is still downloading in %s and is not ready yet." | |
| 3972 | msgstr "Acest torrent încă se descarcă în %s și nu este încă gata." | |
| 3973 | ||
| 3974 | msgctxt "#90697" | |
| 3975 | msgid "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?" | |
| 3976 | msgstr "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 | ||
| 3978 | msgctxt "#90698" | |
| 3979 | msgid "Play with Torrent" | |
| 3980 | msgstr "Redă cu torrent" | |
| 3981 | ||
| 3982 | msgctxt "#90699" | |
| 3983 | msgid "Cancel & Wait" | |
| 3984 | msgstr "Anulează și așteaptă" | |
| 3985 | ||
| 3986 | msgctxt "#90712" | |
| 3987 | msgid "Views" | |
| 3988 | msgstr "Vizualizări" | |
| 3989 | ||
| 3990 | msgctxt "#90713" | |
| 3991 | msgid "Set Main View" | |
| 3992 | msgstr "Setează vizualizarea principală" | |
| 3993 | ||
| 3994 | msgctxt "#90714" | |
| 3995 | msgid "Set Movies View" | |
| 3996 | msgstr "Setează vizualizarea filmelor" | |
| 3997 | ||
| 3998 | msgctxt "#90715" | |
| 3999 | msgid "Set TV Shows View" | |
| 4000 | msgstr "Setează vizualizarea serialelor" | |
| 4001 | ||
| 4002 | msgctxt "#90716" | |
| 4003 | msgid "Set Seasons View" | |
| 4004 | msgstr "Setează vizualizarea sezonelor" | |
| 4005 | ||
| 4006 | msgctxt "#90717" | |
| 4007 | msgid "Set Episodes View" | |
| 4008 | msgstr "Setează vizualizarea episoadelor" | |
| 4009 | ||
| 4010 | msgctxt "#90718" | |
| 4011 | msgid "Set Library View" | |
| 4012 | msgstr "Setează vizualizarea bibliotecii" | |
| 4013 | ||
| 4014 | msgctxt "#90719" | |
| 4015 | msgid "Set History View" | |
| 4016 | msgstr "Setează vizualizarea istoricului" | |
| 4017 | ||
| 4018 | msgctxt "#90720" | |
| 4019 | msgid "Set Downloads View" | |
| 4020 | msgstr "Setează vizualizarea descărcărilor" | |
| 4021 | ||
| 4022 | msgctxt "#90721" | |
| 4023 | msgid "Save Current View" | |
| 4024 | msgstr "Salvează vizualizarea curentă" | |
| 4025 | ||
| 4026 | msgctxt "#90722" | |
| 4027 | msgid "View saved" | |
| 4028 | msgstr "Vizualizare salvată" | |
| 4029 | ||
| 4030 | msgctxt "#90723" | |
| 4031 | msgid "Could not detect the current view" | |
| 4032 | msgstr "Nu s-a putut detecta vizualizarea curentă" | |
| 4033 | ||
| 4034 | msgctxt "#90724" | |
| 4035 | msgid "Saved views reset" | |
| 4036 | msgstr "Vizualizările salvate au fost resetate" | |
| 4037 | ||
| 4038 | msgctxt "#90725" | |
| 4039 | msgid "Reset Saved Views" | |
| 4040 | msgstr "Resetează vizualizările salvate" | |
| 4041 | ||
| 4042 | msgctxt "#90726" | |
| 4043 | msgid "Open this screen, change the Kodi view manually, then select Save Current View." | |
| 4044 | msgstr "Deschideți acest ecran, schimbați manual vizualizarea Kodi, apoi selectați „Salvează vizualizarea curentă”." | |
| 4045 | ||
| 4046 | msgctxt "#90742" | |
| 4047 | msgid "Addon Settings" | |
| 4048 | msgstr "Setări add-on" | |
| 4049 | ||
| 4050 | msgctxt "#90743" | |
| 4051 | msgid "Set Views" | |
| 4052 | msgstr "Setează vizualizări" | |
| 4053 | ||
| 4054 | msgctxt "#90730" | |
| 4055 | msgid "Search with title + year" | |
| 4056 | msgstr "Căutare cu titlu + an" | |
| 4057 | ||
| 4058 | msgctxt "#90731" | |
| 4059 | msgid "Search with original title" | |
| 4060 | msgstr "Căutare cu titlu original" | |
| 4061 | ||
| 4062 | msgctxt "#90732" | |
| 4063 | msgid "Search with original title + year" | |
| 4064 | msgstr "Căutare cu titlul original + an" | |
| 4065 | ||
| 4066 | msgctxt "#90733" | |
| 4067 | msgid "Search modes" | |
| 4068 | msgstr "Moduri de căutare" | |
| 4069 | ||
| 4070 | msgctxt "#90734" | |
| 4071 | msgid "Enter year" | |
| 4072 | msgstr "Introduceți anul" | |
| 4073 | ||
| 4074 | msgctxt "#90735" | |
| 4075 | msgid "Invalid year" | |
| 4076 | msgstr "An invalid" | |
| 4077 | ||
| 4078 | msgctxt "#90736" | |
| 4079 | msgid "Edit title" | |
| 4080 | msgstr "Editează titlul" | |
| 4081 | ||
| 4082 | msgctxt "#90512" | |
| 4083 | msgid "Season" | |
| 4084 | msgstr "Sezon" | |
| 4085 | ||
| 4086 | msgctxt "#90737" | |
| 4087 | msgid "Invalid season" | |
| 4088 | msgstr "Sezon invalid" | |
| 4089 | ||
| 4090 | msgctxt "#90738" | |
| 4091 | msgid "Episode" | |
| 4092 | msgstr "Episod" | |
| 4093 | ||
| 4094 | msgctxt "#90739" | |
| 4095 | msgid "Invalid episode" | |
| 4096 | msgstr "Episod invalid" | |
| 4097 | ||
| 4098 | msgctxt "#90740" | |
| 4099 | msgid "Search with current title" | |
| 4100 | msgstr "Căutare cu titlul curent" | |
| 4101 | ||
| 4102 | msgctxt "#90741" | |
| 4103 | msgid "Search with original title" | |
| 4104 | msgstr "Căutare cu titlul original" | |
| 4105 | ||
| 4106 | msgctxt "#90773" | |
| 4107 | msgid "Search full show with title + year" | |
| 4108 | msgstr "Caută serial complet cu titlu + an" | |
| 4109 | ||
| 4110 | msgctxt "#90744" | |
| 4111 | msgid "Upload Subtitle" | |
| 4112 | msgstr "Încarcă subtitrare" | |
| 4113 | ||
| 4114 | msgctxt "#90745" | |
| 4115 | msgid "Select Subtitle File" | |
| 4116 | msgstr "Selectează fișierul de subtitrare" | |
| 4117 | ||
| 4118 | msgctxt "#90746" | |
| 4119 | msgid "Local File" | |
| 4120 | msgstr "Fișier local" | |
| 4121 | ||
| 4122 | msgctxt "#90747" | |
| 4123 | msgid "Upload from Browser" | |
| 4124 | msgstr "Încarcă din browser" | |
| 4125 | ||
| 4126 | msgctxt "#90748" | |
| 4127 | msgid "Upload subtitle from your browser" | |
| 4128 | msgstr "Încarcă subtitrarea din browserul tău" | |
| 4129 | ||
| 4130 | msgctxt "#90749" | |
| 4131 | msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file" | |
| 4132 | msgstr "Scanează codul QR sau deschide URL-ul (%s) în browser pentru a încărca un fișier de subtitrare" | |
| 4133 | ||
| 4134 | msgctxt "#90750" | |
| 4135 | msgid "Search Title Language" | |
| 4136 | msgstr "Limba titlului de căutare" | |
| 4137 | ||
| 4138 | msgctxt "#90751" | |
| 4139 | msgid "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches." | |
| 4140 | msgstr "Controlează modul în care interogările de titlu Easynews/Jackett/Prowlarr sunt prioritizate pentru căutările contextuale." | |
| 4141 | ||
| 4142 | msgctxt "#90752" | |
| 4143 | msgid "English first" | |
| 4144 | msgstr "Mai întâi în engleză" | |
| 4145 | ||
| 4146 | msgctxt "#90753" | |
| 4147 | msgid "English only" | |
| 4148 | msgstr "Numai engleză" | |
| 4149 | ||
| 4150 | msgctxt "#90754" | |
| 4151 | msgid "Localized first" | |
| 4152 | msgstr "Mai întâi versiunea locală" | |
| 4153 | ||
| 4154 | msgctxt "#90755" | |
| 4155 | msgid "Manage Sources" | |
| 4156 | msgstr "Gestionează sursele" | |
| 4157 | ||
| 4158 | msgctxt "#90756" | |
| 4159 | msgid "No sources available" | |
| 4160 | msgstr "Nu există surse disponibile" | |
| 4161 | ||
| 4162 | msgctxt "#90179" | |
| 4163 | msgid "Stinger Notifications" | |
| 4164 | msgstr "Notificări stinger" | |
| 4165 | ||
| 4166 | msgctxt "#90180" | |
| 4167 | msgid "Show a notification when a movie has post-credit scenes." | |
| 4168 | msgstr "Afișează o notificare când un film are scene post-credit." | |
| 4169 | ||
| 4170 | msgctxt "#90181" | |
| 4171 | msgid "Stinger Notification Time" | |
| 4172 | msgstr "Timp notificare stinger" | |
| 4173 | ||
| 4174 | msgctxt "#90182" | |
| 4175 | msgid "Seconds before the end to show the stinger notification." | |
| 4176 | msgstr "Secunde înainte de final pentru afișarea notificării stinger." | |
| 4177 | ||
| 4178 | msgctxt "#90183" | |
| 4179 | msgid "Stay tuned after the credits!" | |
| 4180 | msgstr "Rămâi după genericul de final!" | |
| 4181 | ||
| 4182 | msgctxt "#90757" | |
| 4183 | msgid "Image Resolution" | |
| 4184 | msgstr "Rezoluție imagine" | |
| 4185 | ||
| 4186 | msgctxt "#90758" | |
| 4187 | msgid "Low" | |
| 4188 | msgstr "Mică" | |
| 4189 | ||
| 4190 | msgctxt "#90759" | |
| 4191 | msgid "Medium" | |
| 4192 | msgstr "Medie" | |
| 4193 | ||
| 4194 | msgctxt "#90760" | |
| 4195 | msgid "High" | |
| 4196 | msgstr "Înaltă" | |
| 4197 | ||
| 4198 | msgctxt "#90761" | |
| 4199 | msgid "Original" | |
| 4200 | msgstr "Originală" | |
| 4201 | ||
| 4202 | msgctxt "#90762" | |
| 4203 | msgid "Enable RPDB Posters" | |
| 4204 | msgstr "Activează postere RPDB" | |
| 4205 | ||
| 4206 | msgctxt "#90763" | |
| 4207 | msgid "RPDB API Key" | |
| 4208 | msgstr "Cheie API RPDB" | |
| 4209 | ||
| 4210 | msgctxt "#90764" | |
| 4211 | msgid "Get your API key at ratingposterdb.com" | |
| 4212 | msgstr "Obține cheia API de la ratingposterdb.com" | |
| 4213 | ||
| 4214 | msgctxt "#90765" | |
| 4215 | msgid "Filter by Codecs" | |
| 4216 | msgstr "Filtrare după codecuri" | |
| 4217 | ||
| 4218 | msgctxt "#90766" | |
| 4219 | msgid "Filter by HDR" | |
| 4220 | msgstr "Filtrare după HDR" | |
| 4221 | ||
| 4222 | msgctxt "#90767" | |
| 4223 | msgid "Include HEVC / H.265" | |
| 4224 | msgstr "Include HEVC / H.265" | |
| 4225 | ||
| 4226 | msgctxt "#90768" | |
| 4227 | msgid "Include AV1" | |
| 4228 | msgstr "Include AV1" | |
| 4229 | ||
| 4230 | msgctxt "#90769" | |
| 4231 | msgid "Include H.264" | |
| 4232 | msgstr "Include H.264" | |
| 4233 | ||
| 4234 | msgctxt "#90770" | |
| 4235 | msgid "Include Dolby Vision" | |
| 4236 | msgstr "Include Dolby Vision" | |
| 4237 | ||
| 4238 | msgctxt "#90771" | |
| 4239 | msgid "Include HDR10 / HDR10+" | |
| 4240 | msgstr "Include HDR10 / HDR10+" | |
| 4241 | ||
| 4242 | msgctxt "#90772" | |
| 4243 | msgid "Include HDR (generic)" | |
| 4244 | msgstr "Include HDR (generic)" | |
| 4245 | ||
| 4246 | msgctxt "#90774" | |
| 4247 | msgid "Organize downloads by type" | |
| 4248 | msgstr "Organizează descărcările pe tip" | |
| 4249 | ||
| 4250 | msgctxt "#90775" | |
| 4251 | msgid "Movies folder name" | |
| 4252 | msgstr "Numele folderului pentru filme" | |
| 4253 | ||
| 4254 | msgctxt "#90776" | |
| 4255 | msgid "TV Shows folder name" | |
| 4256 | msgstr "Numele folderului pentru seriale" | |
| 4257 | ||
| 4258 | msgctxt "#90777" | |
| 4259 | msgid "Pause" | |
| 4260 | msgstr "Pauză" | |
| 4261 | ||
| 4262 | msgctxt "#90778" | |
| 4263 | msgid "Resume" | |
| 4264 | msgstr "Continuă" | |
| 4265 | ||
| 4266 | msgctxt "#90779" | |
| 4267 | msgid "Downloading" | |
| 4268 | msgstr "Se descarcă" | |
| 4269 | ||
| 4270 | msgctxt "#90780" | |
| 4271 | msgid "Paused" | |
| 4272 | msgstr "În pauză" | |
| 4273 | ||
| 4274 | msgctxt "#90781" | |
| 4275 | msgid "Completed" | |
| 4276 | msgstr "Finalizat" | |
| 4277 | ||
| 4278 | msgctxt "#90782" | |
| 4279 | msgid "Delete" | |
| 4280 | msgstr "Șterge" | |
| 4281 | ||
| 4282 | msgctxt "#90783" | |
| 4283 | msgid "Startup" | |
| 4284 | msgstr "Pornire" | |
| 4285 | ||
| 4286 | msgctxt "#90784" | |
| 4287 | msgid "Auto-start Jacktook on Kodi boot" | |
| 4288 | msgstr "Pornire automată Jacktook la boot-ul Kodi" | |
| 4289 | ||
| 4290 | msgctxt "#90785" | |
| 4291 | msgid "Automatically open Jacktook when Kodi starts" | |
| 4292 | msgstr "Deschide automat Jacktook când Kodi pornește" | |
| 4293 | ||
| 4294 | msgctxt "#90786" | |
| 4295 | msgid "Widgets" | |
| 4296 | msgstr "Widget-uri" | |
| 4297 | ||
| 4298 | msgctxt "#90787" | |
| 4299 | msgid "Widget refresh interval (minutes)" | |
| 4300 | msgstr "Interval de reîmprospătare widget (minute)" | |
| 4301 | ||
| 4302 | msgctxt "#90788" | |
| 4303 | msgid "Set to 0 to disable automatic widget refresh" | |
| 4304 | msgstr "Setați la 0 pentru a dezactiva reîmprospătarea automată" | |
| 4305 | ||
| 4306 | msgctxt "#90789" | |
| 4307 | msgid "Show refresh notification" | |
| 4308 | msgstr "Afișați notificarea de reîmprospătare" | |
| 4309 | ||
| 4310 | msgctxt "#90790" | |
| 4311 | msgid "Display a notification when widgets are refreshed" | |
| 4312 | msgstr "Afișați o notificare când widget-urile sunt reîmprospătate" | |
| 4313 | ||
| 4314 | msgctxt "#90791" | |
| 4315 | msgid "Download to Device" | |
| 4316 | msgstr "Descarcă pe dispozitiv" | |
| 4317 | ||
| 4318 | msgctxt "#90792" | |
| 4319 | msgid "Download Manager" | |
| 4320 | msgstr "Manager de descărcări" | |
| 4321 | ||
| 4322 | msgctxt "#90793" | |
| 4323 | msgid "No active downloads" | |
| 4324 | msgstr "Nicio descărcare activă" | |
| 4325 | ||
| 4326 | msgctxt "#90794" | |
| 4327 | msgid "Progress" | |
| 4328 | msgstr "Progres" | |
| 4329 | ||
| 4330 | msgctxt "#90795" | |
| 4331 | msgid "Speed" | |
| 4332 | msgstr "Viteză" | |
| 4333 | ||
| 4334 | msgctxt "#90796" | |
| 4335 | msgid "ETA" | |
| 4336 | msgstr "Timp rămas" | |
| 4337 | ||
| 4338 | msgctxt "#90797" | |
| 4339 | msgid "Size" | |
| 4340 | msgstr "Dimensiune" | |
| 4341 | ||
| 4342 | msgctxt "#90798" | |
| 4343 | msgid "Pause" | |
| 4344 | msgstr "Pauză" | |
| 4345 | ||
| 4346 | msgctxt "#90799" | |
| 4347 | msgid "Resume" | |
| 4348 | msgstr "Relua" | |
| 4349 | ||
| 4350 | msgctxt "#90800" | |
| 4351 | msgid "Cancel" | |
| 4352 | msgstr "Anulare" | |
| 4353 | ||
| 4354 | msgctxt "#90801" | |
| 4355 | msgid "Delete" | |
| 4356 | msgstr "Șterge" | |
| 4357 | ||
| 4358 | msgctxt "#90802" | |
| 4359 | msgid "Clear Completed" | |
| 4360 | msgstr "Șterge completate" | |
| 4361 | ||
| 4362 | msgctxt "#90803" | |
| 4363 | msgid "Download not yet completed" | |
| 4364 | msgstr "Descărcarea nu este încă finalizată" | |
| 4365 | ||
| 4366 | msgctxt "#90804" | |
| 4367 | msgid "File not found" | |
| 4368 | msgstr "Fișier negăsit" | |
| 4369 | ||
| 4370 | msgctxt "#90805" | |
| 4371 | msgid "Download Manager" | |
| 4372 | msgstr "Manager de descărcări" | |
| 4373 | ||
| 4374 | msgctxt "#90806" | |
| 4375 | msgid "My Downloads" | |
| 4376 | msgstr "Descărcările mele" | |
| 4377 | ||
| 4378 | msgctxt "#90807" | |
| 4379 | msgid "Connecting..." | |
| 4380 | msgstr "Se conectează..." | |
| 4381 | ||
| 4382 | msgctxt "#90808" | |
| 4383 | msgid "Close" | |
| 4384 | msgstr "Închide" | |
| 4385 | ||
| 4386 | msgctxt "#90809" | |
| 4387 | msgid "Delete download?" | |
| 4388 | msgstr "Ștergeți descărcarea?" | |
| 4389 | ||
| 4390 | msgctxt "#90810" | |
| 4391 | msgid "This will permanently delete the downloaded file. Are you sure?" | |
| 4392 | msgstr "Acest lucru va șterge permanent fișierul descărcat. Sunteți sigur?" | |
| 4393 | ||
| 4394 | msgctxt "#90811" | |
| 4395 | msgid "Completed" | |
| 4396 | msgstr "Finalizat" | |
| 4397 | ||
| 4398 | msgctxt "#90812" | |
| 4399 | msgid "Excluded TMDB Languages" | |
| 4400 | msgstr "Limbi excluse TMDB" | |
| 4401 | ||
| 4402 | msgctxt "#90813" | |
| 4403 | msgid "Select languages to exclude" | |
| 4404 | msgstr "Selectează limbile de exclus" | |
| 4405 | ||
| 4406 | msgctxt "#90814" | |
| 4407 | msgid "Choose original languages to hide from TMDB listings" | |
| 4408 | msgstr "Alege limbile originale de ascuns din listele TMDB" | |
| 4409 | ||
| 4410 | msgctxt "#90815" | |
| 4411 | msgid "Support Jacktook" | |
| 4412 | msgstr "Susține Jacktook" | |
| 4413 | ||
| 4414 | msgctxt "#90816" | |
| 4415 | msgid "Support Jacktook development with an optional Ko-fi donation." | |
| 4416 | msgstr "Susține dezvoltarea Jacktook cu o donație opțională pe Ko-fi." | |
| 4417 | ||
| 4418 | msgctxt "#90817" | |
| 4419 | msgid "Scan the QR code or open this link:" | |
| 4420 | msgstr "Scanează codul QR sau deschide acest link:" | |
| 4421 | ||
| 4422 | msgctxt "#90818" | |
| 4423 | msgid "Jacktook was updated with new improvements. Donations are optional, but they help keep development active. Would you like to see the support link?" | |
| 4424 | msgstr "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 | ||
| 4426 | msgctxt "#90819" | |
| 4427 | msgid "Rename Stremio add-on" | |
| 4428 | msgstr "Redenumește addonul Stremio" | |
| 4429 | ||
| 4430 | msgctxt "#90824" | |
| 4431 | msgid "Rename Stremio catalog" | |
| 4432 | msgstr "Redenumește catalogul Stremio" | |
| 4433 | ||
| 4434 | msgctxt "#90827" | |
| 4435 | msgid "Set a Kodi-only display alias for a Stremio add-on." | |
| 4436 | msgstr "Setează un alias de afișare doar în Kodi pentru un addon Stremio." | |
| 4437 | ||
| 4438 | msgctxt "#90828" | |
| 4439 | msgid "Set a Kodi-only display alias for a Stremio catalog." | |
| 4440 | msgstr "Setează un alias de afișare doar în Kodi pentru un catalog Stremio." | |
| 4441 | ||
| 4442 | msgctxt "#90832" | |
| 4443 | msgid "Stremio: Addons Management" | |
| 4444 | msgstr "Stremio: Gestionarea addonurilor" | |
| 4445 | ||
| 4446 | msgctxt "#90833" | |
| 4447 | msgid "Stremio: Catalogs & Visibility" | |
| 4448 | msgstr "Stremio: Cataloage și vizibilitate" | |
| 4449 | ||
| 4450 | msgctxt "#90834" | |
| 4451 | msgid "Stremio: Rename & Customization" | |
| 4452 | msgstr "Stremio: Redenumire și personalizare" | |
| 4453 | ||
| 4454 | msgctxt "#90835" | |
| 4455 | msgid "Stremio: Search & Behavior" | |
| 4456 | msgstr "Stremio: Căutare și comportament" | |
| 4457 | ||
| 4458 | msgctxt "#90300" | |
| 4459 | msgid "External Scraper" | |
| 4460 | msgstr "Magneto" | |
| 4461 | ||
| 4462 | msgctxt "#90301" | |
| 4463 | msgid "Enable external scraper module integration." | |
| 4464 | msgstr "Enable external scraper module integration." | |
| 4465 | ||
| 4466 | msgctxt "#90302" | |
| 4467 | msgid "Choose External Scraper Module" | |
| 4468 | msgstr "Alege modulul scraper extern" | |
| 4469 | ||
| 4470 | msgctxt "#90303" | |
| 4471 | msgid "Select an external scraper addon module to use for torrent search." | |
| 4472 | msgstr "Selectează un modul addon scraper extern de folosit pentru căutarea de torrente." | |
| 4473 | ||
| 4474 | msgctxt "#90304" | |
| 4475 | msgid "Choose External Scraper" | |
| 4476 | msgstr "Alege scraper extern" | |
| 4477 | ||
| 4478 | msgctxt "#90305" | |
| 4479 | msgid "Selected Module" | |
| 4480 | msgstr "Modul selectat" | |
| 4481 | ||
| 4482 | msgctxt "#90306" | |
| 4483 | msgid "Open External Scraper Settings" | |
| 4484 | msgstr "Deschide setările scraperului extern" | |
| 4485 | ||
| 4486 | msgctxt "#90307" | |
| 4487 | msgid "Open the settings for the selected external scraper module." | |
| 4488 | msgstr "Deschide setările pentru modulul scraper extern selectat." |
| … | … | @@ -312,8 +312,8 @@ msgid "Unknown" |
| 312 | 312 | msgstr "Неизвестно" |
| 313 | 313 | |
| 314 | 314 | msgctxt "#30700" |
| 315 | msgid "Buffer and play" | |
| 316 | msgstr "Буферизация и воспроизведение" | |
| 315 | msgid "Play Torrent" | |
| 316 | msgstr "Воспроизвести торрент" | |
| 317 | 317 | |
| 318 | 318 | msgctxt "#30701" |
| 319 | 319 | msgid "Stop downloading" |
| … | … | @@ -431,6 +431,58 @@ msgctxt "#30939" |
| 431 | 431 | msgid "Auto-resume from last position without asking" |
| 432 | 432 | msgstr "Автоматически возобновлять с последней позиции без вопросов" |
| 433 | 433 | |
| 434 | msgctxt "#30940" | |
| 435 | msgid "Use Percentage Trigger" | |
| 436 | msgstr "Использовать срабатывание по проценту" | |
| 437 | ||
| 438 | msgctxt "#30941" | |
| 439 | msgid "Show Dialog at Playback Percentage" | |
| 440 | msgstr "Показывать диалог при проценте воспроизведения" | |
| 441 | ||
| 442 | msgctxt "#30942" | |
| 443 | msgid "Trigger the next-episode dialog based on percentage of video played instead of seconds remaining." | |
| 444 | msgstr "Показывать диалог следующего эпизода на основе процента просмотренного видео, а не оставшихся секунд." | |
| 445 | ||
| 446 | msgctxt "#30943" | |
| 447 | msgid "The percentage at which the next-episode dialog appears (e.g. 95 means 95% of the video)." | |
| 448 | msgstr "Процент, при котором появляется диалог следующего эпизода (например, 95 означает 95% видео)." | |
| 449 | ||
| 450 | msgctxt "#30944" | |
| 451 | msgid "Auto-Play Timeout" | |
| 452 | msgstr "Тайм-аут автовоспроизведения" | |
| 453 | ||
| 454 | msgctxt "#30945" | |
| 455 | msgid "Seconds before auto-playing next episode. 0 = disabled, dialog waits for user input." | |
| 456 | msgstr "Секунды до автоматического воспроизведения следующего эпизода. 0 = отключено, диалог ждёт ввода пользователя." | |
| 457 | ||
| 458 | ||
| 459 | msgctxt "#30946" | |
| 460 | msgid "Offcloud Configuration" | |
| 461 | msgstr "Настройка Offcloud" | |
| 462 | ||
| 463 | msgctxt "#30947" | |
| 464 | msgid "Enable Offcloud cached torrent and cloud transfer support." | |
| 465 | msgstr "Включить поддержку кэшированных торрентов и облачных загрузок Offcloud." | |
| 466 | ||
| 467 | msgctxt "#30948" | |
| 468 | msgid "Your Offcloud account username/email." | |
| 469 | msgstr "Ваше имя пользователя или email Offcloud." | |
| 470 | ||
| 471 | msgctxt "#30949" | |
| 472 | msgid "Offcloud API key from Account API Key settings." | |
| 473 | msgstr "API-ключ Offcloud из настроек аккаунта → API Key." | |
| 474 | ||
| 475 | msgctxt "#30950" | |
| 476 | msgid "View Offcloud account information." | |
| 477 | msgstr "Просмотреть информацию об аккаунте Offcloud." | |
| 478 | ||
| 479 | msgctxt "#30951" | |
| 480 | msgid "Authorize add-on to access Offcloud account." | |
| 481 | msgstr "Авторизовать дополнение для доступа к аккаунту Offcloud." | |
| 482 | ||
| 483 | msgctxt "#30952" | |
| 484 | msgid "Revoke Offcloud account access." | |
| 485 | msgstr "Отозвать доступ аккаунта Offcloud." | |
| 434 | 486 | msgctxt "#30728" |
| 435 | 487 | msgid "Seeds" |
| 436 | 488 | msgstr "Сиды" |
| … | … | @@ -459,6 +511,10 @@ msgctxt "#30734" |
| 459 | 511 | msgid "Enable" |
| 460 | 512 | msgstr "Включить" |
| 461 | 513 | |
| 514 | msgctxt "#30735" | |
| 515 | msgid "Still Watching? Threshold" | |
| 516 | msgstr "Порог 'Ещё смотрите?'" | |
| 517 | ||
| 462 | 518 | msgctxt "#30736" |
| 463 | 519 | msgid "Subtitle Language" |
| 464 | 520 | msgstr "Язык субтитров" |
| … | … | @@ -607,6 +663,10 @@ msgctxt "#30772" |
| 607 | 663 | msgid "Zilean Host" |
| 608 | 664 | msgstr "Хост Zilean" |
| 609 | 665 | |
| 666 | msgctxt "#30773" | |
| 667 | msgid "Number of consecutive auto-played episodes before asking 'Still Watching?'. Set to 0 to disable." | |
| 668 | msgstr "Количество подряд автоматически воспроизведённых эпизодов перед вопросом 'Ещё смотрите?'. Установите 0, чтобы отключить." | |
| 669 | ||
| 610 | 670 | msgctxt "#30774" |
| 611 | 671 | msgid "Zilean Timeout" |
| 612 | 672 | msgstr "Тайм-аут Zilean" |
| … | … | @@ -1556,8 +1616,8 @@ msgid "TV Calendar" |
| 1556 | 1616 | msgstr "ТВ-календарь" |
| 1557 | 1617 | |
| 1558 | 1618 | msgctxt "#90022" |
| 1559 | msgid "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:" | |
| 1560 | msgstr "Если вам нравится использовать Jacktook и вы хотите поддержать его разработку, вы можете угостить меня кофе! Ваша поддержка помогает проекту оставаться активным и позволяет вносить будущие улучшения:" | |
| 1619 | msgid "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." | |
| 1620 | msgstr "Jacktook бесплатен и создается с заботой в свободное время. Если вам нравится addon, рассмотрите возможность поддержки его разработки. Пожертвования необязательны и помогают выпускать обновления." | |
| 1561 | 1621 | |
| 1562 | 1622 | msgctxt "#90023" |
| 1563 | 1623 | msgid "Support Jacktook" |
| … | … | @@ -1991,21 +2051,37 @@ msgctxt "#90144" |
| 1991 | 2051 | msgid "Could not resolve playback URL" |
| 1992 | 2052 | msgstr "Не удалось разрешить URL воспроизведения" |
| 1993 | 2053 | |
| 1994 | msgctxt "#90145" | |
| 1995 | msgid "Precaching Enabled" | |
| 1996 | msgstr "Предкэширование включено" | |
| 2054 | msgctxt "#90171" | |
| 2055 | msgid "Autoscrape Next Episode" | |
| 2056 | msgstr "Автопоиск следующего эпизода" | |
| 1997 | 2057 | |
| 1998 | msgctxt "#90146" | |
| 1999 | msgid "Enable or disable precaching of next episodes." | |
| 2000 | msgstr "Включить или выключить предкэширование следующих серий." | |
| 2058 | msgctxt "#90172" | |
| 2059 | msgid "Pre-resolve the next episode's playback URL in the background while watching." | |
| 2060 | msgstr "Предварительно определять URL воспроизведения следующего эпизода в фоне во время просмотра." | |
| 2001 | 2061 | |
| 2002 | msgctxt "#90147" | |
| 2003 | msgid "Precaching Episode Count" | |
| 2004 | msgstr "Количество предкэшируемых серий" | |
| 2062 | msgctxt "#90173" | |
| 2063 | msgid "Autoscrape Threshold" | |
| 2064 | msgstr "Порог автопоиска" | |
| 2005 | 2065 | |
| 2006 | msgctxt "#90148" | |
| 2007 | msgid "Number of next episodes to precache." | |
| 2008 | msgstr "Количество следующих серий для предкэширования." | |
| 2066 | msgctxt "#90174" | |
| 2067 | msgid "Percentage of playback to trigger background pre-resolution." | |
| 2068 | msgstr "Процент воспроизведения, при котором запускается фоновое предварительное определение." | |
| 2069 | ||
| 2070 | msgctxt "#90175" | |
| 2071 | msgid "Autoscrape Episode Count" | |
| 2072 | msgstr "Количество эпизодов для автопоиска" | |
| 2073 | ||
| 2074 | msgctxt "#90176" | |
| 2075 | msgid "Number of upcoming episodes to pre-resolve." | |
| 2076 | msgstr "Количество следующих эпизодов для предварительного определения." | |
| 2077 | ||
| 2078 | msgctxt "#90177" | |
| 2079 | msgid "Autoscrape Cache TTL" | |
| 2080 | msgstr "TTL кэша автопоиска" | |
| 2081 | ||
| 2082 | msgctxt "#90178" | |
| 2083 | msgid "Hours to keep resolved playback data in cache." | |
| 2084 | msgstr "Сколько часов хранить в кэше определённые данные воспроизведения." | |
| 2009 | 2085 | |
| 2010 | 2086 | msgctxt "#30901" |
| 2011 | 2087 | msgid "Show Only Stremio Catalogs" |
| … | … | @@ -2555,6 +2631,10 @@ msgctxt "#90298" |
| 2555 | 2631 | msgid "Liked Lists" |
| 2556 | 2632 | msgstr "Понравившиеся списки" |
| 2557 | 2633 | |
| 2634 | msgctxt "#90299" | |
| 2635 | msgid "No metadata found for this torrent" | |
| 2636 | msgstr "Метаданные для этого торрента не найдены" | |
| 2637 | ||
| 2558 | 2638 | msgctxt "#90331" |
| 2559 | 2639 | msgid "Stremio Addons" |
| 2560 | 2640 | msgstr "Аддоны Stremio" |
| … | … | @@ -3651,6 +3731,18 @@ msgctxt "#90749" |
| 3651 | 3731 | msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file" |
| 3652 | 3732 | msgstr "Отсканируйте QR-код или откройте URL (%s) в браузере, чтобы загрузить файл субтитров" |
| 3653 | 3733 | |
| 3734 | msgctxt "#90740" | |
| 3735 | msgid "Search with current title" | |
| 3736 | msgstr "Искать с текущим названием" | |
| 3737 | ||
| 3738 | msgctxt "#90741" | |
| 3739 | msgid "Search with original title" | |
| 3740 | msgstr "Искать с оригинальным названием" | |
| 3741 | ||
| 3742 | msgctxt "#90773" | |
| 3743 | msgid "Search full show with title + year" | |
| 3744 | msgstr "Искать весь сериал по названию + году" | |
| 3745 | ||
| 3654 | 3746 | msgctxt "#90750" |
| 3655 | 3747 | msgid "Search Title Language" |
| 3656 | 3748 | msgstr "Язык заголовка поиска" |
| … | … | @@ -3670,3 +3762,715 @@ msgstr "Только английский" |
| 3670 | 3762 | msgctxt "#90754" |
| 3671 | 3763 | msgid "Localized first" |
| 3672 | 3764 | msgstr "Сначала локализованный" |
| 3765 | ||
| 3766 | msgctxt "#90755" | |
| 3767 | msgid "Manage Sources" | |
| 3768 | msgstr "Управление источниками" | |
| 3769 | ||
| 3770 | msgctxt "#90756" | |
| 3771 | msgid "No sources available" | |
| 3772 | msgstr "Нет доступных источников" | |
| 3773 | ||
| 3774 | msgctxt "#90179" | |
| 3775 | msgid "Stinger Notifications" | |
| 3776 | msgstr "Уведомления о сценах после титров" | |
| 3777 | ||
| 3778 | msgctxt "#90180" | |
| 3779 | msgid "Show a notification when a movie has post-credit scenes." | |
| 3780 | msgstr "Показывать уведомление, если в фильме есть сцены после титров." | |
| 3781 | ||
| 3782 | msgctxt "#90181" | |
| 3783 | msgid "Stinger Notification Time" | |
| 3784 | msgstr "Время уведомления о сцене после титров" | |
| 3785 | ||
| 3786 | msgctxt "#90182" | |
| 3787 | msgid "Seconds before the end to show the stinger notification." | |
| 3788 | msgstr "За сколько секунд до конца показать уведомление о сцене после титров." | |
| 3789 | ||
| 3790 | msgctxt "#90183" | |
| 3791 | msgid "Stay tuned after the credits!" | |
| 3792 | msgstr "Не выключайте после титров!" | |
| 3793 | ||
| 3794 | msgctxt "#90757" | |
| 3795 | msgid "Image Resolution" | |
| 3796 | msgstr "Razresheniye izobrazheniya" | |
| 3797 | ||
| 3798 | msgctxt "#90758" | |
| 3799 | msgid "Low" | |
| 3800 | msgstr "Nizkoye" | |
| 3801 | ||
| 3802 | msgctxt "#90759" | |
| 3803 | msgid "Medium" | |
| 3804 | msgstr "Sredneye" | |
| 3805 | ||
| 3806 | msgctxt "#90760" | |
| 3807 | msgid "High" | |
| 3808 | msgstr "Vysokoye" | |
| 3809 | ||
| 3810 | msgctxt "#90761" | |
| 3811 | msgid "Original" | |
| 3812 | msgstr "Originalnoye" | |
| 3813 | ||
| 3814 | msgctxt "#90762" | |
| 3815 | msgid "Enable RPDB Posters" | |
| 3816 | msgstr "Vkluchit posteri RPDB" | |
| 3817 | ||
| 3818 | msgctxt "#90763" | |
| 3819 | msgid "RPDB API Key" | |
| 3820 | msgstr "API-klyuch RPDB" | |
| 3821 | ||
| 3822 | msgctxt "#90764" | |
| 3823 | msgid "Get your API key at ratingposterdb.com" | |
| 3824 | msgstr "Poluchite API-klyuch na ratingposterdb.com" | |
| 3825 | ||
| 3826 | msgctxt "#90765" | |
| 3827 | msgid "Filter by Codecs" | |
| 3828 | msgstr "Фильтр по кодекам" | |
| 3829 | ||
| 3830 | msgctxt "#90766" | |
| 3831 | msgid "Filter by HDR" | |
| 3832 | msgstr "Фильтр по HDR" | |
| 3833 | ||
| 3834 | msgctxt "#90767" | |
| 3835 | msgid "Include HEVC / H.265" | |
| 3836 | msgstr "Включать HEVC / H.265" | |
| 3837 | ||
| 3838 | msgctxt "#90768" | |
| 3839 | msgid "Include AV1" | |
| 3840 | msgstr "Включать AV1" | |
| 3841 | ||
| 3842 | msgctxt "#90769" | |
| 3843 | msgid "Include H.264" | |
| 3844 | msgstr "Включать H.264" | |
| 3845 | ||
| 3846 | msgctxt "#90770" | |
| 3847 | msgid "Include Dolby Vision" | |
| 3848 | msgstr "Включать Dolby Vision" | |
| 3849 | ||
| 3850 | msgctxt "#90771" | |
| 3851 | msgid "Include HDR10 / HDR10+" | |
| 3852 | msgstr "Включать HDR10 / HDR10+" | |
| 3853 | ||
| 3854 | msgctxt "#90772" | |
| 3855 | msgid "Include HDR (generic)" | |
| 3856 | msgstr "Включать HDR (общий)" | |
| 3857 | ||
| 3858 | msgctxt "#90774" | |
| 3859 | msgid "Organize downloads by type" | |
| 3860 | msgstr "Organizovat zagruzki po tipu" | |
| 3861 | ||
| 3862 | msgctxt "#90775" | |
| 3863 | msgid "Movies folder name" | |
| 3864 | msgstr "Imya papki filmov" | |
| 3865 | ||
| 3866 | msgctxt "#90776" | |
| 3867 | msgid "TV Shows folder name" | |
| 3868 | msgstr "Imya papki serialov" | |
| 3869 | ||
| 3870 | msgctxt "#90777" | |
| 3871 | msgid "Pause" | |
| 3872 | msgstr "Pauza" | |
| 3873 | ||
| 3874 | msgctxt "#90778" | |
| 3875 | msgid "Resume" | |
| 3876 | msgstr "Vozobnovit" | |
| 3877 | ||
| 3878 | msgctxt "#90779" | |
| 3879 | msgid "Downloading" | |
| 3880 | msgstr "Zagruzka" | |
| 3881 | ||
| 3882 | msgctxt "#90780" | |
| 3883 | msgid "Paused" | |
| 3884 | msgstr "Na pauze" | |
| 3885 | ||
| 3886 | msgctxt "#90781" | |
| 3887 | msgid "Completed" | |
| 3888 | msgstr "Zaversheno" | |
| 3889 | ||
| 3890 | msgctxt "#90782" | |
| 3891 | msgid "Delete" | |
| 3892 | msgstr "Udalit" | |
| 3893 | ||
| 3894 | msgctxt "#90783" | |
| 3895 | msgid "Startup" | |
| 3896 | msgstr "Zapusk" | |
| 3897 | ||
| 3898 | msgctxt "#90784" | |
| 3899 | msgid "Auto-start Jacktook on Kodi boot" | |
| 3900 | msgstr "Avtozapusk Jacktook pri starte Kodi" | |
| 3901 | ||
| 3902 | msgctxt "#90785" | |
| 3903 | msgid "Automatically open Jacktook when Kodi starts" | |
| 3904 | msgstr "Avtomaticheski otkryvat Jacktook pri zapuske Kodi" | |
| 3905 | ||
| 3906 | msgctxt "#90786" | |
| 3907 | msgid "Widgets" | |
| 3908 | msgstr "Vidzhety" | |
| 3909 | ||
| 3910 | msgctxt "#90787" | |
| 3911 | msgid "Widget refresh interval (minutes)" | |
| 3912 | msgstr "Interval obnovleniya vidzhetov (minuty)" | |
| 3913 | ||
| 3914 | msgctxt "#90788" | |
| 3915 | msgid "Set to 0 to disable automatic widget refresh" | |
| 3916 | msgstr "Ustanovite 0, chtoby otklyuchit avtoobnovleniye" | |
| 3917 | ||
| 3918 | msgctxt "#90789" | |
| 3919 | msgid "Show refresh notification" | |
| 3920 | msgstr "Pokazyvat uvedomleniye ob obnovlenii" | |
| 3921 | ||
| 3922 | msgctxt "#90790" | |
| 3923 | msgid "Display a notification when widgets are refreshed" | |
| 3924 | msgstr "Otobrazhat uvedomleniye pri obnovlenii vidzhetov" | |
| 3925 | ||
| 3926 | msgctxt "#90791" | |
| 3927 | msgid "Download to Device" | |
| 3928 | msgstr "Zagruzit na ustroystvo" | |
| 3929 | ||
| 3930 | msgctxt "#90792" | |
| 3931 | msgid "Download Manager" | |
| 3932 | msgstr "Menedzher zagruzok" | |
| 3933 | ||
| 3934 | msgctxt "#90793" | |
| 3935 | msgid "No active downloads" | |
| 3936 | msgstr "Net aktivnykh zagruzok" | |
| 3937 | ||
| 3938 | msgctxt "#90794" | |
| 3939 | msgid "Progress" | |
| 3940 | msgstr "Khod vypolneniya" | |
| 3941 | ||
| 3942 | msgctxt "#90795" | |
| 3943 | msgid "Speed" | |
| 3944 | msgstr "Skorost" | |
| 3945 | ||
| 3946 | msgctxt "#90796" | |
| 3947 | msgid "ETA" | |
| 3948 | msgstr "Ostalos" | |
| 3949 | ||
| 3950 | msgctxt "#90797" | |
| 3951 | msgid "Size" | |
| 3952 | msgstr "Razmer" | |
| 3953 | ||
| 3954 | msgctxt "#90798" | |
| 3955 | msgid "Pause" | |
| 3956 | msgstr "Pauza" | |
| 3957 | ||
| 3958 | msgctxt "#90799" | |
| 3959 | msgid "Resume" | |
| 3960 | msgstr "Vozobnovit" | |
| 3961 | ||
| 3962 | msgctxt "#90800" | |
| 3963 | msgid "Cancel" | |
| 3964 | msgstr "Otmena" | |
| 3965 | ||
| 3966 | msgctxt "#90801" | |
| 3967 | msgid "Delete" | |
| 3968 | msgstr "Udalit" | |
| 3969 | ||
| 3970 | msgctxt "#90802" | |
| 3971 | msgid "Clear Completed" | |
| 3972 | msgstr "Ochistit zavershennye" | |
| 3973 | ||
| 3974 | msgctxt "#90803" | |
| 3975 | msgid "Download not yet completed" | |
| 3976 | msgstr "Zagruzka yeshchyo ne zavershena" | |
| 3977 | ||
| 3978 | msgctxt "#90804" | |
| 3979 | msgid "File not found" | |
| 3980 | msgstr "Fayl ne nayden" | |
| 3981 | ||
| 3982 | msgctxt "#90805" | |
| 3983 | msgid "Download Manager" | |
| 3984 | msgstr "Menedzher zagruzok" | |
| 3985 | ||
| 3986 | msgctxt "#90806" | |
| 3987 | msgid "My Downloads" | |
| 3988 | msgstr "Moi zagruzki" | |
| 3989 | ||
| 3990 | msgctxt "#90807" | |
| 3991 | msgid "Connecting..." | |
| 3992 | msgstr "Podklyucheniye..." | |
| 3993 | ||
| 3994 | msgctxt "#90808" | |
| 3995 | msgid "Close" | |
| 3996 | msgstr "Zakryt" | |
| 3997 | ||
| 3998 | msgctxt "#90809" | |
| 3999 | msgid "Delete download?" | |
| 4000 | msgstr "Udalit zagruzku?" | |
| 4001 | ||
| 4002 | msgctxt "#90810" | |
| 4003 | msgid "This will permanently delete the downloaded file. Are you sure?" | |
| 4004 | msgstr "Zagruzhennyy fayl budet udalyon navsegda. Vy uvereny?" | |
| 4005 | ||
| 4006 | msgctxt "#90811" | |
| 4007 | msgid "Completed" | |
| 4008 | msgstr "Zaversheno" | |
| 4009 | ||
| 4010 | msgctxt "#90812" | |
| 4011 | msgid "Excluded TMDB Languages" | |
| 4012 | msgstr "Isklyuchennye yazyki TMDB" | |
| 4013 | ||
| 4014 | msgctxt "#90813" | |
| 4015 | msgid "Select languages to exclude" | |
| 4016 | msgstr "Vyberite yazyki dlya isklyucheniya" | |
| 4017 | ||
| 4018 | msgctxt "#90814" | |
| 4019 | msgid "Choose original languages to hide from TMDB listings" | |
| 4020 | msgstr "Vyberite original'nye yazyki dlya skrytiya iz spiskov TMDB" | |
| 4021 | ||
| 4022 | msgctxt "#90815" | |
| 4023 | msgid "Support Jacktook" | |
| 4024 | msgstr "Поддержать Jacktook" | |
| 4025 | ||
| 4026 | msgctxt "#90816" | |
| 4027 | msgid "Support Jacktook development with an optional Ko-fi donation." | |
| 4028 | msgstr "Поддержите разработку Jacktook необязательным пожертвованием на Ko-fi." | |
| 4029 | ||
| 4030 | msgctxt "#90817" | |
| 4031 | msgid "Scan the QR code or open this link:" | |
| 4032 | msgstr "Отсканируйте QR-код или откройте эту ссылку:" | |
| 4033 | ||
| 4034 | msgctxt "#90818" | |
| 4035 | msgid "Jacktook was updated with new improvements. Donations are optional, but they help keep development active. Would you like to see the support link?" | |
| 4036 | msgstr "Jacktook был обновлен с новыми улучшениями. Пожертвования необязательны, но помогают поддерживать разработку. Хотите увидеть ссылку поддержки?" | |
| 4037 | ||
| 4038 | msgctxt "#90300" | |
| 4039 | msgid "External Scraper" | |
| 4040 | msgstr "Magneto" | |
| 4041 | ||
| 4042 | msgctxt "#90301" | |
| 4043 | msgid "Enable external scraper module integration." | |
| 4044 | msgstr "Включить интеграцию внешнего модуля scraper." | |
| 4045 | ||
| 4046 | msgctxt "#90302" | |
| 4047 | msgid "Choose External Scraper Module" | |
| 4048 | msgstr "Выбрать внешний модуль scraper" | |
| 4049 | ||
| 4050 | msgctxt "#90303" | |
| 4051 | msgid "Select an external scraper addon module to use for torrent search." | |
| 4052 | msgstr "Выберите внешний модуль scraper addon для поиска торрентов." | |
| 4053 | ||
| 4054 | msgctxt "#90304" | |
| 4055 | msgid "Choose External Scraper" | |
| 4056 | msgstr "Выбрать внешний scraper" | |
| 4057 | ||
| 4058 | msgctxt "#90305" | |
| 4059 | msgid "Selected Module" | |
| 4060 | msgstr "Выбранный модуль" | |
| 4061 | ||
| 4062 | msgctxt "#90306" | |
| 4063 | msgid "Open External Scraper Settings" | |
| 4064 | msgstr "Открыть настройки внешнего scraper" | |
| 4065 | ||
| 4066 | msgctxt "#90307" | |
| 4067 | msgid "Open the settings for the selected external scraper module." | |
| 4068 | msgstr "Открыть настройки выбранного внешнего модуля scraper." | |
| 4069 | ||
| 4070 | msgctxt "#90382" | |
| 4071 | msgid "Please set a valid Trakt Client ID Key" | |
| 4072 | msgstr "Укажите действительный ключ Client ID Trakt" | |
| 4073 | ||
| 4074 | msgctxt "#90383" | |
| 4075 | msgid "Please set a valid Trakt Client Secret Key" | |
| 4076 | msgstr "Укажите действительный секретный ключ Client Secret Trakt" | |
| 4077 | ||
| 4078 | msgctxt "#90384" | |
| 4079 | msgid "Trakt Error Authorizing" | |
| 4080 | msgstr "Ошибка авторизации Trakt" | |
| 4081 | ||
| 4082 | msgctxt "#90385" | |
| 4083 | msgid "Trakt Account Authorized" | |
| 4084 | msgstr "Аккаунт Trakt авторизован" | |
| 4085 | ||
| 4086 | msgctxt "#90386" | |
| 4087 | msgid "You are now logged out from Trakt.tv" | |
| 4088 | msgstr "Вы вышли из Trakt.tv" | |
| 4089 | ||
| 4090 | msgctxt "#90387" | |
| 4091 | msgid "Failed to mark as watched" | |
| 4092 | msgstr "Не удалось отметить как просмотренное" | |
| 4093 | ||
| 4094 | msgctxt "#90388" | |
| 4095 | msgid "Marked as watched" | |
| 4096 | msgstr "Отмечено как просмотренное" | |
| 4097 | ||
| 4098 | msgctxt "#90389" | |
| 4099 | msgid "No results found" | |
| 4100 | msgstr "Результаты не найдены" | |
| 4101 | ||
| 4102 | msgctxt "#90390" | |
| 4103 | msgid "Invalid mode" | |
| 4104 | msgstr "Недопустимый режим" | |
| 4105 | ||
| 4106 | msgctxt "#90391" | |
| 4107 | msgid "No recommendations found" | |
| 4108 | msgstr "Рекомендации не найдены" | |
| 4109 | ||
| 4110 | msgctxt "#90392" | |
| 4111 | msgid "No similar items found" | |
| 4112 | msgstr "Похожие элементы не найдены" | |
| 4113 | ||
| 4114 | msgctxt "#90393" | |
| 4115 | msgid "TMDB metadata not found" | |
| 4116 | msgstr "Метаданные TMDB не найдены" | |
| 4117 | ||
| 4118 | msgctxt "#90394" | |
| 4119 | msgid "No TV shows found" | |
| 4120 | msgstr "Сериалы не найдены" | |
| 4121 | ||
| 4122 | msgctxt "#90395" | |
| 4123 | msgid "No shows airing today" | |
| 4124 | msgstr "Сегодня нет выходящих серий" | |
| 4125 | ||
| 4126 | msgctxt "#90396" | |
| 4127 | msgid "Invalid collection query" | |
| 4128 | msgstr "Недопустимый запрос коллекции" | |
| 4129 | ||
| 4130 | msgctxt "#90397" | |
| 4131 | msgid "No keywords found" | |
| 4132 | msgstr "Ключевые слова не найдены" | |
| 4133 | ||
| 4134 | msgctxt "#90398" | |
| 4135 | msgid "No keyword ID provided" | |
| 4136 | msgstr "ID ключевого слова не указан" | |
| 4137 | ||
| 4138 | msgctxt "#90399" | |
| 4139 | msgid "No results found for this keyword" | |
| 4140 | msgstr "Для этого ключевого слова результаты не найдены" | |
| 4141 | ||
| 4142 | msgctxt "#90400" | |
| 4143 | msgid "No TMDB ID found" | |
| 4144 | msgstr "ID TMDB не найден" | |
| 4145 | ||
| 4146 | msgctxt "#90401" | |
| 4147 | msgid "Unsupported media type or missing external_ids" | |
| 4148 | msgstr "Неподдерживаемый тип медиа или отсутствуют external_ids" | |
| 4149 | ||
| 4150 | msgctxt "#90402" | |
| 4151 | msgid "Refreshed Metadata" | |
| 4152 | msgstr "Метаданные обновлены" | |
| 4153 | ||
| 4154 | msgctxt "#90403" | |
| 4155 | msgid "No Stremio addons selected" | |
| 4156 | msgstr "Аддоны Stremio не выбраны" | |
| 4157 | ||
| 4158 | msgctxt "#90404" | |
| 4159 | msgid "No suitable source found for auto play." | |
| 4160 | msgstr "Подходящий источник для автовоспроизведения не найден." | |
| 4161 | ||
| 4162 | msgctxt "#90405" | |
| 4163 | msgid "No sources found with the preferred quality." | |
| 4164 | msgstr "Источники с предпочитаемым качеством не найдены." | |
| 4165 | ||
| 4166 | msgctxt "#90406" | |
| 4167 | msgid "No languages found" | |
| 4168 | msgstr "Языки не найдены" | |
| 4169 | ||
| 4170 | msgctxt "#90407" | |
| 4171 | msgid "Failed to resolve playback source" | |
| 4172 | msgstr "Не удалось определить источник воспроизведения" | |
| 4173 | ||
| 4174 | msgctxt "#90408" | |
| 4175 | msgid "Trakt not authorized. Please authenticate." | |
| 4176 | msgstr "Trakt не авторизован. Выполните аутентификацию." | |
| 4177 | ||
| 4178 | msgctxt "#90409" | |
| 4179 | msgid "Added to Trakt Watchlist" | |
| 4180 | msgstr "Добавлено в список просмотра Trakt" | |
| 4181 | ||
| 4182 | msgctxt "#90428" | |
| 4183 | msgid "Database read error. File may be corrupted." | |
| 4184 | msgstr "Ошибка чтения базы данных. Файл может быть повреждён." | |
| 4185 | ||
| 4186 | msgctxt "#90429" | |
| 4187 | msgid "Failed to delete data. Database error." | |
| 4188 | msgstr "Не удалось удалить данные. Ошибка базы данных." | |
| 4189 | ||
| 4190 | msgctxt "#90430" | |
| 4191 | msgid "Failed to clear cache. Database in use." | |
| 4192 | msgstr "Не удалось очистить кэш. База данных используется." | |
| 4193 | ||
| 4194 | msgctxt "#90458" | |
| 4195 | msgid "OpenSubtitles Stremio: No results found." | |
| 4196 | msgstr "OpenSubtitles Stremio: результаты не найдены." | |
| 4197 | ||
| 4198 | msgctxt "#90460" | |
| 4199 | msgid "Subtitle Manager: No subtitles available for this video." | |
| 4200 | msgstr "Менеджер субтитров: для этого видео субтитры недоступны." | |
| 4201 | ||
| 4202 | msgctxt "#90468" | |
| 4203 | msgid "Search menu" | |
| 4204 | msgstr "Меню поиска" | |
| 4205 | ||
| 4206 | msgctxt "#90469" | |
| 4207 | msgid "Continue watching menu" | |
| 4208 | msgstr "Меню продолжения просмотра" | |
| 4209 | ||
| 4210 | msgctxt "#90470" | |
| 4211 | msgid "TV Shows Items" | |
| 4212 | msgstr "Элементы сериалов" | |
| 4213 | ||
| 4214 | msgctxt "#90471" | |
| 4215 | msgid "Movies Items" | |
| 4216 | msgstr "Элементы фильмов" | |
| 4217 | ||
| 4218 | msgctxt "#90472" | |
| 4219 | msgid "Anime menu" | |
| 4220 | msgstr "Меню аниме" | |
| 4221 | ||
| 4222 | msgctxt "#90473" | |
| 4223 | msgid "TV Menu" | |
| 4224 | msgstr "Меню ТВ" | |
| 4225 | ||
| 4226 | msgctxt "#90474" | |
| 4227 | msgid "Library menu" | |
| 4228 | msgstr "Меню библиотеки" | |
| 4229 | ||
| 4230 | msgctxt "#90475" | |
| 4231 | msgid "Torrents Management" | |
| 4232 | msgstr "Управление торрентами" | |
| 4233 | ||
| 4234 | msgctxt "#90476" | |
| 4235 | msgid "Telegram menu" | |
| 4236 | msgstr "Меню Telegram" | |
| 4237 | ||
| 4238 | msgctxt "#90477" | |
| 4239 | msgid "Cloud Storage" | |
| 4240 | msgstr "Облачное хранилище" | |
| 4241 | ||
| 4242 | msgctxt "#90478" | |
| 4243 | msgid "Downloads Management" | |
| 4244 | msgstr "Управление загрузками" | |
| 4245 | ||
| 4246 | msgctxt "#90479" | |
| 4247 | msgid "Settings Management" | |
| 4248 | msgstr "Управление настройками" | |
| 4249 | ||
| 4250 | msgctxt "#90480" | |
| 4251 | msgid "History Menu" | |
| 4252 | msgstr "Меню истории" | |
| 4253 | ||
| 4254 | msgctxt "#90481" | |
| 4255 | msgid "Files History" | |
| 4256 | msgstr "История файлов" | |
| 4257 | ||
| 4258 | msgctxt "#90482" | |
| 4259 | msgid "Titles History" | |
| 4260 | msgstr "История названий" | |
| 4261 | ||
| 4262 | msgctxt "#90483" | |
| 4263 | msgid "Titles Calendar" | |
| 4264 | msgstr "Календарь названий" | |
| 4265 | ||
| 4266 | msgctxt "#90484" | |
| 4267 | msgid "My Shows" | |
| 4268 | msgstr "Мои сериалы" | |
| 4269 | ||
| 4270 | msgctxt "#90485" | |
| 4271 | msgid "My Movies" | |
| 4272 | msgstr "Мои фильмы" | |
| 4273 | ||
| 4274 | msgctxt "#90486" | |
| 4275 | msgid "Upcoming Episodes" | |
| 4276 | msgstr "Предстоящие эпизоды" | |
| 4277 | ||
| 4278 | msgctxt "#90487" | |
| 4279 | msgid "Discovery List" | |
| 4280 | msgstr "Список открытий" | |
| 4281 | ||
| 4282 | msgctxt "#90488" | |
| 4283 | msgid "Library List" | |
| 4284 | msgstr "Список библиотеки" | |
| 4285 | ||
| 4286 | msgctxt "#90489" | |
| 4287 | msgid "Trakt Recommendations" | |
| 4288 | msgstr "Рекомендации Trakt" | |
| 4289 | ||
| 4290 | msgctxt "#90490" | |
| 4291 | msgid "Trending Lists" | |
| 4292 | msgstr "Популярные сейчас списки" | |
| 4293 | ||
| 4294 | msgctxt "#90491" | |
| 4295 | msgid "Popular Lists" | |
| 4296 | msgstr "Популярные списки" | |
| 4297 | ||
| 4298 | msgctxt "#90492" | |
| 4299 | msgid "Search Lists" | |
| 4300 | msgstr "Поиск списков" | |
| 4301 | ||
| 4302 | msgctxt "#90493" | |
| 4303 | msgid "Recent Activity" | |
| 4304 | msgstr "Недавняя активность" | |
| 4305 | ||
| 4306 | msgctxt "#90494" | |
| 4307 | msgid "Downloads List" | |
| 4308 | msgstr "Список загрузок" | |
| 4309 | ||
| 4310 | msgctxt "#90495" | |
| 4311 | msgid "Download New" | |
| 4312 | msgstr "Новая загрузка" | |
| 4313 | ||
| 4314 | msgctxt "#90496" | |
| 4315 | msgid "Cartoons Popular" | |
| 4316 | msgstr "Популярные мультфильмы" | |
| 4317 | ||
| 4318 | msgctxt "#90497" | |
| 4319 | msgid "Animation Popular" | |
| 4320 | msgstr "Популярная анимация" | |
| 4321 | ||
| 4322 | msgctxt "#90500" | |
| 4323 | msgid "Translating subtitle..." | |
| 4324 | msgstr "Перевод субтитров..." | |
| 4325 | ||
| 4326 | msgctxt "#90501" | |
| 4327 | msgid "Translation done." | |
| 4328 | msgstr "Перевод завершён." | |
| 4329 | ||
| 4330 | msgctxt "#90502" | |
| 4331 | msgid "Searching subtitles..." | |
| 4332 | msgstr "Поиск субтитров..." | |
| 4333 | ||
| 4334 | msgctxt "#90503" | |
| 4335 | msgid "No subtitles found" | |
| 4336 | msgstr "Субтитры не найдены" | |
| 4337 | ||
| 4338 | msgctxt "#90504" | |
| 4339 | msgid "Failed to authenticate with DeepL. Please check your API key." | |
| 4340 | msgstr "Не удалось пройти аутентификацию в DeepL. Проверьте ключ API." | |
| 4341 | ||
| 4342 | msgctxt "#90505" | |
| 4343 | msgid "DeepL API key is missing or invalid." | |
| 4344 | msgstr "Ключ API DeepL отсутствует или недействителен." | |
| 4345 | ||
| 4346 | msgctxt "#90712" | |
| 4347 | msgid "Views" | |
| 4348 | msgstr "Виды" | |
| 4349 | ||
| 4350 | msgctxt "#90713" | |
| 4351 | msgid "Set Main View" | |
| 4352 | msgstr "Задать основной вид" | |
| 4353 | ||
| 4354 | msgctxt "#90714" | |
| 4355 | msgid "Set Movies View" | |
| 4356 | msgstr "Задать вид фильмов" | |
| 4357 | ||
| 4358 | msgctxt "#90715" | |
| 4359 | msgid "Set TV Shows View" | |
| 4360 | msgstr "Задать вид сериалов" | |
| 4361 | ||
| 4362 | msgctxt "#90716" | |
| 4363 | msgid "Set Seasons View" | |
| 4364 | msgstr "Задать вид сезонов" | |
| 4365 | ||
| 4366 | msgctxt "#90717" | |
| 4367 | msgid "Set Episodes View" | |
| 4368 | msgstr "Задать вид эпизодов" | |
| 4369 | ||
| 4370 | msgctxt "#90718" | |
| 4371 | msgid "Set Library View" | |
| 4372 | msgstr "Задать вид библиотеки" | |
| 4373 | ||
| 4374 | msgctxt "#90719" | |
| 4375 | msgid "Set History View" | |
| 4376 | msgstr "Задать вид истории" | |
| 4377 | ||
| 4378 | msgctxt "#90720" | |
| 4379 | msgid "Set Downloads View" | |
| 4380 | msgstr "Задать вид загрузок" | |
| 4381 | ||
| 4382 | msgctxt "#90721" | |
| 4383 | msgid "Save Current View" | |
| 4384 | msgstr "Сохранить текущий вид" | |
| 4385 | ||
| 4386 | msgctxt "#90722" | |
| 4387 | msgid "View saved" | |
| 4388 | msgstr "Вид сохранён" | |
| 4389 | ||
| 4390 | msgctxt "#90723" | |
| 4391 | msgid "Could not detect the current view" | |
| 4392 | msgstr "Не удалось определить текущий вид" | |
| 4393 | ||
| 4394 | msgctxt "#90724" | |
| 4395 | msgid "Saved views reset" | |
| 4396 | msgstr "Сохранённые виды сброшены" | |
| 4397 | ||
| 4398 | msgctxt "#90725" | |
| 4399 | msgid "Reset Saved Views" | |
| 4400 | msgstr "Сбросить сохранённые виды" | |
| 4401 | ||
| 4402 | msgctxt "#90726" | |
| 4403 | msgid "Open this screen, change the Kodi view manually, then select Save Current View." | |
| 4404 | msgstr "Откройте этот экран, вручную измените вид Kodi, затем выберите Сохранить текущий вид." | |
| 4405 | ||
| 4406 | msgctxt "#90730" | |
| 4407 | msgid "Search with title + year" | |
| 4408 | msgstr "Искать по названию + году" | |
| 4409 | ||
| 4410 | msgctxt "#90731" | |
| 4411 | msgid "Search with original title" | |
| 4412 | msgstr "Искать по оригинальному названию" | |
| 4413 | ||
| 4414 | msgctxt "#90732" | |
| 4415 | msgid "Search with original title + year" | |
| 4416 | msgstr "Искать по оригинальному названию + году" | |
| 4417 | ||
| 4418 | msgctxt "#90733" | |
| 4419 | msgid "Search modes" | |
| 4420 | msgstr "Режимы поиска" | |
| 4421 | ||
| 4422 | msgctxt "#90734" | |
| 4423 | msgid "Enter year" | |
| 4424 | msgstr "Введите год" | |
| 4425 | ||
| 4426 | msgctxt "#90735" | |
| 4427 | msgid "Invalid year" | |
| 4428 | msgstr "Недопустимый год" | |
| 4429 | ||
| 4430 | msgctxt "#90736" | |
| 4431 | msgid "Edit title" | |
| 4432 | msgstr "Редактировать название" | |
| 4433 | ||
| 4434 | msgctxt "#90737" | |
| 4435 | msgid "Invalid season" | |
| 4436 | msgstr "Недопустимый сезон" | |
| 4437 | ||
| 4438 | msgctxt "#90738" | |
| 4439 | msgid "Episode" | |
| 4440 | msgstr "Эпизод" | |
| 4441 | ||
| 4442 | msgctxt "#90739" | |
| 4443 | msgid "Invalid episode" | |
| 4444 | msgstr "Недопустимый эпизод" | |
| 4445 | ||
| 4446 | msgctxt "#90819" | |
| 4447 | msgid "Rename Stremio add-on" | |
| 4448 | msgstr "Переименовать дополнение Stremio" | |
| 4449 | ||
| 4450 | msgctxt "#90824" | |
| 4451 | msgid "Rename Stremio catalog" | |
| 4452 | msgstr "Переименовать каталог Stremio" | |
| 4453 | ||
| 4454 | msgctxt "#90827" | |
| 4455 | msgid "Set a Kodi-only display alias for a Stremio add-on." | |
| 4456 | msgstr "Задать отображаемый псевдоним только в Kodi для дополнения Stremio." | |
| 4457 | ||
| 4458 | msgctxt "#90828" | |
| 4459 | msgid "Set a Kodi-only display alias for a Stremio catalog." | |
| 4460 | msgstr "Задать отображаемый псевдоним только в Kodi для каталога Stremio." | |
| 4461 | ||
| 4462 | msgctxt "#90832" | |
| 4463 | msgid "Stremio: Addons Management" | |
| 4464 | msgstr "Stremio: Управление дополнениями" | |
| 4465 | ||
| 4466 | msgctxt "#90833" | |
| 4467 | msgid "Stremio: Catalogs & Visibility" | |
| 4468 | msgstr "Stremio: Каталоги и видимость" | |
| 4469 | ||
| 4470 | msgctxt "#90834" | |
| 4471 | msgid "Stremio: Rename & Customization" | |
| 4472 | msgstr "Stremio: Переименование и настройка" | |
| 4473 | ||
| 4474 | msgctxt "#90835" | |
| 4475 | msgid "Stremio: Search & Behavior" | |
| 4476 | msgstr "Stremio: Поиск и поведение" |
| … | … | @@ -312,8 +312,8 @@ msgid "Unknown" |
| 312 | 312 | msgstr "Desconocido" |
| 313 | 313 | |
| 314 | 314 | msgctxt "#30700" |
| 315 | msgid "Buffer and play" | |
| 316 | msgstr "Buffer y reproducir" | |
| 315 | msgid "Play Torrent" | |
| 316 | msgstr "Reproducir torrent" | |
| 317 | 317 | |
| 318 | 318 | msgctxt "#30701" |
| 319 | 319 | msgid "Stop downloading" |
| … | … | @@ -431,6 +431,58 @@ msgctxt "#30939" |
| 431 | 431 | msgid "Auto-resume from last position without asking" |
| 432 | 432 | msgstr "Reanudar automáticamente desde la última posición sin preguntar" |
| 433 | 433 | |
| 434 | msgctxt "#30940" | |
| 435 | msgid "Use Percentage Trigger" | |
| 436 | msgstr "Usar disparo por porcentaje" | |
| 437 | ||
| 438 | msgctxt "#30941" | |
| 439 | msgid "Show Dialog at Playback Percentage" | |
| 440 | msgstr "Mostrar diálogo al porcentaje de reproducción" | |
| 441 | ||
| 442 | msgctxt "#30942" | |
| 443 | msgid "Trigger the next-episode dialog based on percentage of video played instead of seconds remaining." | |
| 444 | msgstr "Activar el diálogo del siguiente episodio basado en el porcentaje de video reproducido en lugar de los segundos restantes." | |
| 445 | ||
| 446 | msgctxt "#30943" | |
| 447 | msgid "The percentage at which the next-episode dialog appears (e.g. 95 means 95% of the video)." | |
| 448 | msgstr "El porcentaje en el que aparece el diálogo del siguiente episodio (ej. 95 significa 95% del video)." | |
| 449 | ||
| 450 | msgctxt "#30944" | |
| 451 | msgid "Auto-Play Timeout" | |
| 452 | msgstr "Tiempo de espera de auto-reproducción" | |
| 453 | ||
| 454 | msgctxt "#30945" | |
| 455 | msgid "Seconds before auto-playing next episode. 0 = disabled, dialog waits for user input." | |
| 456 | msgstr "Segundos antes de reproducir automáticamente el siguiente episodio. 0 = desactivado, el diálogo espera la entrada del usuario." | |
| 457 | ||
| 458 | ||
| 459 | msgctxt "#30946" | |
| 460 | msgid "Offcloud Configuration" | |
| 461 | msgstr "Configuración de Offcloud" | |
| 462 | ||
| 463 | msgctxt "#30947" | |
| 464 | msgid "Enable Offcloud cached torrent and cloud transfer support." | |
| 465 | msgstr "Habilitar soporte de torrents en caché y transferencias en la nube de Offcloud." | |
| 466 | ||
| 467 | msgctxt "#30948" | |
| 468 | msgid "Your Offcloud account username/email." | |
| 469 | msgstr "Tu nombre de usuario o correo electrónico de Offcloud." | |
| 470 | ||
| 471 | msgctxt "#30949" | |
| 472 | msgid "Offcloud API key from Account API Key settings." | |
| 473 | msgstr "Clave API de Offcloud desde la configuración de Cuenta → Clave API." | |
| 474 | ||
| 475 | msgctxt "#30950" | |
| 476 | msgid "View Offcloud account information." | |
| 477 | msgstr "Ver información de la cuenta de Offcloud." | |
| 478 | ||
| 479 | msgctxt "#30951" | |
| 480 | msgid "Authorize add-on to access Offcloud account." | |
| 481 | msgstr "Autorizar el complemento para acceder a la cuenta de Offcloud." | |
| 482 | ||
| 483 | msgctxt "#30952" | |
| 484 | msgid "Revoke Offcloud account access." | |
| 485 | msgstr "Revocar el acceso de la cuenta de Offcloud." | |
| 434 | 486 | msgctxt "#30728" |
| 435 | 487 | msgid "Seeds" |
| 436 | 488 | msgstr "Seeds" |
| … | … | @@ -459,6 +511,10 @@ msgctxt "#30734" |
| 459 | 511 | msgid "Enable" |
| 460 | 512 | msgstr "Habilitar" |
| 461 | 513 | |
| 514 | msgctxt "#30735" | |
| 515 | msgid "Still Watching? Threshold" | |
| 516 | msgstr "¿Siguen viendo? Límite" | |
| 517 | ||
| 462 | 518 | msgctxt "#30736" |
| 463 | 519 | msgid "Subtitle Language" |
| 464 | 520 | msgstr "Idioma prioritario" |
| … | … | @@ -607,6 +663,10 @@ msgctxt "#30772" |
| 607 | 663 | msgid "Zilean Host" |
| 608 | 664 | msgstr "Host de Zilean" |
| 609 | 665 | |
| 666 | msgctxt "#30773" | |
| 667 | msgid "Number of consecutive auto-played episodes before asking 'Still Watching?'. Set to 0 to disable." | |
| 668 | msgstr "Número de episodios reproducidos automáticamente antes de preguntar '¿Siguen viendo?'. Pon 0 para desactivar." | |
| 669 | ||
| 610 | 670 | msgctxt "#30774" |
| 611 | 671 | msgid "Zilean Timeout" |
| 612 | 672 | msgstr "Tiempo de espera de Zilean" |
| … | … | @@ -1556,8 +1616,8 @@ msgid "TV Calendar" |
| 1556 | 1616 | msgstr "Calendario de TV" |
| 1557 | 1617 | |
| 1558 | 1618 | msgctxt "#90022" |
| 1559 | msgid "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:" | |
| 1560 | msgstr "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:" | |
| 1619 | msgid "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." | |
| 1620 | msgstr "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." | |
| 1561 | 1621 | |
| 1562 | 1622 | msgctxt "#90023" |
| 1563 | 1623 | msgid "Support Jacktook" |
| … | … | @@ -1797,7 +1857,7 @@ msgstr "Buscar Personas" |
| 1797 | 1857 | |
| 1798 | 1858 | msgctxt "#90082" |
| 1799 | 1859 | msgid "Download subtitles" |
| 1800 | msgstr "Descargar Subtitulos" | |
| 1860 | msgstr "Descargar subtítulos" | |
| 1801 | 1861 | |
| 1802 | 1862 | msgctxt "#90083" |
| 1803 | 1863 | msgid "Download video" |
| … | … | @@ -1991,21 +2051,37 @@ msgctxt "#90144" |
| 1991 | 2051 | msgid "Could not resolve playback URL" |
| 1992 | 2052 | msgstr "No se pudo resolver la URL de reproducción" |
| 1993 | 2053 | |
| 1994 | msgctxt "#90145" | |
| 1995 | msgid "Precaching Enabled" | |
| 1996 | msgstr "Precaching Habilitado" | |
| 2054 | msgctxt "#90171" | |
| 2055 | msgid "Autoscrape Next Episode" | |
| 2056 | msgstr "Autoscrape del Siguiente Episodio" | |
| 2057 | ||
| 2058 | msgctxt "#90172" | |
| 2059 | msgid "Pre-resolve the next episode's playback URL in the background while watching." | |
| 2060 | msgstr "Pre-resolver la URL de reproducción del siguiente episodio en segundo plano." | |
| 2061 | ||
| 2062 | msgctxt "#90173" | |
| 2063 | msgid "Autoscrape Threshold" | |
| 2064 | msgstr "Umbral de Autoscrape" | |
| 2065 | ||
| 2066 | msgctxt "#90174" | |
| 2067 | msgid "Percentage of playback to trigger background pre-resolution." | |
| 2068 | msgstr "Porcentaje de reproducción para disparar la pre-resolución en segundo plano." | |
| 1997 | 2069 | |
| 1998 | msgctxt "#90146" | |
| 1999 | msgid "Enable or disable precaching of next episodes." | |
| 2000 | msgstr "Habilitar o deshabilitar el precaching de los próximos episodios." | |
| 2070 | msgctxt "#90175" | |
| 2071 | msgid "Autoscrape Episode Count" | |
| 2072 | msgstr "Cantidad de Episodios a Autoscrapear" | |
| 2001 | 2073 | |
| 2002 | msgctxt "#90147" | |
| 2003 | msgid "Precaching Episode Count" | |
| 2004 | msgstr "Cantidad de Episodios a Precargar" | |
| 2074 | msgctxt "#90176" | |
| 2075 | msgid "Number of upcoming episodes to pre-resolve." | |
| 2076 | msgstr "Número de próximos episodios a pre-resolver." | |
| 2005 | 2077 | |
| 2006 | msgctxt "#90148" | |
| 2007 | msgid "Number of next episodes to precache." | |
| 2008 | msgstr "Número de próximos episodios a precargar." | |
| 2078 | msgctxt "#90177" | |
| 2079 | msgid "Autoscrape Cache TTL" | |
| 2080 | msgstr "TTL de Caché de Autoscrape" | |
| 2081 | ||
| 2082 | msgctxt "#90178" | |
| 2083 | msgid "Hours to keep resolved playback data in cache." | |
| 2084 | msgstr "Horas para mantener los datos de reproducción resueltos en caché." | |
| 2009 | 2085 | |
| 2010 | 2086 | msgctxt "#30901" |
| 2011 | 2087 | msgid "Show Only Stremio Catalogs" |
| … | … | @@ -2555,6 +2631,10 @@ msgctxt "#90298" |
| 2555 | 2631 | msgid "Liked Lists" |
| 2556 | 2632 | msgstr "Listas con Me gusta" |
| 2557 | 2633 | |
| 2634 | msgctxt "#90299" | |
| 2635 | msgid "No metadata found for this torrent" | |
| 2636 | msgstr "No se encontró metadata para este torrent" | |
| 2637 | ||
| 2558 | 2638 | msgctxt "#90331" |
| 2559 | 2639 | msgid "Stremio Addons" |
| 2560 | 2640 | msgstr "Complementos de Stremio" |
| … | … | @@ -3665,12 +3745,16 @@ msgid "Invalid episode" |
| 3665 | 3745 | msgstr "Episodio no válido" |
| 3666 | 3746 | |
| 3667 | 3747 | msgctxt "#90740" |
| 3668 | msgid "Edit current title" | |
| 3669 | msgstr "Editar título actual" | |
| 3748 | msgid "Search with current title" | |
| 3749 | msgstr "Buscar con título actual" | |
| 3670 | 3750 | |
| 3671 | 3751 | msgctxt "#90741" |
| 3672 | msgid "Use original title" | |
| 3673 | msgstr "Usar título original" | |
| 3752 | msgid "Search with original title" | |
| 3753 | msgstr "Buscar con título original" | |
| 3754 | ||
| 3755 | msgctxt "#90773" | |
| 3756 | msgid "Search full show with title + year" | |
| 3757 | msgstr "Buscar serie completa con título + año" | |
| 3674 | 3758 | |
| 3675 | 3759 | msgctxt "#90742" |
| 3676 | 3760 | msgid "Addon Settings" |
| … | … | @@ -3723,3 +3807,675 @@ msgstr "Solo inglés" |
| 3723 | 3807 | msgctxt "#90754" |
| 3724 | 3808 | msgid "Localized first" |
| 3725 | 3809 | msgstr "Localizado primero" |
| 3810 | ||
| 3811 | msgctxt "#90755" | |
| 3812 | msgid "Manage Sources" | |
| 3813 | msgstr "Gestionar Fuentes" | |
| 3814 | ||
| 3815 | msgctxt "#90756" | |
| 3816 | msgid "No sources available" | |
| 3817 | msgstr "No hay fuentes disponibles" | |
| 3818 | ||
| 3819 | msgctxt "#90179" | |
| 3820 | msgid "Stinger Notifications" | |
| 3821 | msgstr "Notificaciones de Escenas Post-créditos" | |
| 3822 | ||
| 3823 | msgctxt "#90180" | |
| 3824 | msgid "Show a notification when a movie has post-credit scenes." | |
| 3825 | msgstr "Mostrar una notificación cuando una película tenga escenas post-créditos." | |
| 3826 | ||
| 3827 | msgctxt "#90181" | |
| 3828 | msgid "Stinger Notification Time" | |
| 3829 | msgstr "Tiempo de Notificación Post-créditos" | |
| 3830 | ||
| 3831 | msgctxt "#90182" | |
| 3832 | msgid "Seconds before the end to show the stinger notification." | |
| 3833 | msgstr "Segundos antes del final para mostrar la notificación." | |
| 3834 | ||
| 3835 | msgctxt "#90183" | |
| 3836 | msgid "Stay tuned after the credits!" | |
| 3837 | msgstr "¡No te vayas, hay escenas post-créditos!" | |
| 3838 | ||
| 3839 | msgctxt "#90757" | |
| 3840 | msgid "Image Resolution" | |
| 3841 | msgstr "Resolución de Imagen" | |
| 3842 | ||
| 3843 | msgctxt "#90758" | |
| 3844 | msgid "Low" | |
| 3845 | msgstr "Baja" | |
| 3846 | ||
| 3847 | msgctxt "#90759" | |
| 3848 | msgid "Medium" | |
| 3849 | msgstr "Media" | |
| 3850 | ||
| 3851 | msgctxt "#90760" | |
| 3852 | msgid "High" | |
| 3853 | msgstr "Alta" | |
| 3854 | ||
| 3855 | msgctxt "#90761" | |
| 3856 | msgid "Original" | |
| 3857 | msgstr "Original" | |
| 3858 | ||
| 3859 | msgctxt "#90762" | |
| 3860 | msgid "Enable RPDB Posters" | |
| 3861 | msgstr "Habilitar Posters RPDB" | |
| 3862 | ||
| 3863 | msgctxt "#90763" | |
| 3864 | msgid "RPDB API Key" | |
| 3865 | msgstr "RPDB Clave API" | |
| 3866 | ||
| 3867 | msgctxt "#90764" | |
| 3868 | msgid "Get your API key at ratingposterdb.com" | |
| 3869 | msgstr "Obten tu clave API en ratingposterdb.com" | |
| 3870 | ||
| 3871 | msgctxt "#90765" | |
| 3872 | msgid "Filter by Codecs" | |
| 3873 | msgstr "Filtrado por Códecs" | |
| 3874 | ||
| 3875 | msgctxt "#90766" | |
| 3876 | msgid "Filter by HDR" | |
| 3877 | msgstr "Filtrar por HDR" | |
| 3878 | ||
| 3879 | msgctxt "#90767" | |
| 3880 | msgid "Include HEVC / H.265" | |
| 3881 | msgstr "Incluir HEVC / H.265" | |
| 3882 | ||
| 3883 | msgctxt "#90768" | |
| 3884 | msgid "Include AV1" | |
| 3885 | msgstr "Incluir AV1" | |
| 3886 | ||
| 3887 | msgctxt "#90769" | |
| 3888 | msgid "Include H.264" | |
| 3889 | msgstr "Incluir H.264" | |
| 3890 | ||
| 3891 | msgctxt "#90770" | |
| 3892 | msgid "Include Dolby Vision" | |
| 3893 | msgstr "Incluir Dolby Vision" | |
| 3894 | ||
| 3895 | msgctxt "#90771" | |
| 3896 | msgid "Include HDR10 / HDR10+" | |
| 3897 | msgstr "Incluir HDR10 / HDR10+" | |
| 3898 | ||
| 3899 | msgctxt "#90772" | |
| 3900 | msgid "Include HDR (generic)" | |
| 3901 | msgstr "Incluir HDR (genérico)" | |
| 3902 | ||
| 3903 | msgctxt "#90774" | |
| 3904 | msgid "Organize downloads by type" | |
| 3905 | msgstr "Organizar descargas por tipo" | |
| 3906 | ||
| 3907 | msgctxt "#90775" | |
| 3908 | msgid "Movies folder name" | |
| 3909 | msgstr "Nombre de carpeta de peliculas" | |
| 3910 | ||
| 3911 | msgctxt "#90776" | |
| 3912 | msgid "TV Shows folder name" | |
| 3913 | msgstr "Nombre de carpeta de series" | |
| 3914 | ||
| 3915 | msgctxt "#90777" | |
| 3916 | msgid "Pause" | |
| 3917 | msgstr "Pausar" | |
| 3918 | ||
| 3919 | msgctxt "#90778" | |
| 3920 | msgid "Resume" | |
| 3921 | msgstr "Reanudar" | |
| 3922 | ||
| 3923 | msgctxt "#90779" | |
| 3924 | msgid "Downloading" | |
| 3925 | msgstr "Descargando" | |
| 3926 | ||
| 3927 | msgctxt "#90780" | |
| 3928 | msgid "Paused" | |
| 3929 | msgstr "Pausado" | |
| 3930 | ||
| 3931 | msgctxt "#90781" | |
| 3932 | msgid "Completed" | |
| 3933 | msgstr "Completado" | |
| 3934 | ||
| 3935 | msgctxt "#90782" | |
| 3936 | msgid "Delete" | |
| 3937 | msgstr "Eliminar" | |
| 3938 | ||
| 3939 | msgctxt "#90783" | |
| 3940 | msgid "Startup" | |
| 3941 | msgstr "Inicio" | |
| 3942 | ||
| 3943 | msgctxt "#90784" | |
| 3944 | msgid "Auto-start Jacktook on Kodi boot" | |
| 3945 | msgstr "Iniciar Jacktook automáticamente al arrancar Kodi" | |
| 3946 | ||
| 3947 | msgctxt "#90785" | |
| 3948 | msgid "Automatically open Jacktook when Kodi starts" | |
| 3949 | msgstr "Abrir Jacktook automáticamente cuando Kodi se inicia" | |
| 3950 | ||
| 3951 | msgctxt "#90786" | |
| 3952 | msgid "Widgets" | |
| 3953 | msgstr "Widgets" | |
| 3954 | ||
| 3955 | msgctxt "#90787" | |
| 3956 | msgid "Widget refresh interval (minutes)" | |
| 3957 | msgstr "Intervalo de actualización de widgets (minutos)" | |
| 3958 | ||
| 3959 | msgctxt "#90788" | |
| 3960 | msgid "Set to 0 to disable automatic widget refresh" | |
| 3961 | msgstr "Establecer en 0 para desactivar la actualización automática" | |
| 3962 | ||
| 3963 | msgctxt "#90789" | |
| 3964 | msgid "Show refresh notification" | |
| 3965 | msgstr "Mostrar notificación de actualización" | |
| 3966 | ||
| 3967 | msgctxt "#90790" | |
| 3968 | msgid "Display a notification when widgets are refreshed" | |
| 3969 | msgstr "Mostrar una notificación cuando se actualicen los widgets" | |
| 3970 | ||
| 3971 | msgctxt "#90791" | |
| 3972 | msgid "Download to Device" | |
| 3973 | msgstr "Descargar al dispositivo" | |
| 3974 | ||
| 3975 | msgctxt "#90792" | |
| 3976 | msgid "Download Manager" | |
| 3977 | msgstr "Gestor de descargas" | |
| 3978 | ||
| 3979 | msgctxt "#90793" | |
| 3980 | msgid "No active downloads" | |
| 3981 | msgstr "Sin descargas activas" | |
| 3982 | ||
| 3983 | msgctxt "#90794" | |
| 3984 | msgid "Progress" | |
| 3985 | msgstr "Progreso" | |
| 3986 | ||
| 3987 | msgctxt "#90795" | |
| 3988 | msgid "Speed" | |
| 3989 | msgstr "Velocidad" | |
| 3990 | ||
| 3991 | msgctxt "#90796" | |
| 3992 | msgid "ETA" | |
| 3993 | msgstr "Tiempo restante" | |
| 3994 | ||
| 3995 | msgctxt "#90797" | |
| 3996 | msgid "Size" | |
| 3997 | msgstr "Tamaño" | |
| 3998 | ||
| 3999 | msgctxt "#90798" | |
| 4000 | msgid "Pause" | |
| 4001 | msgstr "Pausar" | |
| 4002 | ||
| 4003 | msgctxt "#90799" | |
| 4004 | msgid "Resume" | |
| 4005 | msgstr "Reanudar" | |
| 4006 | ||
| 4007 | msgctxt "#90800" | |
| 4008 | msgid "Cancel" | |
| 4009 | msgstr "Cancelar" | |
| 4010 | ||
| 4011 | msgctxt "#90801" | |
| 4012 | msgid "Delete" | |
| 4013 | msgstr "Eliminar" | |
| 4014 | ||
| 4015 | msgctxt "#90802" | |
| 4016 | msgid "Clear Completed" | |
| 4017 | msgstr "Limpiar completados" | |
| 4018 | ||
| 4019 | msgctxt "#90803" | |
| 4020 | msgid "Download not yet completed" | |
| 4021 | msgstr "Descarga aún no completada" | |
| 4022 | ||
| 4023 | msgctxt "#90804" | |
| 4024 | msgid "File not found" | |
| 4025 | msgstr "Archivo no encontrado" | |
| 4026 | ||
| 4027 | msgctxt "#90805" | |
| 4028 | msgid "Download Manager" | |
| 4029 | msgstr "Gestor de descargas" | |
| 4030 | ||
| 4031 | msgctxt "#90806" | |
| 4032 | msgid "My Downloads" | |
| 4033 | msgstr "Mis descargas" | |
| 4034 | ||
| 4035 | msgctxt "#90807" | |
| 4036 | msgid "Connecting..." | |
| 4037 | msgstr "Conectando..." | |
| 4038 | ||
| 4039 | msgctxt "#90808" | |
| 4040 | msgid "Close" | |
| 4041 | msgstr "Cerrar" | |
| 4042 | ||
| 4043 | msgctxt "#90809" | |
| 4044 | msgid "Delete download?" | |
| 4045 | msgstr "¿Eliminar descarga?" | |
| 4046 | ||
| 4047 | msgctxt "#90810" | |
| 4048 | msgid "This will permanently delete the downloaded file. Are you sure?" | |
| 4049 | msgstr "Esto eliminará permanentemente el archivo descargado. ¿Estás seguro?" | |
| 4050 | ||
| 4051 | msgctxt "#90811" | |
| 4052 | msgid "Completed" | |
| 4053 | msgstr "Completada" | |
| 4054 | ||
| 4055 | msgctxt "#90812" | |
| 4056 | msgid "Excluded TMDB Languages" | |
| 4057 | msgstr "Idiomas excluidos de TMDB" | |
| 4058 | ||
| 4059 | msgctxt "#90813" | |
| 4060 | msgid "Select languages to exclude" | |
| 4061 | msgstr "Selecciona los idiomas a excluir" | |
| 4062 | ||
| 4063 | msgctxt "#90814" | |
| 4064 | msgid "Choose original languages to hide from TMDB listings" | |
| 4065 | msgstr "Elige los idiomas originales que quieres ocultar de los listados de TMDB" | |
| 4066 | ||
| 4067 | msgctxt "#90815" | |
| 4068 | msgid "Support Jacktook" | |
| 4069 | msgstr "Apoya a Jacktook" | |
| 4070 | ||
| 4071 | msgctxt "#90816" | |
| 4072 | msgid "Support Jacktook development with an optional Ko-fi donation." | |
| 4073 | msgstr "Apoya el desarrollo de Jacktook con una donación opcional en Ko-fi." | |
| 4074 | ||
| 4075 | msgctxt "#90817" | |
| 4076 | msgid "Scan the QR code or open this link:" | |
| 4077 | msgstr "Escanea el código QR o abre este enlace:" | |
| 4078 | ||
| 4079 | msgctxt "#90818" | |
| 4080 | msgid "Jacktook was updated with new improvements. Donations are optional, but they help keep development active. Would you like to see the support link?" | |
| 4081 | msgstr "Jacktook se actualizó con nuevas mejoras. Las donaciones son opcionales, pero ayudan a mantener el desarrollo activo. ¿Quieres ver el enlace de apoyo?" | |
| 4082 | ||
| 4083 | msgctxt "#90300" | |
| 4084 | msgid "External Scraper" | |
| 4085 | msgstr "Scraper Externo" | |
| 4086 | ||
| 4087 | msgctxt "#90301" | |
| 4088 | msgid "Enable external scraper module integration." | |
| 4089 | msgstr "Habilitar la integración del módulo scraper externo." | |
| 4090 | ||
| 4091 | msgctxt "#90302" | |
| 4092 | msgid "Choose External Scraper Module" | |
| 4093 | msgstr "Elegir Módulo Scraper Externo" | |
| 4094 | ||
| 4095 | msgctxt "#90303" | |
| 4096 | msgid "Select an external scraper addon module to use for torrent search." | |
| 4097 | msgstr "Selecciona un módulo addon scraper externo para buscar torrents." | |
| 4098 | ||
| 4099 | msgctxt "#90304" | |
| 4100 | msgid "Choose External Scraper" | |
| 4101 | msgstr "Elegir Scraper Externo" | |
| 4102 | ||
| 4103 | msgctxt "#90305" | |
| 4104 | msgid "Selected Module" | |
| 4105 | msgstr "Módulo Seleccionado" | |
| 4106 | ||
| 4107 | msgctxt "#90306" | |
| 4108 | msgid "Open External Scraper Settings" | |
| 4109 | msgstr "Abrir Ajustes del Scraper Externo" | |
| 4110 | ||
| 4111 | msgctxt "#90307" | |
| 4112 | msgid "Open the settings for the selected external scraper module." | |
| 4113 | msgstr "Abrir los ajustes del módulo scraper externo seleccionado." | |
| 4114 | ||
| 4115 | msgctxt "#90382" | |
| 4116 | msgid "Please set a valid Trakt Client ID Key" | |
| 4117 | msgstr "Configura una clave de ID de cliente de Trakt válida" | |
| 4118 | ||
| 4119 | msgctxt "#90383" | |
| 4120 | msgid "Please set a valid Trakt Client Secret Key" | |
| 4121 | msgstr "Configura una clave secreta de cliente de Trakt válida" | |
| 4122 | ||
| 4123 | msgctxt "#90384" | |
| 4124 | msgid "Trakt Error Authorizing" | |
| 4125 | msgstr "Error de autorización de Trakt" | |
| 4126 | ||
| 4127 | msgctxt "#90385" | |
| 4128 | msgid "Trakt Account Authorized" | |
| 4129 | msgstr "Cuenta de Trakt autorizada" | |
| 4130 | ||
| 4131 | msgctxt "#90386" | |
| 4132 | msgid "You are now logged out from Trakt.tv" | |
| 4133 | msgstr "Has cerrado sesión en Trakt.tv" | |
| 4134 | ||
| 4135 | msgctxt "#90387" | |
| 4136 | msgid "Failed to mark as watched" | |
| 4137 | msgstr "No se pudo marcar como visto" | |
| 4138 | ||
| 4139 | msgctxt "#90388" | |
| 4140 | msgid "Marked as watched" | |
| 4141 | msgstr "Marcado como visto" | |
| 4142 | ||
| 4143 | msgctxt "#90389" | |
| 4144 | msgid "No results found" | |
| 4145 | msgstr "No se encontraron resultados" | |
| 4146 | ||
| 4147 | msgctxt "#90390" | |
| 4148 | msgid "Invalid mode" | |
| 4149 | msgstr "Modo no válido" | |
| 4150 | ||
| 4151 | msgctxt "#90391" | |
| 4152 | msgid "No recommendations found" | |
| 4153 | msgstr "No se encontraron recomendaciones" | |
| 4154 | ||
| 4155 | msgctxt "#90392" | |
| 4156 | msgid "No similar items found" | |
| 4157 | msgstr "No se encontraron elementos similares" | |
| 4158 | ||
| 4159 | msgctxt "#90393" | |
| 4160 | msgid "TMDB metadata not found" | |
| 4161 | msgstr "No se encontraron metadatos de TMDB" | |
| 4162 | ||
| 4163 | msgctxt "#90394" | |
| 4164 | msgid "No TV shows found" | |
| 4165 | msgstr "No se encontraron series" | |
| 4166 | ||
| 4167 | msgctxt "#90395" | |
| 4168 | msgid "No shows airing today" | |
| 4169 | msgstr "No hay series emitiéndose hoy" | |
| 4170 | ||
| 4171 | msgctxt "#90396" | |
| 4172 | msgid "Invalid collection query" | |
| 4173 | msgstr "Consulta de colección no válida" | |
| 4174 | ||
| 4175 | msgctxt "#90397" | |
| 4176 | msgid "No keywords found" | |
| 4177 | msgstr "No se encontraron palabras clave" | |
| 4178 | ||
| 4179 | msgctxt "#90398" | |
| 4180 | msgid "No keyword ID provided" | |
| 4181 | msgstr "No se proporcionó ID de palabra clave" | |
| 4182 | ||
| 4183 | msgctxt "#90399" | |
| 4184 | msgid "No results found for this keyword" | |
| 4185 | msgstr "No se encontraron resultados para esta palabra clave" | |
| 4186 | ||
| 4187 | msgctxt "#90400" | |
| 4188 | msgid "No TMDB ID found" | |
| 4189 | msgstr "No se encontró ID de TMDB" | |
| 4190 | ||
| 4191 | msgctxt "#90401" | |
| 4192 | msgid "Unsupported media type or missing external_ids" | |
| 4193 | msgstr "Tipo de medio no compatible o external_ids faltantes" | |
| 4194 | ||
| 4195 | msgctxt "#90402" | |
| 4196 | msgid "Refreshed Metadata" | |
| 4197 | msgstr "Metadatos actualizados" | |
| 4198 | ||
| 4199 | msgctxt "#90403" | |
| 4200 | msgid "No Stremio addons selected" | |
| 4201 | msgstr "No hay addons de Stremio seleccionados" | |
| 4202 | ||
| 4203 | msgctxt "#90404" | |
| 4204 | msgid "No suitable source found for auto play." | |
| 4205 | msgstr "No se encontró una fuente adecuada para reproducción automática." | |
| 4206 | ||
| 4207 | msgctxt "#90405" | |
| 4208 | msgid "No sources found with the preferred quality." | |
| 4209 | msgstr "No se encontraron fuentes con la calidad preferida." | |
| 4210 | ||
| 4211 | msgctxt "#90406" | |
| 4212 | msgid "No languages found" | |
| 4213 | msgstr "No se encontraron idiomas" | |
| 4214 | ||
| 4215 | msgctxt "#90407" | |
| 4216 | msgid "Failed to resolve playback source" | |
| 4217 | msgstr "No se pudo resolver la fuente de reproducción" | |
| 4218 | ||
| 4219 | msgctxt "#90408" | |
| 4220 | msgid "Trakt not authorized. Please authenticate." | |
| 4221 | msgstr "Trakt no está autorizado. Autentícate." | |
| 4222 | ||
| 4223 | msgctxt "#90409" | |
| 4224 | msgid "Added to Trakt Watchlist" | |
| 4225 | msgstr "Añadido a la lista de seguimiento de Trakt" | |
| 4226 | ||
| 4227 | msgctxt "#90428" | |
| 4228 | msgid "Database read error. File may be corrupted." | |
| 4229 | msgstr "Error al leer la base de datos. El archivo puede estar dañado." | |
| 4230 | ||
| 4231 | msgctxt "#90429" | |
| 4232 | msgid "Failed to delete data. Database error." | |
| 4233 | msgstr "No se pudieron eliminar los datos. Error de base de datos." | |
| 4234 | ||
| 4235 | msgctxt "#90430" | |
| 4236 | msgid "Failed to clear cache. Database in use." | |
| 4237 | msgstr "No se pudo limpiar la caché. La base de datos está en uso." | |
| 4238 | ||
| 4239 | msgctxt "#90458" | |
| 4240 | msgid "OpenSubtitles Stremio: No results found." | |
| 4241 | msgstr "OpenSubtitles Stremio: no se encontraron resultados." | |
| 4242 | ||
| 4243 | msgctxt "#90460" | |
| 4244 | msgid "Subtitle Manager: No subtitles available for this video." | |
| 4245 | msgstr "Gestor de subtítulos: no hay subtítulos disponibles para este video." | |
| 4246 | ||
| 4247 | msgctxt "#90468" | |
| 4248 | msgid "Search menu" | |
| 4249 | msgstr "Menú de búsqueda" | |
| 4250 | ||
| 4251 | msgctxt "#90469" | |
| 4252 | msgid "Continue watching menu" | |
| 4253 | msgstr "Menú de continuar viendo" | |
| 4254 | ||
| 4255 | msgctxt "#90470" | |
| 4256 | msgid "TV Shows Items" | |
| 4257 | msgstr "Elementos de series" | |
| 4258 | ||
| 4259 | msgctxt "#90471" | |
| 4260 | msgid "Movies Items" | |
| 4261 | msgstr "Elementos de películas" | |
| 4262 | ||
| 4263 | msgctxt "#90472" | |
| 4264 | msgid "Anime menu" | |
| 4265 | msgstr "Menú de anime" | |
| 4266 | ||
| 4267 | msgctxt "#90473" | |
| 4268 | msgid "TV Menu" | |
| 4269 | msgstr "Menú de TV" | |
| 4270 | ||
| 4271 | msgctxt "#90474" | |
| 4272 | msgid "Library menu" | |
| 4273 | msgstr "Menú de biblioteca" | |
| 4274 | ||
| 4275 | msgctxt "#90475" | |
| 4276 | msgid "Torrents Management" | |
| 4277 | msgstr "Gestión de torrents" | |
| 4278 | ||
| 4279 | msgctxt "#90476" | |
| 4280 | msgid "Telegram menu" | |
| 4281 | msgstr "Menú de Telegram" | |
| 4282 | ||
| 4283 | msgctxt "#90477" | |
| 4284 | msgid "Cloud Storage" | |
| 4285 | msgstr "Almacenamiento en la nube" | |
| 4286 | ||
| 4287 | msgctxt "#90478" | |
| 4288 | msgid "Downloads Management" | |
| 4289 | msgstr "Gestión de descargas" | |
| 4290 | ||
| 4291 | msgctxt "#90479" | |
| 4292 | msgid "Settings Management" | |
| 4293 | msgstr "Gestión de ajustes" | |
| 4294 | ||
| 4295 | msgctxt "#90480" | |
| 4296 | msgid "History Menu" | |
| 4297 | msgstr "Menú de historial" | |
| 4298 | ||
| 4299 | msgctxt "#90481" | |
| 4300 | msgid "Files History" | |
| 4301 | msgstr "Historial de archivos" | |
| 4302 | ||
| 4303 | msgctxt "#90482" | |
| 4304 | msgid "Titles History" | |
| 4305 | msgstr "Historial de títulos" | |
| 4306 | ||
| 4307 | msgctxt "#90483" | |
| 4308 | msgid "Titles Calendar" | |
| 4309 | msgstr "Calendario de títulos" | |
| 4310 | ||
| 4311 | msgctxt "#90484" | |
| 4312 | msgid "My Shows" | |
| 4313 | msgstr "Mis series" | |
| 4314 | ||
| 4315 | msgctxt "#90485" | |
| 4316 | msgid "My Movies" | |
| 4317 | msgstr "Mis películas" | |
| 4318 | ||
| 4319 | msgctxt "#90486" | |
| 4320 | msgid "Upcoming Episodes" | |
| 4321 | msgstr "Próximos episodios" | |
| 4322 | ||
| 4323 | msgctxt "#90487" | |
| 4324 | msgid "Discovery List" | |
| 4325 | msgstr "Lista de descubrimiento" | |
| 4326 | ||
| 4327 | msgctxt "#90488" | |
| 4328 | msgid "Library List" | |
| 4329 | msgstr "Lista de biblioteca" | |
| 4330 | ||
| 4331 | msgctxt "#90489" | |
| 4332 | msgid "Trakt Recommendations" | |
| 4333 | msgstr "Recomendaciones de Trakt" | |
| 4334 | ||
| 4335 | msgctxt "#90490" | |
| 4336 | msgid "Trending Lists" | |
| 4337 | msgstr "Listas en tendencia" | |
| 4338 | ||
| 4339 | msgctxt "#90491" | |
| 4340 | msgid "Popular Lists" | |
| 4341 | msgstr "Listas populares" | |
| 4342 | ||
| 4343 | msgctxt "#90492" | |
| 4344 | msgid "Search Lists" | |
| 4345 | msgstr "Buscar listas" | |
| 4346 | ||
| 4347 | msgctxt "#90493" | |
| 4348 | msgid "Recent Activity" | |
| 4349 | msgstr "Actividad reciente" | |
| 4350 | ||
| 4351 | msgctxt "#90494" | |
| 4352 | msgid "Downloads List" | |
| 4353 | msgstr "Lista de descargas" | |
| 4354 | ||
| 4355 | msgctxt "#90495" | |
| 4356 | msgid "Download New" | |
| 4357 | msgstr "Nueva descarga" | |
| 4358 | ||
| 4359 | msgctxt "#90496" | |
| 4360 | msgid "Cartoons Popular" | |
| 4361 | msgstr "Dibujos animados populares" | |
| 4362 | ||
| 4363 | msgctxt "#90497" | |
| 4364 | msgid "Animation Popular" | |
| 4365 | msgstr "Animación popular" | |
| 4366 | ||
| 4367 | msgctxt "#90500" | |
| 4368 | msgid "Translating subtitle..." | |
| 4369 | msgstr "Traduciendo subtítulo..." | |
| 4370 | ||
| 4371 | msgctxt "#90501" | |
| 4372 | msgid "Translation done." | |
| 4373 | msgstr "Traducción completada." | |
| 4374 | ||
| 4375 | msgctxt "#90502" | |
| 4376 | msgid "Searching subtitles..." | |
| 4377 | msgstr "Buscando subtítulos..." | |
| 4378 | ||
| 4379 | msgctxt "#90503" | |
| 4380 | msgid "No subtitles found" | |
| 4381 | msgstr "No se encontraron subtítulos" | |
| 4382 | ||
| 4383 | msgctxt "#90504" | |
| 4384 | msgid "Failed to authenticate with DeepL. Please check your API key." | |
| 4385 | msgstr "No se pudo autenticar con DeepL. Comprueba tu clave API." | |
| 4386 | ||
| 4387 | msgctxt "#90505" | |
| 4388 | msgid "DeepL API key is missing or invalid." | |
| 4389 | msgstr "La clave API de DeepL falta o no es válida." | |
| 4390 | ||
| 4391 | msgctxt "#90712" | |
| 4392 | msgid "Views" | |
| 4393 | msgstr "Vistas" | |
| 4394 | ||
| 4395 | msgctxt "#90713" | |
| 4396 | msgid "Set Main View" | |
| 4397 | msgstr "Definir vista principal" | |
| 4398 | ||
| 4399 | msgctxt "#90714" | |
| 4400 | msgid "Set Movies View" | |
| 4401 | msgstr "Definir vista de películas" | |
| 4402 | ||
| 4403 | msgctxt "#90715" | |
| 4404 | msgid "Set TV Shows View" | |
| 4405 | msgstr "Definir vista de series" | |
| 4406 | ||
| 4407 | msgctxt "#90716" | |
| 4408 | msgid "Set Seasons View" | |
| 4409 | msgstr "Definir vista de temporadas" | |
| 4410 | ||
| 4411 | msgctxt "#90717" | |
| 4412 | msgid "Set Episodes View" | |
| 4413 | msgstr "Definir vista de episodios" | |
| 4414 | ||
| 4415 | msgctxt "#90718" | |
| 4416 | msgid "Set Library View" | |
| 4417 | msgstr "Definir vista de biblioteca" | |
| 4418 | ||
| 4419 | msgctxt "#90719" | |
| 4420 | msgid "Set History View" | |
| 4421 | msgstr "Definir vista de historial" | |
| 4422 | ||
| 4423 | msgctxt "#90720" | |
| 4424 | msgid "Set Downloads View" | |
| 4425 | msgstr "Definir vista de descargas" | |
| 4426 | ||
| 4427 | msgctxt "#90721" | |
| 4428 | msgid "Save Current View" | |
| 4429 | msgstr "Guardar vista actual" | |
| 4430 | ||
| 4431 | msgctxt "#90722" | |
| 4432 | msgid "View saved" | |
| 4433 | msgstr "Vista guardada" | |
| 4434 | ||
| 4435 | msgctxt "#90723" | |
| 4436 | msgid "Could not detect the current view" | |
| 4437 | msgstr "No se pudo detectar la vista actual" | |
| 4438 | ||
| 4439 | msgctxt "#90724" | |
| 4440 | msgid "Saved views reset" | |
| 4441 | msgstr "Vistas guardadas restablecidas" | |
| 4442 | ||
| 4443 | msgctxt "#90725" | |
| 4444 | msgid "Reset Saved Views" | |
| 4445 | msgstr "Restablecer vistas guardadas" | |
| 4446 | ||
| 4447 | msgctxt "#90726" | |
| 4448 | msgid "Open this screen, change the Kodi view manually, then select Save Current View." | |
| 4449 | msgstr "Abre esta pantalla, cambia manualmente la vista de Kodi y luego selecciona Guardar vista actual." | |
| 4450 | ||
| 4451 | msgctxt "#90819" | |
| 4452 | msgid "Rename Stremio add-on" | |
| 4453 | msgstr "Renombrar complemento de Stremio" | |
| 4454 | ||
| 4455 | msgctxt "#90824" | |
| 4456 | msgid "Rename Stremio catalog" | |
| 4457 | msgstr "Renombrar catálogo de Stremio" | |
| 4458 | ||
| 4459 | msgctxt "#90827" | |
| 4460 | msgid "Set a Kodi-only display alias for a Stremio add-on." | |
| 4461 | msgstr "Definir un alias de visualización solo en Kodi para un complemento de Stremio." | |
| 4462 | ||
| 4463 | msgctxt "#90828" | |
| 4464 | msgid "Set a Kodi-only display alias for a Stremio catalog." | |
| 4465 | msgstr "Definir un alias de visualización solo en Kodi para un catálogo de Stremio." | |
| 4466 | ||
| 4467 | msgctxt "#90832" | |
| 4468 | msgid "Stremio: Addons Management" | |
| 4469 | msgstr "Stremio: Gestión de complementos" | |
| 4470 | ||
| 4471 | msgctxt "#90833" | |
| 4472 | msgid "Stremio: Catalogs & Visibility" | |
| 4473 | msgstr "Stremio: Catálogos y visibilidad" | |
| 4474 | ||
| 4475 | msgctxt "#90834" | |
| 4476 | msgid "Stremio: Rename & Customization" | |
| 4477 | msgstr "Stremio: Renombrado y personalización" | |
| 4478 | ||
| 4479 | msgctxt "#90835" | |
| 4480 | msgid "Stremio: Search & Behavior" | |
| 4481 | msgstr "Stremio: Búsqueda y comportamiento" |
| … | … | @@ -3,21 +3,44 @@ |
| 3 | 3 | <section id="plugin.video.jacktook"> |
| 4 | 4 | <category id="general_category" label="30717"> |
| 5 | 5 | <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"> | |
| 21 | 44 | <setting id="search_dialog_style" type="string" label="90238" help=""> |
| 22 | 45 | <default>0</default> |
| 23 | 46 | <constraints> |
| … | … | @@ -59,7 +82,7 @@ |
| 59 | 82 | <close>false</close> |
| 60 | 83 | </control> |
| 61 | 84 | </setting> |
| 62 | <setting id="donate" type="action" label="90018" help=""> | |
| 85 | <setting id="donate" type="action" label="90018" help="90816"> | |
| 63 | 86 | <data>RunPlugin(plugin://plugin.video.jacktook/?action=donate)</data> |
| 64 | 87 | <control type="button" format="action"> |
| 65 | 88 | <close>false</close> |
| … | … | @@ -341,6 +364,79 @@ |
| 341 | 364 | </dependencies> |
| 342 | 365 | </setting> |
| 343 | 366 | </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> | |
| 344 | 440 | <group id="premiumize" label="30785"> |
| 345 | 441 | <setting id="premiumize_enabled" type="boolean" label="30734" help="30824"> |
| 346 | 442 | <default>false</default> |
| … | … | @@ -499,7 +595,7 @@ |
| 499 | 595 | </group> |
| 500 | 596 | </category> |
| 501 | 597 | <category id="sources_category" label="30864"> |
| 502 | <group id="stremio_general" label="90150"> | |
| 598 | <group id="stremio_general" label="90151"> | |
| 503 | 599 | <setting id="stremio_enabled" type="boolean" label="30734" help="30869"> |
| 504 | 600 | <default>false</default> |
| 505 | 601 | <control type="toggle" /> |
| … | … | @@ -523,63 +619,10 @@ |
| 523 | 619 | <control type="edit" format="string" /> |
| 524 | 620 | <visible>false</visible> |
| 525 | 621 | </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> | |
| 576 | 622 | <setting id="stremio_login" type="action" label="30871" help="30866"> |
| 577 | 623 | <dependencies> |
| 578 | 624 | <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> | |
| 583 | 626 | </dependency> |
| 584 | 627 | </dependencies> |
| 585 | 628 | <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_login)</data> |
| … | … | @@ -590,10 +633,7 @@ |
| 590 | 633 | <setting id="stremio_logout" type="action" label="30868" help="30867"> |
| 591 | 634 | <dependencies> |
| 592 | 635 | <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> | |
| 597 | 637 | </dependency> |
| 598 | 638 | </dependencies> |
| 599 | 639 | <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_logout)</data> |
| … | … | @@ -604,10 +644,7 @@ |
| 604 | 644 | <setting id="stremio_update" type="action" label="30874" help="90336"> |
| 605 | 645 | <dependencies> |
| 606 | 646 | <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> | |
| 611 | 648 | </dependency> |
| 612 | 649 | </dependencies> |
| 613 | 650 | <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_update)</data> |
| … | … | @@ -615,69 +652,91 @@ |
| 615 | 652 | <close>false</close> |
| 616 | 653 | </control> |
| 617 | 654 | </setting> |
| 655 | </group> | |
| 656 | <group id="stremio_addons_management" label="90832"> | |
| 618 | 657 | <setting id="stremio_toggle_addons" type="action" label="30870" help="90337"> |
| 619 | 658 | <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_toggle_addons)</data> |
| 620 | 659 | <control type="button" format="action"> |
| 621 | 660 | <close>false</close> |
| 622 | 661 | </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" /> | |
| 628 | 686 | </setting> |
| 629 | 687 | <setting id="stremio_toggle_catalogs" type="action" label="30875" help="90338"> |
| 630 | 688 | <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_toggle_catalogs)</data> |
| 631 | 689 | <control type="button" format="action"> |
| 632 | 690 | <close>false</close> |
| 633 | 691 | </control> |
| 634 | <dependencies> | |
| 635 | <dependency type="visible"> | |
| 636 | <condition operator="is" setting="stremio_enabled">true</condition> | |
| 637 | </dependency> | |
| 638 | </dependencies> | |
| 639 | 692 | </setting> |
| 640 | 693 | <setting id="stremio_toggle_tv_addons" type="action" label="30903" help="90339"> |
| 641 | 694 | <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_toggle_tv_addons)</data> |
| 642 | 695 | <control type="button" format="action"> |
| 643 | 696 | <close>false</close> |
| 644 | 697 | </control> |
| 645 | <dependencies> | |
| 646 | <dependency type="visible"> | |
| 647 | <condition operator="is" setting="stremio_enabled">true</condition> | |
| 648 | </dependency> | |
| 649 | </dependencies> | |
| 650 | 698 | </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> | |
| 653 | 703 | <control type="button" format="action"> |
| 654 | 704 | <close>false</close> |
| 655 | 705 | </control> |
| 656 | <dependencies> | |
| 657 | <dependency type="visible"> | |
| 658 | <condition operator="is" setting="stremio_enabled">true</condition> | |
| 659 | </dependency> | |
| 660 | </dependencies> | |
| 661 | 706 | </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> | |
| 664 | 709 | <control type="button" format="action"> |
| 665 | 710 | <close>false</close> |
| 666 | 711 | </control> |
| 667 | <dependencies> | |
| 668 | <dependency type="visible"> | |
| 669 | <condition operator="is" setting="stremio_enabled">true</condition> | |
| 670 | </dependency> | |
| 671 | </dependencies> | |
| 672 | 712 | </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> | |
| 675 | 728 | <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> | |
| 677 | 736 | </control> |
| 678 | 737 | <dependencies> |
| 679 | 738 | <dependency type="visible"> |
| 680 | <condition operator="is" setting="stremio_enabled">true</condition> | |
| 739 | <condition operator="is" setting="cache_enabled">true</condition> | |
| 681 | 740 | </dependency> |
| 682 | 741 | </dependencies> |
| 683 | 742 | </setting> |
| … | … | @@ -857,6 +916,44 @@ |
| 857 | 916 | </dependencies> |
| 858 | 917 | </setting> |
| 859 | 918 | </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> | |
| 860 | 957 | </category> |
| 861 | 958 | <category id="playback_category" label="90139"> |
| 862 | 959 | <group id="playback_group" label="90139"> |
| … | … | @@ -894,11 +991,27 @@ |
| 894 | 991 | </dependency> |
| 895 | 992 | </dependencies> |
| 896 | 993 | </setting> |
| 897 | <setting id="precaching_enabled" type="boolean" label="90145" help="90146"> | |
| 994 | <setting id="autoscrape_next_episode" type="boolean" label="90171" help="90172"> | |
| 898 | 995 | <default>false</default> |
| 899 | 996 | <control type="toggle" /> |
| 900 | 997 | </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"> | |
| 902 | 1015 | <default>1</default> |
| 903 | 1016 | <constraints> |
| 904 | 1017 | <minimum>1</minimum> |
| … | … | @@ -910,14 +1023,31 @@ |
| 910 | 1023 | </control> |
| 911 | 1024 | <dependencies> |
| 912 | 1025 | <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> | |
| 914 | 1043 | </dependency> |
| 915 | 1044 | </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"> | |
| 917 | 1047 | <default>true</default> |
| 918 | 1048 | <control type="toggle" /> |
| 919 | 1049 | </setting> |
| 920 | <setting id="playnext_time" type="integer" label="30720" help="30829"> | |
| 1050 | <setting id="playnext_time" type="integer" label="30720" help="30829"> | |
| 921 | 1051 | <default>50</default> |
| 922 | 1052 | <constraints> |
| 923 | 1053 | <minimum>5</minimum> |
| … | … | @@ -927,6 +1057,71 @@ |
| 927 | 1057 | <control type="slider" format="integer"> |
| 928 | 1058 | <popup>false</popup> |
| 929 | 1059 | </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> | |
| 930 | 1125 | </setting> |
| 931 | 1126 | <setting id="volume_check_enabled" type="boolean" label="90216" help="90217"> |
| 932 | 1127 | <default>false</default> |
| … | … | @@ -1000,17 +1195,34 @@ |
| 1000 | 1195 | </dependencies> |
| 1001 | 1196 | </setting> |
| 1002 | 1197 | </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> | |
| 1003 | 1220 | </category> |
| 1004 | 1221 | <category id="filtering_category" label="30861"> |
| 1005 | 1222 | <group id="filtering_stremio" label="90155"> |
| 1006 | 1223 | <setting id="stremio_bypass_addons" type="boolean" label="90211" help="90212"> |
| 1007 | 1224 | <default>false</default> |
| 1008 | 1225 | <control type="toggle" /> |
| 1009 | <dependencies> | |
| 1010 | <dependency type="visible"> | |
| 1011 | <condition operator="is" setting="stremio_enabled">true</condition> | |
| 1012 | </dependency> | |
| 1013 | </dependencies> | |
| 1014 | 1226 | </setting> |
| 1015 | 1227 | <setting id="stremio_bypass_addons_select" type="action" label="90213" help="90214"> |
| 1016 | 1228 | <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_bypass_addons_select)</data> |
| … | … | @@ -1019,10 +1231,7 @@ |
| 1019 | 1231 | </control> |
| 1020 | 1232 | <dependencies> |
| 1021 | 1233 | <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> | |
| 1026 | 1235 | </dependency> |
| 1027 | 1236 | </dependencies> |
| 1028 | 1237 | </setting> |
| … | … | @@ -1115,6 +1324,34 @@ |
| 1115 | 1324 | <control type="toggle" /> |
| 1116 | 1325 | </setting> |
| 1117 | 1326 | </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> | |
| 1118 | 1355 | <group id="size_filter" label="90058"> |
| 1119 | 1356 | <setting id="filter_size_enabled" type="boolean" label="90059" help=""> |
| 1120 | 1357 | <default>false</default> |
| … | … | @@ -1169,11 +1406,6 @@ |
| 1169 | 1406 | <setting id="stremio_subtitle_enabled" type="boolean" label="30734" help=""> |
| 1170 | 1407 | <default>false</default> |
| 1171 | 1408 | <control type="toggle" /> |
| 1172 | <dependencies> | |
| 1173 | <dependency type="visible"> | |
| 1174 | <condition operator="is" setting="stremio_enabled">true</condition> | |
| 1175 | </dependency> | |
| 1176 | </dependencies> | |
| 1177 | 1409 | </setting> |
| 1178 | 1410 | <setting id="stremio_sub_addon_host" type="string" label="30885" help=""> |
| 1179 | 1411 | <default>https://opensubtitles-v3.strem.io/</default> |
| … | … | @@ -1182,10 +1414,7 @@ |
| 1182 | 1414 | </constraints> |
| 1183 | 1415 | <dependencies> |
| 1184 | 1416 | <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> | |
| 1189 | 1418 | </dependency> |
| 1190 | 1419 | </dependencies> |
| 1191 | 1420 | <control type="edit" format="string" /> |
| … | … | @@ -1216,11 +1445,6 @@ |
| 1216 | 1445 | </options> |
| 1217 | 1446 | </constraints> |
| 1218 | 1447 | <control type="list" format="string" /> |
| 1219 | <dependencies> | |
| 1220 | <dependency type="visible"> | |
| 1221 | <condition operator="is" setting="stremio_enabled">true</condition> | |
| 1222 | </dependency> | |
| 1223 | </dependencies> | |
| 1224 | 1448 | </setting> |
| 1225 | 1449 | </group> |
| 1226 | 1450 | <group id="deepl_translator" label="30891"> |
| … | … | @@ -1399,6 +1623,50 @@ |
| 1399 | 1623 | </dependency> |
| 1400 | 1624 | </dependencies> |
| 1401 | 1625 | </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> | |
| 1402 | 1670 | </group> |
| 1403 | 1671 | <group id="calendar" label="30896"> |
| 1404 | 1672 | <setting id="use_broadcast_adjustment" type="boolean" label="30897" help="30898"> |
| … | … | @@ -1695,6 +1963,34 @@ |
| 1695 | 1963 | </constraints> |
| 1696 | 1964 | <control type="button" format="path"/> |
| 1697 | 1965 | </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> | |
| 1698 | 1994 | </group> |
| 1699 | 1995 | <group id="cleanup" label="30106"> |
| 1700 | 1996 | <setting id="clear_database_cache" type="action" label="90342"> |
| … | … | @@ -1788,6 +2084,14 @@ |
| 1788 | 2084 | </constraints> |
| 1789 | 2085 | <control type="edit" format="string" /> |
| 1790 | 2086 | </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> | |
| 1791 | 2095 | </group> |
| 1792 | 2096 | </category> |
| 1793 | 2097 | </section> |
| … | … | @@ -1,92 +1,145 @@ |
| 1 | 1 | <window type="dialog" id="12000"> |
| 2 | 2 | <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> | |
| 7 | 7 | </coordinates> |
| 8 | <defaultcontrol always="true">12005</defaultcontrol> | |
| 8 | <defaultcontrol always="true">12003</defaultcontrol> | |
| 9 | 9 | <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" /> | |
| 12 | 11 | </animation> |
| 13 | 12 | <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" /> | |
| 16 | 14 | </animation> |
| 17 | <depth>0.40</depth> | |
| 18 | 15 | <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 | ||
| 19 | 36 | <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 --> | |
| 28 | 43 | <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> | |
| 32 | 47 | <align>center</align> |
| 33 | <font>font14</font> | |
| 48 | <font>font30_title</font> | |
| 34 | 49 | <textcolor>white</textcolor> |
| 35 | 50 | <shadowcolor>AA000000</shadowcolor> |
| 36 | 51 | </control> |
| 52 | ||
| 53 | <!-- File name --> | |
| 37 | 54 | <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> | |
| 41 | 58 | <align>center</align> |
| 42 | <font>font12</font> | |
| 43 | <textcolor>white</textcolor> | |
| 59 | <font>font14</font> | |
| 60 | <textcolor>EEEEEE</textcolor> | |
| 44 | 61 | <shadowcolor>AA000000</shadowcolor> |
| 45 | 62 | <wrapmultiline>true</wrapmultiline> |
| 46 | 63 | </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 --> | |
| 47 | 100 | <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> | |
| 52 | 105 | <info>percentage</info> |
| 53 | 106 | </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 --> | |
| 60 | 115 | <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> | |
| 64 | 120 | <align>center</align> |
| 65 | <textoffsetx>20</textoffsetx> | |
| 66 | <font>font14</font> | |
| 121 | <font>font13</font> | |
| 67 | 122 | <textcolor>DDFFFFFF</textcolor> |
| 68 | 123 | <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> | |
| 73 | 127 | </control> |
| 74 | <!-- Close Button --> | |
| 128 | <!-- Close --> | |
| 75 | 129 | <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> | |
| 79 | 134 | <align>center</align> |
| 80 | <textoffsetx>20</textoffsetx> | |
| 81 | <font>font14</font> | |
| 135 | <font>font13</font> | |
| 82 | 136 | <textcolor>DDFFFFFF</textcolor> |
| 83 | 137 | <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> | |
| 88 | 141 | </control> |
| 89 | 142 | </control> |
| 90 | 143 | </control> |
| 91 | 144 | </controls> |
| 92 | </window> | |
| 145 | </window> |
| … | … | @@ -34,7 +34,7 @@ |
| 34 | 34 | <control type="label" id="32500"> |
| 35 | 35 | <top>20</top> |
| 36 | 36 | <width>1000</width> |
| 37 | <height>40</height> | |
| 37 | <height>50</height> | |
| 38 | 38 | <font>font25</font> |
| 39 | 39 | <align>center</align> |
| 40 | 40 | <textcolor>white</textcolor> |
| … | … | @@ -43,10 +43,10 @@ |
| 43 | 43 | |
| 44 | 44 | <!-- QR Code --> |
| 45 | 45 | <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> | |
| 50 | 50 | <texture>qr_placeholder.png</texture> |
| 51 | 51 | <aspectratio>keep</aspectratio> |
| 52 | 52 | </control> |
| … | … | @@ -54,10 +54,10 @@ |
| 54 | 54 | <!-- Text Body --> |
| 55 | 55 | <control type="label" id="32501"> |
| 56 | 56 | <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> | |
| 61 | 61 | <align>left</align> |
| 62 | 62 | <textcolor>white</textcolor> |
| 63 | 63 | <shadowcolor>AA000000</shadowcolor> |
| … | … | @@ -76,17 +76,27 @@ |
| 76 | 76 | </control> |
| 77 | 77 | |
| 78 | 78 | <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> | |
| 83 | 83 | |
| 84 | 84 | <!-- Card with Rounded Corners --> |
| 85 | 85 | <control type="image"> |
| 86 | 86 | <right>-6</right> |
| 87 | 87 | <bottom>-2</bottom> |
| 88 | 88 | <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> | |
| 90 | 100 | </control> |
| 91 | 101 | |
| 92 | 102 | <!-- Text URL --> |
| … | … | @@ -94,7 +104,8 @@ |
| 94 | 104 | <font>font14</font> |
| 95 | 105 | <align>center</align> |
| 96 | 106 | <aligny>center</aligny> |
| 97 | <height>100</height> | |
| 107 | <top>48</top> | |
| 108 | <height>55</height> | |
| 98 | 109 | <textcolor>white</textcolor> |
| 99 | 110 | <shadowcolor>FF000000</shadowcolor> |
| 100 | 111 | <wrap>true</wrap> |
| … | … | @@ -102,4 +113,3 @@ |
| 102 | 113 | </control> |
| 103 | 114 | </controls> |
| 104 | 115 | </window> |
| 105 |
| … | … | @@ -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> |
| … | … | @@ -1,103 +1,193 @@ |
| 1 | 1 | <?xml version="1.0" encoding="UTF-8"?> |
| 2 | 2 | <window> |
| 3 | <onload>Dialog.Close(fullscreeninfo,true)</onload> | |
| 3 | <onload>Dialog.Close(fullscreeninfo,true)</onload> | |
| 4 | 4 | <onload>Dialog.Close(videoosd,true)</onload> |
| 5 | 5 | |
| 6 | <defaultcontrol always="true">3001</defaultcontrol> | |
| 6 | <defaultcontrol always="true">3001</defaultcontrol> | |
| 7 | 7 | |
| 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> | |
| 14 | 14 | |
| 15 | 15 | <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"/> | |
| 18 | 18 | </animation> |
| 19 | 19 | |
| 20 | 20 | <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"/> | |
| 22 | 23 | </animation> |
| 23 | 24 | |
| 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> | |
| 55 | 33 | |
| 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> | |
| 57 | 107 | |
| 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> | |
| 102 | 192 | </controls> |
| 103 | 193 | </window> |
| … | … | @@ -112,6 +112,16 @@ |
| 112 | 112 | <textcolor>white</textcolor> |
| 113 | 113 | <label>$INFO[Window.Property(task_0_results)]</label> |
| 114 | 114 | </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> | |
| 115 | 125 | </control> |
| 116 | 126 | |
| 117 | 127 | <!-- Task 1 --> |
| … | … | @@ -145,6 +155,16 @@ |
| 145 | 155 | <textcolor>white</textcolor> |
| 146 | 156 | <label>$INFO[Window.Property(task_1_results)]</label> |
| 147 | 157 | </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> | |
| 148 | 168 | </control> |
| 149 | 169 | |
| 150 | 170 | <!-- Task 2 --> |
| … | … | @@ -178,6 +198,16 @@ |
| 178 | 198 | <textcolor>white</textcolor> |
| 179 | 199 | <label>$INFO[Window.Property(task_2_results)]</label> |
| 180 | 200 | </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> | |
| 181 | 211 | </control> |
| 182 | 212 | |
| 183 | 213 | <!-- Task 3 --> |
| … | … | @@ -211,6 +241,16 @@ |
| 211 | 241 | <textcolor>white</textcolor> |
| 212 | 242 | <label>$INFO[Window.Property(task_3_results)]</label> |
| 213 | 243 | </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> | |
| 214 | 254 | </control> |
| 215 | 255 | |
| 216 | 256 | <!-- Task 4 --> |
| … | … | @@ -244,6 +284,16 @@ |
| 244 | 284 | <textcolor>white</textcolor> |
| 245 | 285 | <label>$INFO[Window.Property(task_4_results)]</label> |
| 246 | 286 | </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> | |
| 247 | 297 | </control> |
| 248 | 298 | |
| 249 | 299 | <!-- Task 5 --> |
| … | … | @@ -277,6 +327,16 @@ |
| 277 | 327 | <textcolor>white</textcolor> |
| 278 | 328 | <label>$INFO[Window.Property(task_5_results)]</label> |
| 279 | 329 | </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> | |
| 280 | 340 | </control> |
| 281 | 341 | |
| 282 | 342 | <!-- Task 6 --> |
| … | … | @@ -310,6 +370,16 @@ |
| 310 | 370 | <textcolor>white</textcolor> |
| 311 | 371 | <label>$INFO[Window.Property(task_6_results)]</label> |
| 312 | 372 | </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> | |
| 313 | 383 | </control> |
| 314 | 384 | |
| 315 | 385 | <!-- Task 7 --> |
| … | … | @@ -343,6 +413,16 @@ |
| 343 | 413 | <textcolor>white</textcolor> |
| 344 | 414 | <label>$INFO[Window.Property(task_7_results)]</label> |
| 345 | 415 | </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> | |
| 346 | 426 | </control> |
| 347 | 427 | |
| 348 | 428 | <!-- Task 8 --> |
| … | … | @@ -376,6 +456,16 @@ |
| 376 | 456 | <textcolor>white</textcolor> |
| 377 | 457 | <label>$INFO[Window.Property(task_8_results)]</label> |
| 378 | 458 | </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> | |
| 379 | 469 | </control> |
| 380 | 470 | |
| 381 | 471 | <!-- Task 9 --> |
| … | … | @@ -409,6 +499,16 @@ |
| 409 | 499 | <textcolor>white</textcolor> |
| 410 | 500 | <label>$INFO[Window.Property(task_9_results)]</label> |
| 411 | 501 | </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> | |
| 412 | 512 | </control> |
| 413 | 513 | |
| 414 | 514 | <!-- Progress Bar --> |
| … | … | @@ -4,12 +4,109 @@ import sys |
| 4 | 4 | |
| 5 | 5 | # Disallowed base names used as generics in Python < 3.9 |
| 6 | 6 | PEP585_TYPES = {"list", "dict", "set", "tuple", "frozenset", "type", "callable"} |
| 7 | PEP585_REPLACEMENTS = { | |
| 8 | "list": "List", | |
| 9 | "dict": "Dict", | |
| 10 | "set": "Set", | |
| 11 | "tuple": "Tuple", | |
| 12 | "frozenset": "FrozenSet", | |
| 13 | "type": "Type", | |
| 14 | "callable": "Callable", | |
| 15 | } | |
| 7 | 16 | |
| 8 | 17 | # PEP 593, 604, 647, 612, 673 keywords |
| 9 | 18 | UNSUPPORTED_NAMES = { |
| 10 | 19 | "Annotated", "Self", "TypeGuard", "ParamSpec", "Concatenate", "Required", "NotRequired" |
| 11 | 20 | } |
| 12 | 21 | |
| 22 | EXCLUDED_DIRS = { | |
| 23 | ".git", | |
| 24 | ".venv", | |
| 25 | ".agent", | |
| 26 | ".agents", | |
| 27 | ".opencode", | |
| 28 | ".worktrees", | |
| 29 | ".mypy_cache", | |
| 30 | ".pytest_cache", | |
| 31 | "__pycache__", | |
| 32 | } | |
| 33 | ||
| 34 | EXCLUDED_PATH_PARTS = { | |
| 35 | ("lib", "vendor"), | |
| 36 | ("lib", "api", "tmdbv3api"), | |
| 37 | } | |
| 38 | ||
| 39 | ||
| 40 | def 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 | ||
| 49 | def 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 | ||
| 57 | def 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 | ||
| 65 | def 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 | ||
| 73 | def 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 | ||
| 81 | def 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 | ||
| 86 | def 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 | ||
| 104 | def 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 | ||
| 13 | 110 | def find_incompatible_syntax(file_path): |
| 14 | 111 | with open(file_path, "r", encoding="utf-8") as f: |
| 15 | 112 | try: |
| … | … | @@ -20,61 +117,72 @@ def find_incompatible_syntax(file_path): |
| 20 | 117 | issues = [] |
| 21 | 118 | |
| 22 | 119 | 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) | |
| 27 | 122 | self.generic_visit(node) |
| 28 | 123 | |
| 29 | 124 | 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) | |
| 34 | 128 | self.generic_visit(node) |
| 35 | 129 | |
| 36 | 130 | 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) | |
| 46 | 132 | self.generic_visit(node) |
| 47 | 133 | |
| 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) | |
| 50 | 136 | self.generic_visit(node) |
| 51 | 137 | |
| 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")) | |
| 55 | 140 | self.generic_visit(node) |
| 56 | 141 | |
| 57 | 142 | Visitor().visit(tree) |
| 58 | 143 | return issues |
| 59 | 144 | |
| 145 | ||
| 146 | def 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 | ||
| 60 | 157 | def scan_project(root_path): |
| 61 | 158 | 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")] | |
| 63 | 171 | for file in filenames: |
| 64 | 172 | if file.endswith(".py"): |
| 65 | 173 | filepath = os.path.join(dirpath, file) |
| 174 | if is_excluded_path(filepath): | |
| 175 | continue | |
| 66 | 176 | issues = find_incompatible_syntax(filepath) |
| 67 | 177 | for kind, lineno, message in issues: |
| 68 | 178 | results.append((filepath, lineno, kind, message)) |
| 69 | 179 | return results |
| 70 | 180 | |
| 71 | 181 | if __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)) | |
| 78 | 186 | |
| 79 | 187 | if not findings: |
| 80 | 188 | print("✅ No Python 3.7 incompatibilities found.") |
| … | … | @@ -82,3 +190,4 @@ if __name__ == "__main__": |
| 82 | 190 | print("❌ Found potential Python 3.7 incompatibilities:\n") |
| 83 | 191 | for path, line, kind, msg in findings: |
| 84 | 192 | print(f"{path}:{line} [{kind}] {msg}") |
| 193 | sys.exit(1) |
| … | … | @@ -1,4 +1,3 @@ |
| 1 | 1 | from lib.services.background_service import JacktookMOnitor |
| 2 | 2 | |
| 3 | ||
| 4 | 3 | JacktookMOnitor().waitForAbort() |
| … | … | @@ -1,5 +1,5 @@ |
| 1 | import sys | |
| 2 | 1 | import os |
| 2 | import sys | |
| 3 | 3 | from unittest.mock import MagicMock |
| 4 | 4 | |
| 5 | 5 | # --- Create stub classes for xbmcgui to avoid type hint issues in Python 3.13 --- |
| … | … | @@ -0,0 +1,351 @@ |
| 1 | import json | |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | ||
| 4 | ||
| 5 | class 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 | ||
| 18 | class 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() |
| … | … | @@ -0,0 +1,455 @@ |
| 1 | import json | |
| 2 | import re | |
| 3 | from datetime import timedelta | |
| 4 | from pathlib import Path | |
| 5 | from unittest.mock import MagicMock, patch | |
| 6 | ||
| 7 | ||
| 8 | def 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 | ||
| 15 | def 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 | ||
| 28 | def 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 | ||
| 40 | def 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 | ||
| 89 | def 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 | ||
| 116 | def 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 | ||
| 134 | def 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 | ||
| 159 | def 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 | ||
| 193 | def 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 | ||
| 226 | def 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 | ||
| 257 | def 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 | ||
| 288 | def 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 | ||
| 309 | PLAYER_PATH = Path(__file__).resolve().parents[2] / "lib" / "player.py" | |
| 310 | ||
| 311 | ||
| 312 | def 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 | ||
| 323 | def 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 | ||
| 334 | def 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 | ||
| 367 | def 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 | ||
| 404 | def 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 | ||
| 435 | def 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() |
| … | … | @@ -1,4 +1,4 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 1 | from unittest.mock import patch | |
| 2 | 2 | |
| 3 | 3 | from lib.gui.base_window import BaseWindow |
| 4 | 4 |
| … | … | @@ -0,0 +1,190 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | import pytest | |
| 4 | ||
| 5 | from lib.domain.torrent import TorrentStream | |
| 6 | from lib.jacktook.client import Burst | |
| 7 | ||
| 8 | ||
| 9 | @pytest.fixture | |
| 10 | def burst_client(): | |
| 11 | return Burst(notification=MagicMock()) | |
| 12 | ||
| 13 | ||
| 14 | class 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 | ||
| 24 | def 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 | ||
| 57 | def 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 | ||
| 77 | def 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") | |
| 97 | def 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 | ||
| 134 | def 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 | ||
| 152 | def 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 | ||
| 170 | def test_parse_response_empty_input(burst_client): | |
| 171 | parsed = burst_client.parse_response([]) | |
| 172 | assert parsed == [] | |
| 173 | ||
| 174 | ||
| 175 | def 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) |
| … | … | @@ -0,0 +1,126 @@ |
| 1 | import importlib.util | |
| 2 | from pathlib import Path | |
| 3 | ||
| 4 | ||
| 5 | SCRIPT_PATH = Path(__file__).resolve().parents[2] / "scripts" / "check_py37_compat.py" | |
| 6 | ||
| 7 | ||
| 8 | def 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 | ||
| 15 | def 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 | ||
| 21 | def 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 | ||
| 34 | def 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 | ||
| 53 | def 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 | ||
| 66 | def 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 | ||
| 75 | def 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 | ||
| 84 | def 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 | ||
| 99 | def 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 | ||
| 108 | def 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 | ||
| 120 | def 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)) == [] |
| … | … | @@ -0,0 +1,46 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.utils.general.utils import clear_tmdb_cache | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 24 | def 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 | ||
| 35 | def 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 |
| … | … | @@ -0,0 +1,319 @@ |
| 1 | import os | |
| 2 | import sys | |
| 3 | import tempfile | |
| 4 | from unittest.mock import MagicMock, patch | |
| 5 | ||
| 6 | # Ensure Kodi modules are mocked before importing lib modules | |
| 7 | sys.modules.setdefault("xbmc", MagicMock()) | |
| 8 | sys.modules.setdefault("xbmcgui", MagicMock()) | |
| 9 | sys.modules.setdefault("xbmcplugin", MagicMock()) | |
| 10 | sys.modules.setdefault("xbmcvfs", MagicMock()) | |
| 11 | sys.modules.setdefault("xbmcaddon", MagicMock()) | |
| 12 | ||
| 13 | ||
| 14 | from lib.download_manager import DownloadManager | |
| 15 | ||
| 16 | ||
| 17 | def _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 | ||
| 25 | class 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 |
| … | … | @@ -13,9 +13,7 @@ def test_get_magnet_from_uri_follows_redirect_to_magnet(): |
| 13 | 13 | with patch( |
| 14 | 14 | "lib.utils.debrid.debrid_utils.requests.get", return_value=redirect_response |
| 15 | 15 | ) 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") | |
| 19 | 17 | |
| 20 | 18 | assert magnet.startswith("magnet:?xt=urn:btih:1234567890ABCDEF") |
| 21 | 19 | assert info_hash == "1234567890abcdef1234567890abcdef12345678" |
| … | … | @@ -38,9 +36,7 @@ def test_get_magnet_from_uri_follows_http_redirect_chain(): |
| 38 | 36 | "lib.utils.debrid.debrid_utils.requests.get", |
| 39 | 37 | side_effect=[first_response, second_response], |
| 40 | 38 | ) 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") | |
| 44 | 40 | |
| 45 | 41 | assert magnet.startswith("magnet:?xt=urn:btih:ABCDEF1234567890") |
| 46 | 42 | assert info_hash == "abcdef1234567890abcdef1234567890abcdef12" |
| … | … | @@ -60,9 +56,7 @@ def test_get_magnet_from_uri_preserves_torrent_url_for_playback(): |
| 60 | 56 | "lib.utils.debrid.debrid_utils.extract_torrent_metadata", |
| 61 | 57 | return_value="magnet:?xt=urn:btih:AAAABBBBCCCCDDDDEEEEFFFF0000111122223333&dn=Test", |
| 62 | 58 | ): |
| 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") | |
| 66 | 60 | |
| 67 | 61 | assert magnet.startswith("magnet:?xt=urn:btih:AAAABBBBCCCCDDDDEEEEFFFF") |
| 68 | 62 | assert info_hash == "aaaabbbbccccddddeeeeffff0000111122223333" |
| … | … | @@ -0,0 +1,219 @@ |
| 1 | import threading | |
| 2 | from unittest.mock import MagicMock | |
| 3 | ||
| 4 | from lib.download_manager import DownloadManager | |
| 5 | ||
| 6 | ||
| 7 | class 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 |
| … | … | @@ -0,0 +1,318 @@ |
| 1 | import threading | |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | ||
| 4 | from lib.download_manager import DownloadManager | |
| 5 | from lib.gui.download_manager_window import DownloadManagerWindow | |
| 6 | ||
| 7 | ||
| 8 | class 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 |
| … | … | @@ -1,5 +1,8 @@ |
| 1 | 1 | import importlib |
| 2 | import json | |
| 3 | import os | |
| 2 | 4 | import sys |
| 5 | import tempfile | |
| 3 | 6 | from unittest.mock import MagicMock, patch |
| 4 | 7 | |
| 5 | 8 | |
| … | … | @@ -12,7 +15,9 @@ def _load_downloader_module(): |
| 12 | 15 | def test_normalize_file_name_preserves_valid_video_extension(): |
| 13 | 16 | downloader = _load_downloader_module() |
| 14 | 17 | |
| 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 | ) | |
| 16 | 21 | |
| 17 | 22 | |
| 18 | 23 | def test_normalize_file_name_uses_url_extension_when_title_has_none(): |
| … | … | @@ -49,7 +54,12 @@ def test_handle_download_file_passes_url_to_normalizer(): |
| 49 | 54 | downloader, "normalize_file_name", return_value="Movie.mkv" |
| 50 | 55 | ) as normalize_file_name, patch.object( |
| 51 | 56 | 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 | |
| 53 | 63 | downloader.handle_download_file( |
| 54 | 64 | { |
| 55 | 65 | "destination": "/downloads", |
| … | … | @@ -63,6 +73,621 @@ def test_handle_download_file_passes_url_to_normalizer(): |
| 63 | 73 | url="https://example.com/Movie.mkv", |
| 64 | 74 | destination="/downloads", |
| 65 | 75 | name="Movie.mkv", |
| 76 | registry_id="/downloads/Movie.mkv", | |
| 66 | 77 | ) |
| 67 | 78 | cache_set.assert_called_once_with("/downloads/Movie.mkv", False) |
| 68 | 79 | downloader_instance.run.assert_called_once_with() |
| 80 | ||
| 81 | ||
| 82 | # --- Tests for get_destination_path --- | |
| 83 | ||
| 84 | ||
| 85 | def 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 | ||
| 101 | def 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 | ||
| 122 | def 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 | ||
| 149 | def 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 | ||
| 178 | def 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 | ||
| 188 | def 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 | ||
| 208 | def 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 | ||
| 231 | def 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 | ||
| 269 | def 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 | ||
| 295 | def 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 | ||
| 309 | def 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 | ||
| 339 | def 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 | ||
| 351 | def 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 | ||
| 362 | def 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 | ||
| 417 | def 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 | ||
| 462 | def 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 | ||
| 517 | def 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 | ||
| 566 | def 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 | ||
| 605 | def 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 | ||
| 636 | def 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 | ||
| 665 | def 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 |
| … | … | @@ -0,0 +1,84 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.clients.tmdb.utils.utils import ( | |
| 4 | filter_excluded_languages, | |
| 5 | get_excluded_languages, | |
| 6 | ) | |
| 7 | ||
| 8 | ||
| 9 | class 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 | ||
| 23 | class 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 |
| … | … | @@ -0,0 +1,480 @@ |
| 1 | """Unit tests for the external scraper client integration. | |
| 2 | ||
| 3 | These tests verify the data-mapping, provider-filtering and result-parsing | |
| 4 | logic without requiring the ``script.module.magneto`` addon to be installed. | |
| 5 | """ | |
| 6 | ||
| 7 | from unittest.mock import MagicMock, patch | |
| 8 | ||
| 9 | import pytest | |
| 10 | ||
| 11 | from lib.clients.external_scraper import _BYTES_PER_GB, ExternalScraperClient | |
| 12 | from lib.domain.torrent import TorrentStream | |
| 13 | from lib.utils.general.utils import Indexer | |
| 14 | ||
| 15 | # --------------------------------------------------------------------------- | |
| 16 | # Fixtures | |
| 17 | # --------------------------------------------------------------------------- | |
| 18 | ||
| 19 | ||
| 20 | @pytest.fixture | |
| 21 | def 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 | |
| 31 | def 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 | ||
| 43 | class 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 | ||
| 61 | def 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 | ||
| 78 | class 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 | ||
| 137 | class 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 | ||
| 257 | class 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 | ||
| 312 | class 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 | ||
| 378 | class 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 | ||
| 448 | class 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 |
| … | … | @@ -18,7 +18,9 @@ def test_extras_trailer_button_uses_play_trailer_route(): |
| 18 | 18 | with patch( |
| 19 | 19 | "lib.gui.extras_window.build_url", |
| 20 | 20 | 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: | |
| 22 | 24 | window._handle_click(11) |
| 23 | 25 | |
| 24 | 26 | build_url_mock.assert_called_once_with( |
| … | … | @@ -1,15 +1,24 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 1 | 3 | import pytest |
| 4 | ||
| 2 | 5 | from 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, | |
| 7 | 8 | extract_publish_date, |
| 8 | 9 | extract_release_group, |
| 9 | unicode_flag_to_country_code, | |
| 10 | is_video, | |
| 11 | get_random_color, | |
| 12 | 10 | 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, | |
| 13 | 22 | ) |
| 14 | 23 | |
| 15 | 24 | |
| … | … | @@ -93,3 +102,286 @@ def test_get_random_color(): |
| 93 | 102 | ) |
| 94 | 103 | def test_format_season_episode(season, episode, expected): |
| 95 | 104 | assert format_season_episode(season, episode) == expected |
| 105 | ||
| 106 | ||
| 107 | def 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 | ||
| 121 | def 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 | ||
| 131 | class 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 | ||
| 156 | class 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 | ||
| 202 | class 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 | ||
| 280 | class 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"] |
| … | … | @@ -1,15 +1,14 @@ |
| 1 | import pytest | |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | 1 | import os |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | ||
| 4 | import pytest | |
| 4 | 5 | |
| 5 | 6 | from lib.clients.jackett import Jackett |
| 6 | 7 | from lib.domain.torrent import TorrentStream |
| 7 | 8 | |
| 8 | 9 | # Load fixture data |
| 9 | FIXTURE_PATH = os.path.join( | |
| 10 | os.path.dirname(__file__), "../fixtures/jackett_response.xml" | |
| 11 | ) | |
| 12 | with open(FIXTURE_PATH, "r") as f: | |
| 10 | FIXTURE_PATH = os.path.join(os.path.dirname(__file__), "../fixtures/jackett_response.xml") | |
| 11 | with open(FIXTURE_PATH) as f: | |
| 13 | 12 | XML_RESPONSE = f.read() |
| 14 | 13 | |
| 15 | 14 |
| … | … | @@ -1,44 +1,96 @@ |
| 1 | import pytest | |
| 1 | 2 | from unittest.mock import patch |
| 2 | 3 | |
| 3 | 4 | from lib.utils.kodi import utils |
| 4 | 5 | |
| 5 | 6 | |
| 6 | 7 | def 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( | |
| 8 | 11 | utils.xbmcaddon, |
| 9 | 12 | "Addon", |
| 10 | 13 | ) as addon_cls: |
| 11 | 14 | assert utils.is_youtube_addon_enabled() is False |
| 12 | 15 | |
| 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})") | |
| 16 | 17 | addon_cls.assert_not_called() |
| 17 | 18 | |
| 18 | 19 | |
| 19 | 20 | def 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( | |
| 21 | 24 | utils.xbmcaddon, |
| 22 | 25 | "Addon", |
| 23 | 26 | return_value=object(), |
| 24 | 27 | ) as addon_cls: |
| 25 | 28 | assert utils.is_youtube_addon_enabled() is True |
| 26 | 29 | |
| 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})") | |
| 30 | 31 | addon_cls.assert_called_once_with(utils.YOUTUBE_ADDON_ID) |
| 31 | 32 | |
| 32 | 33 | |
| 33 | 34 | def 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( | |
| 35 | 38 | utils.xbmcaddon, |
| 36 | 39 | "Addon", |
| 37 | 40 | side_effect=RuntimeError, |
| 38 | 41 | ) as addon_cls: |
| 39 | 42 | assert utils.is_youtube_addon_enabled() is False |
| 40 | 43 | |
| 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})") | |
| 44 | 45 | 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 | ) | |
| 82 | def 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 | ) | |
| 95 | def test_convert_size_to_bytes_invalid(input_str): | |
| 96 | assert utils.convert_size_to_bytes(input_str) == 0 |
| … | … | @@ -18,19 +18,37 @@ def test_library_menu_adds_clear_context_menu(monkeypatch): |
| 18 | 18 | monkeypatch.setattr( |
| 19 | 19 | library_history, |
| 20 | 20 | "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)), | |
| 22 | 28 | ) |
| 23 | 29 | 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 | ) | |
| 25 | 35 | monkeypatch.setattr( |
| 26 | 36 | library_history, |
| 27 | 37 | "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()) | |
| 31 | 50 | ), |
| 32 | 51 | ) |
| 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()))) | |
| 34 | 52 | monkeypatch.setattr( |
| 35 | 53 | library_history, |
| 36 | 54 | "add_directory_items_batch", |
| … | … | @@ -41,8 +59,18 @@ def test_library_menu_adds_clear_context_menu(monkeypatch): |
| 41 | 59 | |
| 42 | 60 | assert added[0].label == "My TvShows" |
| 43 | 61 | 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 | ] | |
| 46 | 74 | assert end_calls == [{"cache": False}] |
| 47 | 75 | |
| 48 | 76 |
| … | … | @@ -8,7 +8,9 @@ def test_get_library_entries_prioritizes_stremio_metadata(monkeypatch): |
| 8 | 8 | monkeypatch.setattr(library.cache, "set", lambda *args, **kwargs: None) |
| 9 | 9 | |
| 10 | 10 | 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 | ) | |
| 12 | 14 | |
| 13 | 15 | entries = library._get_library_entries( |
| 14 | 16 | [ |
| … | … | @@ -101,16 +103,26 @@ def test_show_library_items_renders_stremio_entry_without_tmdb(monkeypatch): |
| 101 | 103 | def setProperty(self, key, value): |
| 102 | 104 | self.properties[key] = value |
| 103 | 105 | |
| 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 | ) | |
| 105 | 111 | monkeypatch.setattr(library, "set_pluging_category", lambda *args, **kwargs: None) |
| 106 | 112 | monkeypatch.setattr(library, "setContent", lambda *args, **kwargs: None) |
| 107 | 113 | 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 | ) | |
| 109 | 119 | monkeypatch.setattr(library, "set_media_infoTag", lambda *args, **kwargs: None) |
| 110 | 120 | monkeypatch.setattr( |
| 111 | 121 | library, |
| 112 | 122 | "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 | ), | |
| 114 | 126 | ) |
| 115 | 127 | monkeypatch.setattr( |
| 116 | 128 | library.PickleDatabase, |
| … | … | @@ -146,7 +158,9 @@ def test_library_treats_entries_with_stremio_route_fields_as_stremio(monkeypatch |
| 146 | 158 | monkeypatch.setattr(library.cache, "set", lambda *args, **kwargs: None) |
| 147 | 159 | |
| 148 | 160 | 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 | ) | |
| 150 | 164 | |
| 151 | 165 | entries = library._get_library_entries( |
| 152 | 166 | [ |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 1 | from unittest.mock import patch | |
| 2 | 2 | |
| 3 | from lib.utils.views import library | |
| 4 | 3 | from lib.utils.general.utils import add_to_library, remove_from_library |
| 4 | from lib.utils.views import library | |
| 5 | 5 | |
| 6 | 6 | |
| 7 | 7 | def test_get_library_entries_uses_cached_materialized_list(): |
| … | … | @@ -6,10 +6,9 @@ from lib.nav import debrid as debrid_navigation |
| 6 | 6 | def test_cloud_details_uses_registry_actions_for_realdebrid(): |
| 7 | 7 | with patch("lib.nav.debrid.addDirectoryItem") as add_directory_item, patch( |
| 8 | 8 | "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, | |
| 13 | 12 | ): |
| 14 | 13 | debrid_navigation.cloud_details({"debrid_name": debrid_navigation.DebridType.RD}) |
| 15 | 14 | |
| … | … | @@ -17,6 +16,19 @@ def test_cloud_details_uses_registry_actions_for_realdebrid(): |
| 17 | 16 | assert add_directory_item.call_args_list[0].args[1] == "get_rd_downloads" |
| 18 | 17 | |
| 19 | 18 | |
| 19 | def 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 | ||
| 20 | 32 | def test_download_notifies_on_unknown_debrid_type(): |
| 21 | 33 | with patch("lib.nav.debrid.notification") as notify, patch( |
| 22 | 34 | "lib.nav.debrid.translation", return_value="Unsupported debrid type" |
| … | … | @@ -6,12 +6,11 @@ from lib.nav import library_history |
| 6 | 6 | def test_history_menu_renders_history_items(): |
| 7 | 7 | with patch("lib.nav.library_history.set_pluging_category") as set_category, patch( |
| 8 | 8 | "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( | |
| 12 | 10 | "lib.nav.library_history.build_url", side_effect=lambda action, **kwargs: action |
| 13 | 11 | ), 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, | |
| 15 | 14 | ): |
| 16 | 15 | library_history.history_menu({}) |
| 17 | 16 |
| … | … | @@ -0,0 +1,441 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | import requests | |
| 4 | ||
| 5 | from lib.api.debrid.offcloud import Offcloud | |
| 6 | from lib.clients.debrid.offcloud import OffcloudHelper | |
| 7 | from lib.domain.torrent import TorrentStream | |
| 8 | from lib.services.debrid.auth import run_offcloud_auth | |
| 9 | from lib.utils.general.utils import DebridType, IndexerType | |
| 10 | ||
| 11 | ||
| 12 | class 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 | ||
| 34 | def 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 | ||
| 57 | def 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 | ||
| 67 | def 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 | ||
| 94 | def 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 | ||
| 122 | def 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 | ||
| 144 | def 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 | ||
| 164 | def 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 | ||
| 181 | def 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") | |
| 217 | def 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") | |
| 275 | def 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") | |
| 318 | def 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") | |
| 363 | def 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") | |
| 409 | def 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") |
| … | … | @@ -1,13 +1,19 @@ |
| 1 | from pathlib import Path | |
| 1 | import importlib | |
| 2 | 2 | import re |
| 3 | ||
| 3 | import sys | |
| 4 | from datetime import date | |
| 5 | from pathlib import Path | |
| 6 | from types import SimpleNamespace | |
| 7 | from unittest.mock import MagicMock, call | |
| 4 | 8 | |
| 5 | 9 | PLAYER_PATH = Path(__file__).resolve().parents[2] / "lib" / "player.py" |
| 6 | 10 | |
| 7 | 11 | |
| 8 | 12 | def test_monitor_finally_uses_non_destructive_cleanup(): |
| 9 | 13 | 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 | ) | |
| 11 | 17 | |
| 12 | 18 | assert monitor_match is not None |
| 13 | 19 | |
| … | … | @@ -17,11 +23,10 @@ def test_monitor_finally_uses_non_destructive_cleanup(): |
| 17 | 23 | assert finally_match is not None |
| 18 | 24 | |
| 19 | 25 | finally_body = finally_match.group("body") |
| 20 | assert "self._cleanup_playback_session()" in finally_body | |
| 21 | 26 | assert "self.cancel_playback()" not in finally_body |
| 22 | 27 | |
| 23 | 28 | |
| 24 | def test_cancel_playback_still_invalidates_resolved_url(): | |
| 29 | def test_cancel_playback_signals_kodi_and_stops_player(): | |
| 25 | 30 | source = PLAYER_PATH.read_text() |
| 26 | 31 | cancel_match = re.search( |
| 27 | 32 | r"def cancel_playback\(self\):(?P<body>.*?)def _cleanup_playback_session", |
| … | … | @@ -33,4 +38,658 @@ def test_cancel_playback_still_invalidates_resolved_url(): |
| 33 | 38 | |
| 34 | 39 | cancel_body = cancel_match.group("body") |
| 35 | 40 | assert "self._cleanup_playback_session()" in cancel_body |
| 41 | # Must signal Kodi to avoid spinners (setResolvedUrl with False) | |
| 36 | 42 | 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 | ||
| 47 | def 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 | ||
| 65 | def 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 | ||
| 75 | def _episode(number, name="Episode", air_date="2020-01-01"): | |
| 76 | return SimpleNamespace(episode_number=number, name=name, air_date=air_date) | |
| 77 | ||
| 78 | ||
| 79 | def _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 | ||
| 89 | def _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 | ||
| 102 | def _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 | ||
| 119 | class 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 | ||
| 129 | def 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 | ||
| 151 | def 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 | ||
| 176 | def 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 | ||
| 199 | def _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 | ||
| 218 | def 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 | ||
| 239 | def 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 | ||
| 265 | def 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 | ||
| 277 | def 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 | ||
| 291 | def 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 | ||
| 318 | def 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 | ||
| 342 | def 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 | ||
| 351 | def 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 | ||
| 363 | def 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 | ||
| 380 | def 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 | ||
| 404 | def 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 | ||
| 432 | def 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 | ||
| 449 | def 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 | ||
| 478 | def 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 | ||
| 493 | def 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 | ||
| 524 | def 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 | ||
| 553 | def 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 | ||
| 566 | def 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 | ||
| 580 | def 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 | ||
| 598 | def 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 | ||
| 623 | def 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 | ||
| 647 | def 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} |
| … | … | @@ -0,0 +1,105 @@ |
| 1 | from unittest.mock import patch | |
| 2 | from urllib.parse import quote | |
| 3 | ||
| 4 | ||
| 5 | def 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 | ||
| 23 | def 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 | ||
| 43 | def 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 | ||
| 54 | def 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 | ||
| 65 | def 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 | ||
| 88 | def 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" |
| … | … | @@ -0,0 +1,469 @@ |
| 1 | import sys | |
| 2 | from types import ModuleType | |
| 3 | from unittest.mock import MagicMock, call | |
| 4 | ||
| 5 | import pytest | |
| 6 | ||
| 7 | ||
| 8 | @pytest.fixture(autouse=True) | |
| 9 | def 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 | ||
| 22 | def _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 | ||
| 39 | def 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 | ||
| 57 | def 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 | ||
| 86 | def 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 | ||
| 108 | def 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 | ||
| 142 | def 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 | ||
| 167 | def 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 | ||
| 188 | def 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 | ||
| 213 | def 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 | ||
| 240 | def 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 | ||
| 257 | def 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 | ||
| 274 | def 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 | ||
| 300 | def 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 | ||
| 319 | def 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 | ||
| 339 | def 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 | ||
| 389 | def _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 | ||
| 408 | def 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 | ||
| 422 | def 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 | ||
| 437 | def 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 | ||
| 452 | def 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() |
| … | … | @@ -78,3 +78,74 @@ def test_pre_process_excludes_unknown_quality_when_disabled(): |
| 78 | 78 | filtered = pre_process(results, mode="movies", episode_name="", episode=0, season=0) |
| 79 | 79 | |
| 80 | 80 | assert [item.title for item in filtered] == ["Movie.2024.1080p.WEB-DL"] |
| 81 | ||
| 82 | ||
| 83 | def 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 | ||
| 118 | def 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 | ] |
| … | … | @@ -0,0 +1,214 @@ |
| 1 | from unittest.mock import patch | |
| 2 | ||
| 3 | from lib.domain.torrent import TorrentStream | |
| 4 | from lib.utils.general.processors import PreProcessBuilder | |
| 5 | ||
| 6 | ||
| 7 | def 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 | ||
| 23 | def 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 | ||
| 48 | def 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 | ||
| 72 | def 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 | ||
| 96 | def 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 | ||
| 120 | def 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 | ||
| 145 | def 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 | ||
| 169 | def 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 | ||
| 193 | def 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 | ] |
| … | … | @@ -1,6 +1,7 @@ |
| 1 | import pytest | |
| 2 | 1 | from unittest.mock import MagicMock, patch |
| 3 | 2 | |
| 3 | import pytest | |
| 4 | ||
| 4 | 5 | from lib.clients.prowlarr import Prowlarr |
| 5 | 6 | |
| 6 | 7 | |
| … | … | @@ -63,9 +64,7 @@ def test_prowlarr_search_variant_mapping(prowlarr_client): |
| 63 | 64 | |
| 64 | 65 | with patch("lib.clients.prowlarr.get_prowlarr_timeout", return_value=10): |
| 65 | 66 | # 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) | |
| 69 | 68 | |
| 70 | 69 | call_args = mock_get.call_args |
| 71 | 70 | params = call_args[1]["params"] |
| … | … | @@ -1,24 +1,21 @@ |
| 1 | import pytest | |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | import os | |
| 4 | 1 | import json |
| 2 | import os | |
| 3 | from unittest.mock import MagicMock, patch | |
| 4 | ||
| 5 | import pytest | |
| 5 | 6 | |
| 6 | 7 | from lib.api.debrid.realdebrid import RealDebrid |
| 7 | 8 | |
| 8 | 9 | # Load fixture data |
| 9 | FIXTURE_PATH = os.path.join( | |
| 10 | os.path.dirname(__file__), "../fixtures/realdebrid_response.json" | |
| 11 | ) | |
| 12 | with open(FIXTURE_PATH, "r") as f: | |
| 10 | FIXTURE_PATH = os.path.join(os.path.dirname(__file__), "../fixtures/realdebrid_response.json") | |
| 11 | with open(FIXTURE_PATH) as f: | |
| 13 | 12 | JSON_DATA = json.load(f) |
| 14 | 13 | |
| 15 | 14 | |
| 16 | 15 | @pytest.fixture |
| 17 | 16 | def rd_client(): |
| 18 | 17 | 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"}): | |
| 22 | 19 | client = RealDebrid(token="test_token", session=mock_session) |
| 23 | 20 | client.headers = {"Authorization": f"Bearer {client.token}"} |
| 24 | 21 | return client |
| … | … | @@ -1,6 +1,7 @@ |
| 1 | import pytest | |
| 2 | 1 | from unittest.mock import MagicMock |
| 3 | 2 | |
| 3 | import pytest | |
| 4 | ||
| 4 | 5 | from lib.api.debrid.base import ProviderException |
| 5 | 6 | from lib.clients.debrid.realdebrid import RealDebridHelper |
| 6 | 7 | |
| … | … | @@ -19,7 +20,7 @@ def test_get_link_multi_file_movie_uses_largest_selected_file(): |
| 19 | 20 | ], |
| 20 | 21 | } |
| 21 | 22 | helper.client.create_download_link.side_effect = lambda link: { |
| 22 | "download": "https://download/{}".format(link) | |
| 23 | "download": f"https://download/{link}" | |
| 23 | 24 | } |
| 24 | 25 | |
| 25 | 26 | result = helper.get_link("info-hash", {}) |
| … | … | @@ -2,6 +2,8 @@ import importlib |
| 2 | 2 | import sys |
| 3 | 3 | from unittest.mock import patch |
| 4 | 4 | |
| 5 | import pytest | |
| 6 | ||
| 5 | 7 | |
| 6 | 8 | def _load_router_module(): |
| 7 | 9 | if "lib.router" in sys.modules: |
| … | … | @@ -13,6 +15,8 @@ def test_get_route_handler_returns_debrid_dispatcher(): |
| 13 | 15 | router = _load_router_module() |
| 14 | 16 | |
| 15 | 17 | 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 | |
| 16 | 20 | |
| 17 | 21 | |
| 18 | 22 | def test_get_route_handler_returns_cache_dispatcher(): |
| … | … | @@ -27,6 +31,16 @@ def test_get_route_handler_returns_download_dispatcher_for_handle_download_file( |
| 27 | 31 | assert router._get_route_handler("handle_download_file") is router._route_downloads |
| 28 | 32 | |
| 29 | 33 | |
| 34 | @pytest.mark.parametrize( | |
| 35 | "action", | |
| 36 | ["rename_stremio_addon", "rename_stremio_catalog"], | |
| 37 | ) | |
| 38 | def 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 | ||
| 30 | 44 | def test_get_route_handler_falls_back_to_core_dispatcher(): |
| 31 | 45 | router = _load_router_module() |
| 32 | 46 | |
| … | … | @@ -50,9 +64,7 @@ def test_addon_router_uses_grouped_dispatcher(): |
| 50 | 64 | "lib.router._route_debrid" |
| 51 | 65 | ) as route_debrid, patch( |
| 52 | 66 | "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"): | |
| 56 | 68 | router.addon_router() |
| 57 | 69 | |
| 58 | 70 | get_route_handler.assert_called_once_with("rd_auth") |
| … | … | @@ -88,3 +100,38 @@ def test_route_downloads_dispatches_handle_download_file(): |
| 88 | 100 | router._route_downloads("handle_download_file", params) |
| 89 | 101 | |
| 90 | 102 | handle_download_file.assert_called_once_with(params) |
| 103 | ||
| 104 | ||
| 105 | def 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 | ||
| 111 | def 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 | ||
| 120 | def 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 | ||
| 130 | def 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) |
| … | … | @@ -1,7 +1,17 @@ |
| 1 | from unittest.mock import patch | |
| 1 | import json | |
| 2 | from unittest.mock import MagicMock, patch | |
| 2 | 3 | |
| 3 | 4 | from lib.api.tmdbv3api.as_obj import AsObj |
| 4 | from lib.search import SearchVariant, _build_title_fallback_queries, show_source_select | |
| 5 | from 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 | ) | |
| 14 | from lib.utils.general.utils import Indexer | |
| 5 | 15 | |
| 6 | 16 | |
| 7 | 17 | def test_search_variant_values(): |
| … | … | @@ -23,9 +33,7 @@ def test_build_title_fallback_queries_default_variant_uses_tmdb_titles(): |
| 23 | 33 | } |
| 24 | 34 | ) |
| 25 | 35 | |
| 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): | |
| 29 | 37 | queries = _build_title_fallback_queries( |
| 30 | 38 | "Ataque a los Titanes", |
| 31 | 39 | {"tmdb_id": "1429"}, |
| … | … | @@ -47,9 +55,7 @@ def test_build_title_fallback_queries_supports_variants_and_year(): |
| 47 | 55 | } |
| 48 | 56 | ) |
| 49 | 57 | |
| 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): | |
| 53 | 59 | title_year_queries = _build_title_fallback_queries( |
| 54 | 60 | "Ataque a los Titanes", |
| 55 | 61 | {"tmdb_id": "1429"}, |
| … | … | @@ -71,7 +77,11 @@ def test_build_title_fallback_queries_supports_variants_and_year(): |
| 71 | 77 | year=2013, |
| 72 | 78 | ) |
| 73 | 79 | |
| 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 | ] | |
| 75 | 85 | assert original_title_queries == ["Shingeki no Kyojin"] |
| 76 | 86 | assert original_title_year_queries == ["Shingeki no Kyojin 2013"] |
| 77 | 87 | |
| … | … | @@ -96,3 +106,506 @@ def test_show_source_select_passes_year_from_media_metadata(): |
| 96 | 106 | item_info = source_select_mock.call_args[0][0] |
| 97 | 107 | assert item_info["year"] == 2010 |
| 98 | 108 | assert item_info["query"] == "Inception" |
| 109 | ||
| 110 | ||
| 111 | def 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 | ||
| 134 | def 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 | ||
| 158 | def 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 | ||
| 182 | def 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 | ||
| 215 | def 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 | ||
| 222 | def 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") | |
| 229 | def 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 | ||
| 241 | def 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") | |
| 249 | def 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") | |
| 266 | def 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") | |
| 295 | def 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") | |
| 339 | def 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 | ||
| 393 | class 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 | ||
| 455 | class 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() |
| … | … | @@ -0,0 +1,68 @@ |
| 1 | from concurrent.futures import Future | |
| 2 | from unittest.mock import MagicMock, patch | |
| 3 | ||
| 4 | from lib.gui.search_status_window import SearchStatusWindow, SearchTask, SearchTaskManager | |
| 5 | ||
| 6 | ||
| 7 | def 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 | ||
| 28 | def 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 | ||
| 51 | def 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() |
| … | … | @@ -3,17 +3,17 @@ from unittest.mock import patch |
| 3 | 3 | from lib.api.tmdbv3api.as_obj import AsObj |
| 4 | 4 | from lib.domain.torrent import TorrentStream |
| 5 | 5 | from lib.search import ( |
| 6 | _build_title_fallback_queries, | |
| 7 | _perform_search_with_title_fallback, | |
| 8 | SearchVariant, | |
| 9 | 6 | TITLE_LANGUAGE_ENGLISH_FIRST, |
| 10 | 7 | TITLE_LANGUAGE_ENGLISH_ONLY, |
| 11 | 8 | TITLE_LANGUAGE_LOCALIZED_FIRST, |
| 9 | SearchVariant, | |
| 10 | _build_title_fallback_queries, | |
| 11 | _perform_search_with_title_fallback, | |
| 12 | 12 | ) |
| 13 | 13 | |
| 14 | 14 | |
| 15 | 15 | def test_search_variant_enum_values(): |
| 16 | if not hasattr(SearchVariant, '__members__'): | |
| 16 | if not hasattr(SearchVariant, "__members__"): | |
| 17 | 17 | return |
| 18 | 18 | assert SearchVariant.DEFAULT.value == "default" |
| 19 | 19 | assert SearchVariant.TITLE_YEAR.value == "title_year" |
| … | … | @@ -33,9 +33,7 @@ def test_build_title_fallback_queries_default_variant_uses_fallback_behavior(): |
| 33 | 33 | } |
| 34 | 34 | ) |
| 35 | 35 | |
| 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): | |
| 39 | 37 | queries = _build_title_fallback_queries( |
| 40 | 38 | "Intocable", |
| 41 | 39 | {"tmdb_id": "77338"}, |
| … | … | @@ -71,9 +69,7 @@ def test_build_title_fallback_queries_original_title_uses_tmdb_original(): |
| 71 | 69 | } |
| 72 | 70 | ) |
| 73 | 71 | |
| 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): | |
| 77 | 73 | queries = _build_title_fallback_queries( |
| 78 | 74 | "Spirited Away", |
| 79 | 75 | {"tmdb_id": "129"}, |
| … | … | @@ -96,9 +92,7 @@ def test_build_title_fallback_queries_original_title_year_appends_year(): |
| 96 | 92 | } |
| 97 | 93 | ) |
| 98 | 94 | |
| 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): | |
| 102 | 96 | queries = _build_title_fallback_queries( |
| 103 | 97 | "Spirited Away", |
| 104 | 98 | {"tmdb_id": "129"}, |
| … | … | @@ -134,9 +128,7 @@ def test_build_title_fallback_queries_original_title_fallbacks_to_query(): |
| 134 | 128 | } |
| 135 | 129 | ) |
| 136 | 130 | |
| 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): | |
| 140 | 132 | queries = _build_title_fallback_queries( |
| 141 | 133 | "Some Movie", |
| 142 | 134 | {"tmdb_id": "123"}, |
| … | … | @@ -163,9 +155,7 @@ def test_build_title_fallback_queries_prefers_english_translation_for_movies(): |
| 163 | 155 | } |
| 164 | 156 | ) |
| 165 | 157 | |
| 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): | |
| 169 | 159 | queries = _build_title_fallback_queries( |
| 170 | 160 | "Intocable", |
| 171 | 161 | {"tmdb_id": "77338"}, |
| … | … | @@ -192,9 +182,7 @@ def test_build_title_fallback_queries_english_first_reorders_candidates(): |
| 192 | 182 | } |
| 193 | 183 | ) |
| 194 | 184 | |
| 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): | |
| 198 | 186 | queries = _build_title_fallback_queries( |
| 199 | 187 | "Intocable", |
| 200 | 188 | {"tmdb_id": "77338"}, |
| … | … | @@ -217,9 +205,7 @@ def test_build_title_fallback_queries_english_first_falls_back_to_original_befor |
| 217 | 205 | } |
| 218 | 206 | ) |
| 219 | 207 | |
| 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): | |
| 223 | 209 | queries = _build_title_fallback_queries( |
| 224 | 210 | "Avatar: Fuego y ceniza", |
| 225 | 211 | {"tmdb_id": "123456"}, |
| … | … | @@ -246,9 +232,7 @@ def test_build_title_fallback_queries_english_only_skips_localized_query(): |
| 246 | 232 | } |
| 247 | 233 | ) |
| 248 | 234 | |
| 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): | |
| 252 | 236 | queries = _build_title_fallback_queries( |
| 253 | 237 | "Intocable", |
| 254 | 238 | {"tmdb_id": "77338"}, |
| … | … | @@ -275,12 +259,8 @@ def test_build_title_fallback_queries_uses_original_name_when_needed_for_tv(): |
| 275 | 259 | } |
| 276 | 260 | ) |
| 277 | 261 | |
| 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") | |
| 284 | 264 | |
| 285 | 265 | assert queries == [ |
| 286 | 266 | "Ataque a los Titanes", |
| … | … | @@ -229,21 +229,23 @@ def test_apply_backup_payload_replaces_custom_stremio_addons(monkeypatch, tmp_pa |
| 229 | 229 | assert ("trakt_token", "tok") in property_calls |
| 230 | 230 | assert ("trakt_refresh", "ref") in property_calls |
| 231 | 231 | 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 | ] | |
| 233 | 236 | assert settings_backup.decode_selected_ids( |
| 234 | 237 | fake_cache.store[settings_backup.STREMIO_ADDONS_KEY] |
| 235 | 238 | ) == [account_key, new_key] |
| 236 | 239 | assert settings_backup.decode_selected_ids( |
| 237 | 240 | fake_cache.store[settings_backup.STREMIO_ADDONS_CATALOGS_KEY] |
| 238 | 241 | ) == [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 | ) | |
| 242 | 246 | |
| 243 | 247 | |
| 244 | def test_apply_backup_payload_clears_scrubbed_fields_and_custom_addons( | |
| 245 | monkeypatch, tmp_path | |
| 246 | ): | |
| 248 | def test_apply_backup_payload_clears_scrubbed_fields_and_custom_addons(monkeypatch, tmp_path): | |
| 247 | 249 | settings_path = _write_settings_xml( |
| 248 | 250 | tmp_path, |
| 249 | 251 | """ |
| … | … | @@ -319,14 +321,15 @@ def test_apply_backup_payload_clears_scrubbed_fields_and_custom_addons( |
| 319 | 321 | assert settings_backup.decode_selected_ids( |
| 320 | 322 | fake_cache.store[settings_backup.STREMIO_ADDONS_KEY] |
| 321 | 323 | ) == [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 | ) | |
| 325 | 330 | |
| 326 | 331 | |
| 327 | def test_reset_all_settings_restores_defaults_and_clears_custom_addons( | |
| 328 | monkeypatch, tmp_path | |
| 329 | ): | |
| 332 | def test_reset_all_settings_restores_defaults_and_clears_custom_addons(monkeypatch, tmp_path): | |
| 330 | 333 | settings_path = _write_settings_xml( |
| 331 | 334 | tmp_path, |
| 332 | 335 | """ |
| … | … | @@ -390,9 +393,12 @@ def test_reset_all_settings_restores_defaults_and_clears_custom_addons( |
| 390 | 393 | assert settings_backup.decode_selected_ids( |
| 391 | 394 | fake_cache.store[settings_backup.STREMIO_ADDONS_KEY] |
| 392 | 395 | ) == [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 | ) | |
| 396 | 402 | |
| 397 | 403 | |
| 398 | 404 | def 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 |
| 435 | 441 | lambda setting_id, value: property_calls.append((setting_id, value)), |
| 436 | 442 | ) |
| 437 | 443 | 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 | ) | |
| 439 | 447 | 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 | ) | |
| 443 | 457 | monkeypatch.setattr(settings_backup, "PickleDatabase", lambda: fake_pickle_db) |
| 444 | 458 | |
| 445 | 459 | settings_backup.factory_reset(settings_xml_path=settings_path) |
| … | … | @@ -0,0 +1,17 @@ |
| 1 | from pathlib import Path | |
| 2 | import xml.etree.ElementTree as ET | |
| 3 | ||
| 4 | ||
| 5 | SETTINGS_XML = Path(__file__).resolve().parents[2] / "resources" / "settings.xml" | |
| 6 | ||
| 7 | ||
| 8 | def test_settings_xml_parses(): | |
| 9 | ET.parse(SETTINGS_XML) | |
| 10 | ||
| 11 | ||
| 12 | def 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 == [] |
| … | … | @@ -8,9 +8,7 @@ def test_on_init_starts_background_monitor_thread(mock_thread_cls): |
| 8 | 8 | thread = MagicMock() |
| 9 | 9 | mock_thread_cls.return_value = thread |
| 10 | 10 | |
| 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") | |
| 14 | 12 | window.setProperty = MagicMock() |
| 15 | 13 | |
| 16 | 14 | window.onInit() |
| … | … | @@ -26,19 +26,26 @@ def test_download_file_uses_episode_filename_when_tv_data_is_present(): |
| 26 | 26 | "title": "Game of Thrones", |
| 27 | 27 | "url": "https://example.com/files/game.of.thrones.mkv", |
| 28 | 28 | "tv_data": {"season": "1", "episode": "1"}, |
| 29 | "mode": "movie", | |
| 29 | 30 | } |
| 30 | 31 | |
| 31 | 32 | with patch.object( |
| 32 | 33 | source_select_module.SourceSelect, |
| 33 | 34 | "_ensure_playback_info", |
| 34 | 35 | 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( | |
| 38 | 41 | source_select_module.xbmc, "executebuiltin" |
| 39 | 42 | ) as executebuiltin: |
| 40 | 43 | source_select._download_file(selected_source) |
| 41 | 44 | |
| 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" | |
| 42 | 49 | action_url_run.assert_called_once_with( |
| 43 | 50 | "handle_download_file", |
| 44 | 51 | file_name="Game of Thrones - S01E01", |
| … | … | @@ -61,19 +68,26 @@ def test_download_file_uses_year_for_movie_filename(): |
| 61 | 68 | playback_info = { |
| 62 | 69 | "title": "Project Hail Mary", |
| 63 | 70 | "url": "https://example.com/files/project.hail.mary.mkv", |
| 71 | "mode": "movie", | |
| 64 | 72 | } |
| 65 | 73 | |
| 66 | 74 | with patch.object( |
| 67 | 75 | source_select_module.SourceSelect, |
| 68 | 76 | "_ensure_playback_info", |
| 69 | 77 | 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( | |
| 73 | 83 | source_select_module.xbmc, "executebuiltin" |
| 74 | 84 | ) as executebuiltin: |
| 75 | 85 | source_select._download_file(selected_source) |
| 76 | 86 | |
| 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" | |
| 77 | 91 | action_url_run.assert_called_once_with( |
| 78 | 92 | "handle_download_file", |
| 79 | 93 | file_name="Project Hail Mary (2025)", |
| … | … | @@ -130,13 +144,156 @@ def test_populate_sources_list_shows_debrid_type_for_stremio_debrid_sources(): |
| 130 | 144 | "badges": "", |
| 131 | 145 | "release_group": "", |
| 132 | 146 | }, |
| 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( | |
| 134 | 150 | 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]"): | |
| 140 | 156 | source_select.populate_sources_list() |
| 141 | 157 | |
| 142 | 158 | assert source_select.display_list.items[0].properties["type"] == DebridType.TB |
| 159 | ||
| 160 | ||
| 161 | def 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 | ||
| 242 | def 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 | ||
| 279 | def 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) |
| … | … | @@ -0,0 +1,50 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.clients.tmdb.utils.utils import get_movie_keywords | |
| 4 | ||
| 5 | ||
| 6 | @patch("lib.clients.tmdb.utils.utils.tmdb_get") | |
| 7 | def 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") | |
| 22 | def 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") | |
| 31 | def 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") | |
| 40 | def 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"] |
| … | … | @@ -1,13 +1,17 @@ |
| 1 | 1 | import json |
| 2 | 2 | from unittest.mock import MagicMock |
| 3 | 3 | |
| 4 | from lib import search | |
| 4 | 5 | from lib.api.stremio.addon_manager import AddonManager |
| 5 | 6 | from lib.api.stremio.models import Meta, MetaPreview |
| 6 | from lib.clients.stremio import helpers | |
| 7 | from lib.clients.stremio import addon_client, addon_selection, catalog_menus, helpers | |
| 7 | 8 | from lib.clients.stremio.catalog_menus import CATALOG_PAGE_SIZE |
| 8 | from lib.clients.stremio import catalog_menus | |
| 9 | from lib.clients.stremio.constants import ( | |
| 10 | STREMIO_ADDON_ALIASES_KEY, | |
| 11 | STREMIO_CATALOG_ALIASES_KEY, | |
| 12 | ) | |
| 9 | 13 | from lib.domain.torrent import TorrentStream |
| 10 | from lib import search | |
| 14 | from lib.utils.debrid import debrid_utils | |
| 11 | 15 | |
| 12 | 16 | |
| 13 | 17 | def 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): |
| 80 | 84 | |
| 81 | 85 | processed_batches = [] |
| 82 | 86 | |
| 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 | ) | |
| 87 | 95 | monkeypatch.setattr(search, "pre_process_results", lambda results, *args, **kwargs: results) |
| 88 | 96 | monkeypatch.setattr( |
| 89 | 97 | search, |
| … | … | @@ -107,6 +115,56 @@ def test_process_search_results_bypasses_exact_addon_instance(monkeypatch): |
| 107 | 115 | assert processed_batches == [[native_result]] |
| 108 | 116 | |
| 109 | 117 | |
| 118 | def 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 | ||
| 110 | 168 | def test_get_addons_uses_cached_account_addons_before_settings_refresh(monkeypatch): |
| 111 | 169 | custom_addon = { |
| 112 | 170 | "manifest": {"id": "custom.one", "name": "Custom One"}, |
| … | … | @@ -131,6 +189,269 @@ def test_get_addons_uses_cached_account_addons_before_settings_refresh(monkeypat |
| 131 | 189 | assert addon_ids == ["custom.one", "account.one"] |
| 132 | 190 | |
| 133 | 191 | |
| 192 | def 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 | ||
| 227 | def 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 | ||
| 277 | def 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 | ||
| 315 | def 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 | ||
| 367 | def 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 | ||
| 422 | def 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 | ||
| 134 | 455 | def test_list_catalog_locally_paginates_when_catalog_has_no_skip(monkeypatch): |
| 135 | 456 | metas = [ |
| 136 | 457 | 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): |
| 144 | 465 | "catalogs_get_cache", |
| 145 | 466 | lambda *args, **kwargs: {"metas": metas}, |
| 146 | 467 | ) |
| 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) | |
| 150 | 469 | monkeypatch.setattr( |
| 151 | 470 | catalog_menus, |
| 152 | 471 | "add_meta_items", |
| … | … | @@ -190,9 +509,7 @@ def test_list_catalog_uses_server_skip_when_manifest_supports_it(monkeypatch): |
| 190 | 509 | "catalogs_get_cache", |
| 191 | 510 | lambda path, params, **kwargs: captured_kwargs.update(kwargs) or {"metas": []}, |
| 192 | 511 | ) |
| 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) | |
| 196 | 513 | monkeypatch.setattr(catalog_menus, "setContent", lambda *args, **kwargs: None) |
| 197 | 514 | monkeypatch.setattr(catalog_menus, "end_of_directory", lambda *args, **kwargs: None) |
| 198 | 515 | monkeypatch.setattr(catalog_menus, "notification", lambda *args, **kwargs: None) |
| … | … | @@ -221,18 +538,26 @@ def test_search_catalog_cancel_shows_previous_search_terms(monkeypatch): |
| 221 | 538 | |
| 222 | 539 | monkeypatch.setattr(catalog_menus, "show_keyboard", lambda *args, **kwargs: None) |
| 223 | 540 | 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 | ) | |
| 225 | 546 | monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": _ListItem(label)) |
| 226 | 547 | monkeypatch.setattr( |
| 227 | 548 | catalog_menus, |
| 228 | 549 | "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 | ), | |
| 230 | 553 | ) |
| 231 | 554 | monkeypatch.setattr(catalog_menus, "end_of_directory", lambda *args, **kwargs: None) |
| 232 | 555 | monkeypatch.setattr( |
| 233 | 556 | catalog_menus, |
| 234 | 557 | "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 | ), | |
| 236 | 561 | ) |
| 237 | 562 | |
| 238 | 563 | catalog_menus.search_catalog( |
| … | … | @@ -341,13 +666,29 @@ def test_list_stremio_catalogs_uses_batch_add(monkeypatch): |
| 341 | 666 | return "https://example.com/addon" |
| 342 | 667 | |
| 343 | 668 | 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 | ) | |
| 345 | 680 | 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 | ) | |
| 347 | 686 | monkeypatch.setattr( |
| 348 | 687 | catalog_menus, |
| 349 | 688 | "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 | ), | |
| 351 | 692 | ) |
| 352 | 693 | monkeypatch.setattr( |
| 353 | 694 | catalog_menus, |
| … | … | @@ -357,7 +698,9 @@ def test_list_stremio_catalogs_uses_batch_add(monkeypatch): |
| 357 | 698 | monkeypatch.setattr( |
| 358 | 699 | catalog_menus, |
| 359 | 700 | "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 | ), | |
| 361 | 704 | ) |
| 362 | 705 | |
| 363 | 706 | 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): |
| 368 | 711 | assert labels == ["Search Trending", "Trending", "Popular"] |
| 369 | 712 | |
| 370 | 713 | |
| 714 | def 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 | ||
| 760 | def 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 | ||
| 790 | def 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 | ||
| 806 | def 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 | ||
| 371 | 822 | def test_run_search_entry_preserves_stremio_route_in_library_payload(monkeypatch): |
| 372 | 823 | captured = {} |
| 373 | 824 | |
| … | … | @@ -415,7 +866,11 @@ def test_search_catalog_backfills_missing_menu_type(monkeypatch): |
| 415 | 866 | "catalogs_get_cache", |
| 416 | 867 | lambda *args, **kwargs: {"metas": []}, |
| 417 | 868 | ) |
| 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 | ) | |
| 419 | 874 | monkeypatch.setattr(catalog_menus, "show_keyboard", lambda *args, **kwargs: "paradise") |
| 420 | 875 | monkeypatch.setattr(catalog_menus.cache, "add_to_list", lambda *args, **kwargs: None) |
| 421 | 876 | 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): |
| 447 | 902 | shown = [] |
| 448 | 903 | |
| 449 | 904 | 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 | ) | |
| 451 | 910 | |
| 452 | 911 | params = { |
| 453 | 912 | "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 |
| 651 | 1110 | |
| 652 | 1111 | monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": _ListItem(label)) |
| 653 | 1112 | 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 | ) | |
| 655 | 1118 | monkeypatch.setattr(catalog_menus, "get_addon_by_base_url", lambda *args, **kwargs: None) |
| 656 | 1119 | monkeypatch.setattr(catalog_menus, "addon_has_meta", lambda *args, **kwargs: False) |
| 657 | 1120 | monkeypatch.setattr(catalog_menus, "addon_has_stream", lambda *args, **kwargs: False) |
| 658 | 1121 | 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 | ) | |
| 661 | 1130 | monkeypatch.setattr( |
| 662 | 1131 | catalog_menus, |
| 663 | 1132 | "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 | ], | |
| 665 | 1138 | ) |
| 666 | 1139 | monkeypatch.setattr( |
| 667 | 1140 | catalog_menus, |
| … | … | @@ -670,8 +1143,20 @@ def test_add_meta_items_uses_tmdb_context_menu_only_for_reliable_tmdb_ids(monkey |
| 670 | 1143 | ) |
| 671 | 1144 | |
| 672 | 1145 | 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 | ), | |
| 675 | 1160 | ] |
| 676 | 1161 | |
| 677 | 1162 | catalog_menus.add_meta_items( |
| … | … | @@ -722,19 +1207,31 @@ def test_add_meta_items_resolves_tmdb_id_from_imdb_for_context_menu(monkeypatch) |
| 722 | 1207 | |
| 723 | 1208 | monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": _ListItem(label)) |
| 724 | 1209 | 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 | ) | |
| 726 | 1215 | monkeypatch.setattr(catalog_menus, "get_addon_by_base_url", lambda *args, **kwargs: None) |
| 727 | 1216 | monkeypatch.setattr(catalog_menus, "addon_has_meta", lambda *args, **kwargs: False) |
| 728 | 1217 | monkeypatch.setattr(catalog_menus, "addon_has_stream", lambda *args, **kwargs: False) |
| 729 | 1218 | 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 | ) | |
| 732 | 1227 | monkeypatch.setattr( |
| 733 | 1228 | catalog_menus.cache, |
| 734 | 1229 | "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 | ), | |
| 738 | 1235 | ) |
| 739 | 1236 | |
| 740 | 1237 | def _movie_context_menu(**kwargs): |
| … | … | @@ -749,7 +1246,14 @@ def test_add_meta_items_resolves_tmdb_id_from_imdb_for_context_menu(monkeypatch) |
| 749 | 1246 | ) |
| 750 | 1247 | |
| 751 | 1248 | 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 | ), | |
| 753 | 1257 | ] |
| 754 | 1258 | |
| 755 | 1259 | catalog_menus.add_meta_items( |
| … | … | @@ -779,7 +1283,12 @@ def test_list_stremio_movie_builds_enriched_play_media_payload(monkeypatch): |
| 779 | 1283 | type( |
| 780 | 1284 | "Stream", |
| 781 | 1285 | (), |
| 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 | }, | |
| 783 | 1292 | )() |
| 784 | 1293 | ] |
| 785 | 1294 | }, |
| … | … | @@ -801,7 +1310,9 @@ def test_list_stremio_movie_builds_enriched_play_media_payload(monkeypatch): |
| 801 | 1310 | "addon_url": "https://example.com/addon", |
| 802 | 1311 | "catalog_type": "movie", |
| 803 | 1312 | "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 | ), | |
| 805 | 1316 | "poster": "poster.jpg", |
| 806 | 1317 | "fanart": "fanart.jpg", |
| 807 | 1318 | "genres": json.dumps(["Drama"]), |
| … | … | @@ -1,5 +1,5 @@ |
| 1 | import pytest | |
| 2 | from lib.utils.parsers.title_parser import parse_title_info | |
| 1 | from lib.utils.parsers.title_parser import extract_codec_hdr, parse_title_info | |
| 2 | ||
| 3 | 3 | |
| 4 | 4 | def test_parse_title_info_complex(): |
| 5 | 5 | title = "Movie.Name.2024.2160p.REPACK.HEVC.DV.HDR10.Atmos.TrueHD.7.1-GROUP" |
| … | … | @@ -9,8 +9,63 @@ def test_parse_title_info_complex(): |
| 9 | 9 | assert "DV" in info["hdr"] |
| 10 | 10 | assert "GROUP" in info["release_group"] |
| 11 | 11 | |
| 12 | ||
| 12 | 13 | def test_parse_title_info_simple(): |
| 13 | 14 | title = "Show.S01E01.1080p.H264-EVO" |
| 14 | 15 | info = parse_title_info(title) |
| 15 | 16 | assert "H.264" in info["codec"] |
| 16 | 17 | assert info["release_group"] == "EVO" |
| 18 | ||
| 19 | ||
| 20 | def 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 | ||
| 26 | def 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 | ||
| 32 | def 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 | ||
| 38 | def 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 | ||
| 44 | def 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 | ||
| 50 | def 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 | ||
| 56 | def 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 | ||
| 62 | def test_extract_codec_hdr_empty(): | |
| 63 | codec, hdr = extract_codec_hdr("Movie.2024.WEB-DL-GROUP") | |
| 64 | assert codec == "" | |
| 65 | assert hdr == "" | |
| 66 | ||
| 67 | ||
| 68 | def test_extract_codec_hdr_none(): | |
| 69 | codec, hdr = extract_codec_hdr("") | |
| 70 | assert codec == "" | |
| 71 | assert hdr == "" |
| … | … | @@ -0,0 +1,31 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.clients.tmdb.tmdb import TmdbClient | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 20 | def 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" |
| … | … | @@ -0,0 +1,177 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | ||
| 4 | class 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 |
| … | … | @@ -0,0 +1,33 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | from lib.clients.tmdb.utils.utils import tmdb_get | |
| 4 | ||
| 5 | ||
| 6 | def 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 | ||
| 21 | def 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|") |
| … | … | @@ -0,0 +1,62 @@ |
| 1 | from unittest.mock import MagicMock, patch | |
| 2 | ||
| 3 | ||
| 4 | class 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" |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | import json |
| 2 | 2 | from types import SimpleNamespace |
| 3 | from urllib.parse import parse_qs, urlparse | |
| 4 | 3 | from unittest.mock import MagicMock, patch |
| 4 | from urllib.parse import parse_qs, urlparse | |
| 5 | 5 | |
| 6 | 6 | from lib.clients.tmdb.people_client import PeopleClient |
| 7 | 7 | from lib.clients.tmdb.tmdb import TmdbClient |
| … | … | @@ -59,15 +59,15 @@ def test_movie_multi_context_menu_passes_media_type_to_context_actions(): |
| 59 | 59 | |
| 60 | 60 | |
| 61 | 61 | def 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 | ) | |
| 63 | 65 | |
| 64 | 66 | with patch("lib.clients.tmdb.tmdb.tmdb_get", return_value=results) as tmdb_get, patch( |
| 65 | 67 | "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( | |
| 71 | 71 | "lib.clients.tmdb.tmdb.BaseTmdbClient.add_media_directory_item", |
| 72 | 72 | return_value=("url", MagicMock(), False), |
| 73 | 73 | ), 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(): |
| 92 | 92 | |
| 93 | 93 | |
| 94 | 94 | def 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 | ) | |
| 96 | 98 | |
| 97 | 99 | with patch("lib.clients.tmdb.tmdb.tmdb_get", return_value=results) as tmdb_get, patch( |
| 98 | 100 | "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( | |
| 104 | 104 | "lib.clients.tmdb.tmdb.BaseTmdbClient.add_media_directory_item", |
| 105 | 105 | return_value=("url", MagicMock(), False), |
| 106 | 106 | ), patch("lib.clients.tmdb.tmdb.add_directory_items_batch"), patch( |
| … | … | @@ -11,7 +11,9 @@ def test_tmdb_search_modes_runs_title_year_search_for_movies(): |
| 11 | 11 | |
| 12 | 12 | with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch( |
| 13 | 13 | "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: | |
| 15 | 17 | dialog_cls.return_value.select.return_value = 0 |
| 16 | 18 | dialog_cls.return_value.numeric.return_value = "2010" |
| 17 | 19 | |
| … | … | @@ -45,7 +47,10 @@ def test_tmdb_search_modes_runs_original_title_search_for_movies(): |
| 45 | 47 | |
| 46 | 48 | with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch( |
| 47 | 49 | "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: | |
| 49 | 54 | dialog_cls.return_value.select.return_value = 1 |
| 50 | 55 | |
| 51 | 56 | TmdbClient.tmdb_search_modes( |
| … | … | @@ -71,7 +76,9 @@ def test_tmdb_search_modes_returns_early_when_dialog_cancelled(): |
| 71 | 76 | |
| 72 | 77 | with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch( |
| 73 | 78 | "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: | |
| 75 | 82 | dialog_cls.return_value.select.return_value = -1 |
| 76 | 83 | |
| 77 | 84 | TmdbClient.tmdb_search_modes( |
| … | … | @@ -93,7 +100,9 @@ def test_tmdb_search_modes_returns_early_when_year_input_cancelled(): |
| 93 | 100 | |
| 94 | 101 | with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch( |
| 95 | 102 | "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: | |
| 97 | 106 | dialog_cls.return_value.select.return_value = 0 |
| 98 | 107 | dialog_cls.return_value.numeric.return_value = "" |
| 99 | 108 | |
| … | … | @@ -116,9 +125,9 @@ def test_tmdb_search_modes_rejects_invalid_manual_year(): |
| 116 | 125 | |
| 117 | 126 | with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch( |
| 118 | 127 | "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: | |
| 122 | 131 | dialog_cls.return_value.select.return_value = 2 |
| 123 | 132 | dialog_cls.return_value.numeric.return_value = "20ab" |
| 124 | 133 | |
| … | … | @@ -166,9 +175,9 @@ def test_tmdb_episode_search_modes_runs_search_with_edited_title_season_episode( |
| 166 | 175 | |
| 167 | 176 | with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch( |
| 168 | 177 | "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: | |
| 172 | 181 | dialog_cls.return_value.select.return_value = 0 |
| 173 | 182 | dialog_cls.return_value.numeric.side_effect = ["2", "5"] |
| 174 | 183 | |
| … | … | @@ -203,9 +212,9 @@ def test_tmdb_episode_search_modes_runs_search_with_original_title(): |
| 203 | 212 | |
| 204 | 213 | with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch( |
| 205 | 214 | "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: | |
| 209 | 218 | dialog_cls.return_value.select.return_value = 1 |
| 210 | 219 | dialog_cls.return_value.numeric.side_effect = ["3", "8"] |
| 211 | 220 | |
| … | … | @@ -226,6 +235,44 @@ def test_tmdb_episode_search_modes_runs_search_with_original_title(): |
| 226 | 235 | assert tv_data["episode"] == 8 |
| 227 | 236 | |
| 228 | 237 | |
| 238 | def 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 | ||
| 229 | 276 | def test_tmdb_episode_search_modes_returns_early_when_selector_cancelled(): |
| 230 | 277 | tmdb_obj = MagicMock() |
| 231 | 278 | tmdb_obj.original_name = "Breaking Bad" |
| … | … | @@ -280,9 +327,9 @@ def test_tmdb_episode_search_modes_returns_early_when_season_cancelled(): |
| 280 | 327 | |
| 281 | 328 | with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch( |
| 282 | 329 | "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: | |
| 286 | 333 | dialog_cls.return_value.select.return_value = 0 |
| 287 | 334 | dialog_cls.return_value.numeric.return_value = "" |
| 288 | 335 | |
| … | … | @@ -305,9 +352,11 @@ def test_tmdb_episode_search_modes_rejects_invalid_season(): |
| 305 | 352 | |
| 306 | 353 | with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch( |
| 307 | 354 | "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: | |
| 311 | 360 | dialog_cls.return_value.select.return_value = 0 |
| 312 | 361 | dialog_cls.return_value.numeric.return_value = "abc" |
| 313 | 362 |
| … | … | @@ -1,4 +1,3 @@ |
| 1 | import os | |
| 2 | 1 | from unittest.mock import patch |
| 3 | 2 | |
| 4 | 3 | from lib.api.tmdbv3api.as_obj import AsObj |
| … | … | @@ -52,7 +51,12 @@ def test_choose_tmdb_trailer_prefers_trailer_over_teaser_then_official_flag(): |
| 52 | 51 | def test_choose_tmdb_trailer_ignores_non_youtube_and_unrelated_types(): |
| 53 | 52 | videos = [ |
| 54 | 53 | {"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 | }, | |
| 56 | 60 | ] |
| 57 | 61 | |
| 58 | 62 | assert choose_tmdb_trailer(videos) is None |
| … | … | @@ -7,18 +7,24 @@ def test_movie_item_adds_trakt_context_actions_when_authenticated(): |
| 7 | 7 | list_item = MagicMock() |
| 8 | 8 | ids = {"tmdb_id": 10, "imdb_id": "tt0010", "tvdb_id": ""} |
| 9 | 9 | |
| 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")], | |
| 12 | 16 | ), 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")], | |
| 14 | 19 | ), 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")], | |
| 16 | 22 | ), 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")], | |
| 18 | 25 | ), 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")], | |
| 22 | 28 | ), patch("lib.clients.tmdb.base.add_kodi_dir_item"): |
| 23 | 29 | BaseTmdbClient.add_media_directory_item(list_item, "movies", "Demo Movie", ids) |
| 24 | 30 | |
| … | … | @@ -39,9 +45,12 @@ def test_tv_item_skips_trakt_context_actions_when_not_authenticated(): |
| 39 | 45 | list_item = MagicMock() |
| 40 | 46 | ids = {"tmdb_id": 20, "imdb_id": "tt0020", "tvdb_id": 200} |
| 41 | 47 | |
| 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 | ): | |
| 45 | 54 | BaseTmdbClient.add_media_directory_item(list_item, "tv", "Demo Show", ids) |
| 46 | 55 | |
| 47 | 56 | list_item.addContextMenuItems.assert_called_once_with([("tmdb-tv", "cmd")]) |
| … | … | @@ -0,0 +1,195 @@ |
| 1 | """Tests for TorrServer API client response normalization. | |
| 2 | ||
| 3 | Regression tests for issue #193: TorrServer may return a JSON list | |
| 4 | instead of a dict for some endpoints, causing TypeError when | |
| 5 | accessing ["hash"] on the list. | |
| 6 | """ | |
| 7 | ||
| 8 | from unittest.mock import MagicMock | |
| 9 | ||
| 10 | import pytest | |
| 11 | ||
| 12 | from lib.api.jacktorr.jacktorr import TorrServer, TorrServerError | |
| 13 | ||
| 14 | ||
| 15 | def _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 | |
| 25 | def 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 | ||
| 38 | class 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 | ||
| 72 | class 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 | ||
| 106 | class 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 | ||
| 122 | class 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 | ||
| 140 | class 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 | ||
| 160 | class 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 == [] |
| … | … | @@ -0,0 +1,198 @@ |
| 1 | import importlib | |
| 2 | import json | |
| 3 | import sys | |
| 4 | from unittest.mock import MagicMock, patch | |
| 5 | ||
| 6 | ||
| 7 | def _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 | ||
| 13 | def _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 | ||
| 19 | class 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 | ||
| 102 | class 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") |
| … | … | @@ -0,0 +1,328 @@ |
| 1 | import importlib | |
| 2 | import json | |
| 3 | import sys | |
| 4 | from unittest.mock import MagicMock, patch | |
| 5 | ||
| 6 | ||
| 7 | def _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 | ||
| 13 | class 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 | ||
| 140 | class 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 | ||
| 296 | class 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") |
| … | … | @@ -1,13 +1,12 @@ |
| 1 | from urllib.parse import parse_qs, urlparse | |
| 2 | 1 | from unittest.mock import MagicMock, patch |
| 2 | from urllib.parse import parse_qs, urlparse | |
| 3 | 3 | |
| 4 | from lib.clients.trakt.trakt import BaseTraktClient | |
| 5 | 4 | from lib.clients.tmdb.base import BaseTmdbClient |
| 6 | 5 | from lib.clients.tmdb.utils.utils import ( |
| 7 | 6 | add_tmdb_movie_context_menu, |
| 8 | 7 | add_tmdb_show_context_menu, |
| 9 | 8 | ) |
| 10 | ||
| 9 | from lib.clients.trakt.trakt import BaseTraktClient | |
| 11 | 10 | |
| 12 | 11 | TRAILER_LABEL = "Play Trailer" |
| 13 | 12 | |
| … | … | @@ -46,7 +45,10 @@ def _parse_runplugin_command(command): |
| 46 | 45 | |
| 47 | 46 | |
| 48 | 47 | def 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 | ): | |
| 50 | 52 | menu = add_tmdb_movie_context_menu( |
| 51 | 53 | mode="movies", |
| 52 | 54 | media_type="movie", |
| … | … | @@ -68,7 +70,10 @@ def test_movie_context_menu_contains_play_trailer_when_tmdb_id_exists(): |
| 68 | 70 | |
| 69 | 71 | |
| 70 | 72 | def 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 | ): | |
| 72 | 77 | menu = add_tmdb_movie_context_menu( |
| 73 | 78 | mode="movies", |
| 74 | 79 | media_type="movie", |
| … | … | @@ -106,7 +111,10 @@ def test_movie_context_menu_contains_search_modes_entry(): |
| 106 | 111 | |
| 107 | 112 | |
| 108 | 113 | def 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 | ): | |
| 110 | 118 | menu = add_tmdb_show_context_menu( |
| 111 | 119 | mode="tv", |
| 112 | 120 | title="Demo Show", |
| … | … | @@ -132,9 +140,9 @@ def test_base_tmdb_client_passes_show_title_to_context_menu_builder(): |
| 132 | 140 | with patch( |
| 133 | 141 | "lib.clients.tmdb.base.add_tmdb_show_context_menu", |
| 134 | 142 | 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 | ): | |
| 138 | 146 | BaseTmdbClient.add_media_directory_item( |
| 139 | 147 | list_item=list_item, |
| 140 | 148 | mode="tv", |
| … | … | @@ -142,17 +150,15 @@ def test_base_tmdb_client_passes_show_title_to_context_menu_builder(): |
| 142 | 150 | ids={"tmdb_id": 1399}, |
| 143 | 151 | ) |
| 144 | 152 | |
| 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") | |
| 148 | 154 | |
| 149 | 155 | |
| 150 | 156 | def test_trakt_movie_context_menu_contains_play_trailer_when_tmdb_id_exists(): |
| 151 | 157 | list_item = MagicMock() |
| 152 | 158 | |
| 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( | |
| 156 | 162 | "lib.clients.tmdb.utils.utils.translation", |
| 157 | 163 | side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}", |
| 158 | 164 | ): |
| … | … | @@ -181,11 +187,9 @@ def test_trakt_movie_context_menu_contains_play_trailer_when_tmdb_id_exists(): |
| 181 | 187 | def test_trakt_show_context_menu_contains_play_trailer_when_tmdb_id_exists(): |
| 182 | 188 | list_item = MagicMock() |
| 183 | 189 | |
| 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")], | |
| 189 | 193 | ), patch("lib.clients.trakt.trakt.add_kodi_dir_item"), patch( |
| 190 | 194 | "lib.clients.tmdb.utils.utils.translation", |
| 191 | 195 | side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}", |
| … | … | @@ -10,16 +10,25 @@ def test_play_trailer_resolves_and_hands_off_to_kodi(): |
| 10 | 10 | "title": "Demo Movie", |
| 11 | 11 | } |
| 12 | 12 | list_item = MagicMock() |
| 13 | player = MagicMock() | |
| 13 | 14 | |
| 14 | 15 | with patch( |
| 15 | 16 | "lib.navigation.resolve_item_trailer", |
| 16 | 17 | 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 | }, | |
| 19 | 26 | }, |
| 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: | |
| 23 | 32 | from lib.navigation import play_trailer |
| 24 | 33 | |
| 25 | 34 | play_trailer(params) |
| … | … | @@ -36,12 +45,13 @@ def test_play_trailer_resolves_and_hands_off_to_kodi(): |
| 36 | 45 | ) |
| 37 | 46 | list_item.setPath.assert_called_once_with("https://video.example/trailer.m3u8") |
| 38 | 47 | 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) | |
| 40 | 49 | notification.assert_not_called() |
| 41 | 50 | |
| 42 | 51 | |
| 43 | 52 | def test_play_trailer_handles_adaptive_source_without_falling_back_to_youtube_addon(): |
| 44 | 53 | list_item = MagicMock() |
| 54 | player = MagicMock() | |
| 45 | 55 | |
| 46 | 56 | with patch( |
| 47 | 57 | "lib.navigation.resolve_item_trailer", |
| … | … | @@ -54,8 +64,8 @@ def test_play_trailer_handles_adaptive_source_without_falling_back_to_youtube_ad |
| 54 | 64 | }, |
| 55 | 65 | }, |
| 56 | 66 | ), 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: | |
| 59 | 69 | from lib.navigation import play_trailer |
| 60 | 70 | |
| 61 | 71 | 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 |
| 69 | 79 | (("IsPlayable", "true"),), |
| 70 | 80 | (("inputstream.adaptive.manifest_type", "mpd"),), |
| 71 | 81 | ] |
| 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) | |
| 73 | 83 | execute_builtin.assert_not_called() |
| 74 | 84 | |
| 75 | 85 | |
| … | … | @@ -80,9 +90,7 @@ def test_play_trailer_notifies_when_no_resolved_video_url(): |
| 80 | 90 | ) as resolve_item_trailer, patch( |
| 81 | 91 | "lib.navigation.translation", |
| 82 | 92 | 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: | |
| 86 | 94 | from lib.navigation import play_trailer |
| 87 | 95 | |
| 88 | 96 | 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(): |
| 94 | 102 | media_type="tv", |
| 95 | 103 | ) |
| 96 | 104 | notification.assert_called_once_with("Trailer unavailable") |
| 97 | set_resolved_url.assert_not_called() | |
| 98 | 105 | |
| 99 | 106 | |
| 100 | 107 | def test_play_trailer_uses_mode_when_media_type_missing(): |
| 101 | 108 | with patch( |
| 102 | 109 | "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: | |
| 105 | 118 | from lib.navigation import play_trailer |
| 106 | 119 | |
| 107 | 120 | play_trailer({"tmdb_id": "1399", "mode": "tv", "title": "Demo Show"}) |
| … | … | @@ -122,9 +135,7 @@ def test_play_trailer_logs_when_resolution_fails(): |
| 122 | 135 | ), patch("lib.navigation.kodilog") as log_mock, patch( |
| 123 | 136 | "lib.navigation.translation", |
| 124 | 137 | 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"): | |
| 128 | 139 | from lib.navigation import play_trailer |
| 129 | 140 | |
| 130 | 141 | 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 |
| 141 | 152 | return_value=True, |
| 142 | 153 | ) as is_youtube_addon_enabled, patch( |
| 143 | 154 | "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: | |
| 147 | 156 | from lib.navigation import play_trailer |
| 148 | 157 | |
| 149 | 158 | play_trailer( |
| … | … | @@ -166,7 +175,6 @@ def test_play_trailer_falls_back_to_youtube_addon_when_direct_playback_unavailab |
| 166 | 175 | "PlayMedia(plugin://plugin.video.youtube/play/?video_id=dQw4w9WgXcQ)" |
| 167 | 176 | ) |
| 168 | 177 | notification.assert_not_called() |
| 169 | set_resolved_url.assert_not_called() | |
| 170 | 178 | |
| 171 | 179 | |
| 172 | 180 | def 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 |
| 176 | 184 | ) as resolve_item_trailer, patch( |
| 177 | 185 | "lib.navigation.is_youtube_addon_enabled", |
| 178 | 186 | 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: | |
| 182 | 193 | from lib.navigation import play_trailer |
| 183 | 194 | |
| 184 | 195 | play_trailer( |
| … | … | @@ -249,9 +260,12 @@ def test_play_trailer_notifies_when_youtube_addon_is_missing_for_fallback(): |
| 249 | 260 | ) as resolve_item_trailer, patch( |
| 250 | 261 | "lib.navigation.is_youtube_addon_enabled", |
| 251 | 262 | 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: | |
| 255 | 269 | from lib.navigation import play_trailer |
| 256 | 270 | |
| 257 | 271 | play_trailer( |
| … | … | @@ -290,9 +304,7 @@ def test_play_trailer_logs_unavailable_youtube_addon_fallback_details(): |
| 290 | 304 | ), patch( |
| 291 | 305 | "lib.navigation.translation", |
| 292 | 306 | 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: | |
| 296 | 308 | from lib.navigation import play_trailer |
| 297 | 309 | |
| 298 | 310 | play_trailer( |
| … | … | @@ -103,9 +103,7 @@ def test_build_up_next_entries_finds_next_aired_episode(): |
| 103 | 103 | "seasons": [ |
| 104 | 104 | { |
| 105 | 105 | "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"}], | |
| 109 | 107 | } |
| 110 | 108 | ], |
| 111 | 109 | } |
| … | … | @@ -7,9 +7,9 @@ from lib.clients.trakt.trakt import TraktPresentation |
| 7 | 7 | def test_create_list_calls_trakt_and_clears_my_lists_cache(): |
| 8 | 8 | trakt_lists = TraktLists() |
| 9 | 9 | |
| 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( | |
| 13 | 13 | "lib.api.trakt.trakt.clear_trakt_list_contents_data" |
| 14 | 14 | ) as clear_list_contents: |
| 15 | 15 | trakt_lists.create_list("My List", "Description") |
| … | … | @@ -112,9 +112,7 @@ def test_remove_from_collection_uses_all_known_ids_for_shows(): |
| 112 | 112 | with patch.object(trakt_lists, "call_trakt", return_value={}) as call_trakt, patch( |
| 113 | 113 | "lib.api.trakt.trakt.lists_cache.delete_prefix" |
| 114 | 114 | ), 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"}) | |
| 118 | 116 | |
| 119 | 117 | call_trakt.assert_called_once_with( |
| 120 | 118 | "sync/collection/remove", |
| … | … | @@ -236,9 +234,7 @@ def test_build_show_collection_season_payload_uses_season_numbers_only(): |
| 236 | 234 | def test_normalize_trakt_media_type_prefers_show_when_tvdb_present(): |
| 237 | 235 | from lib.clients.trakt.trakt import TraktClient |
| 238 | 236 | |
| 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}) | |
| 242 | 238 | |
| 243 | 239 | assert result == "shows" |
| 244 | 240 | |
| … | … | @@ -269,9 +265,7 @@ def test_build_collection_remove_payload_uses_existing_collection_entry(): |
| 269 | 265 | } |
| 270 | 266 | ], |
| 271 | 267 | ): |
| 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}) | |
| 275 | 269 | |
| 276 | 270 | assert payload == { |
| 277 | 271 | "shows": [ |
| … | … | @@ -25,7 +25,10 @@ def test_sync_activities_applies_changed_buckets(): |
| 25 | 25 | api.sync.get_last_activities.return_value = {"movies": {"watched_at": "new"}} |
| 26 | 26 | service = TraktSyncService(api=api, monitor=MagicMock()) |
| 27 | 27 | |
| 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( | |
| 29 | 32 | service, "_get_changed_buckets", return_value={"watched_movies", "lists"} |
| 30 | 33 | ) as changed_buckets, patch.object(service, "_apply_activity_changes") as apply_changes: |
| 31 | 34 | result = service.sync_activities() |
| … | … | @@ -40,7 +43,10 @@ def test_sync_activities_logs_changed_buckets(): |
| 40 | 43 | api.sync.get_last_activities.return_value = {"movies": {"watched_at": "new"}} |
| 41 | 44 | service = TraktSyncService(api=api, monitor=MagicMock()) |
| 42 | 45 | |
| 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( | |
| 44 | 50 | service, "_get_changed_buckets", return_value={"watched_movies", "lists"} |
| 45 | 51 | ), patch.object(service, "_apply_activity_changes"), patch( |
| 46 | 52 | "lib.services.trakt_sync.kodilog" |
| … | … | @@ -2,8 +2,8 @@ from unittest.mock import MagicMock, patch |
| 2 | 2 | |
| 3 | 3 | from lib.clients.debrid.torbox import TorboxHelper |
| 4 | 4 | from lib.nav import debrid as debrid_navigation |
| 5 | from lib.utils.general.utils import DebridType | |
| 6 | 5 | from lib.utils.debrid import debrid_utils |
| 6 | from lib.utils.general.utils import DebridType | |
| 7 | 7 | from lib.utils.torrent import torrserver_utils |
| 8 | 8 | |
| 9 | 9 | |
| … | … | @@ -19,12 +19,33 @@ def test_add_source_to_torrserver_uses_magnet_when_available(): |
| 19 | 19 | ) |
| 20 | 20 | |
| 21 | 21 | 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="") | |
| 25 | 23 | notification.assert_called_once() |
| 26 | 24 | |
| 27 | 25 | |
| 26 | def 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 | ||
| 28 | 49 | def test_add_source_to_torrserver_uploads_torrent_file_from_url(): |
| 29 | 50 | api = MagicMock() |
| 30 | 51 | api.add_torrent_obj.return_value = "def456" |
| … | … | @@ -68,18 +89,14 @@ def test_add_source_to_torrserver_falls_back_to_magnet_when_url_fails(): |
| 68 | 89 | |
| 69 | 90 | with patch.object(torrserver_utils, "JACKTORR_ADDON", True), patch.object( |
| 70 | 91 | 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")): | |
| 74 | 93 | result = torrserver_utils.add_source_to_torrserver( |
| 75 | 94 | url="https://example.com/details/1", |
| 76 | 95 | magnet="magnet:?xt=urn:btih:abc123", |
| 77 | 96 | ) |
| 78 | 97 | |
| 79 | 98 | 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="") | |
| 83 | 100 | |
| 84 | 101 | |
| 85 | 102 | def 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 |
| 112 | 129 | dialog.select.assert_called_once() |
| 113 | 130 | |
| 114 | 131 | |
| 132 | def 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 | ||
| 115 | 137 | def test_torbox_helper_get_cloud_downloads_returns_playable_items(): |
| 116 | 138 | helper = TorboxHelper() |
| 117 | 139 | helper.client = MagicMock() |
| … | … | @@ -155,9 +177,7 @@ def test_torbox_helper_get_link_uses_cloud_ids_when_present(): |
| 155 | 177 | |
| 156 | 178 | assert data is not None |
| 157 | 179 | 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") | |
| 161 | 181 | |
| 162 | 182 | |
| 163 | 183 | def test_get_tb_downloads_builds_playable_cloud_entries(): |
| … | … | @@ -101,7 +101,9 @@ def test_update_addon_keeps_current_install_when_zip_is_invalid(monkeypatch, tmp |
| 101 | 101 | |
| 102 | 102 | updater.update_addon("1.7.2") |
| 103 | 103 | |
| 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 | ) | |
| 105 | 107 | assert dialog_calls |
| 106 | 108 | |
| 107 | 109 | |
| … | … | @@ -130,7 +132,9 @@ def test_update_addon_restores_backup_when_install_move_fails(monkeypatch, tmp_p |
| 130 | 132 | |
| 131 | 133 | updater.update_addon("1.7.2") |
| 132 | 134 | |
| 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 | ) | |
| 134 | 138 | assert not os.path.exists(os.path.join(str(packages_dir), f"{updater.ADDON_ID}-backup")) |
| 135 | 139 | |
| 136 | 140 | |
| … | … | @@ -147,6 +151,8 @@ def test_update_addon_installs_valid_downgrade(monkeypatch, tmp_path): |
| 147 | 151 | |
| 148 | 152 | updater.update_addon("1.7.2") |
| 149 | 153 | |
| 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 | ) | |
| 151 | 157 | assert not os.path.exists(os.path.join(str(packages_dir), f"{updater.ADDON_ID}-backup")) |
| 152 | 158 | assert not os.path.exists(os.path.join(str(packages_dir), f"{updater.ADDON_ID}-staging")) |
| … | … | @@ -1,6 +1,3 @@ |
| 1 | from unittest.mock import MagicMock | |
| 2 | ||
| 3 | ||
| 4 | 1 | from lib.services.webserver import _addon_capabilities |
| 5 | 2 | |
| 6 | 3 |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | import pytest | |
| 2 | 1 | from unittest.mock import MagicMock, patch |
| 3 | 2 | |
| 3 | import pytest | |
| 4 | 4 | |
| 5 | 5 | from lib.clients.youtube_resolver import ( |
| 6 | 6 | extract_video_id, |
| … | … | @@ -11,7 +11,6 @@ from lib.clients.youtube_resolver import ( |
| 11 | 11 | select_best_stream, |
| 12 | 12 | ) |
| 13 | 13 | |
| 14 | ||
| 15 | 14 | WATCH_HTML = """ |
| 16 | 15 | <html> |
| 17 | 16 | <script> |
| … | … | @@ -452,10 +451,11 @@ def test_resolve_trailer_playback_tries_android_vr_android_then_ios_clients(): |
| 452 | 451 | resolved = resolve_trailer_playback("dQw4w9WgXcQ", session=session) |
| 453 | 452 | |
| 454 | 453 | 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 | ] | |
| 459 | 459 | assert [call[1]["headers"]["User-Agent"] for call in session.post_calls] == [ |
| 460 | 460 | ( |
| 461 | 461 | "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(): |
| 469 | 469 | "3", |
| 470 | 470 | "5", |
| 471 | 471 | ] |
| 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 | ] | |
| 475 | 477 | assert [call[1]["headers"]["X-Goog-Visitor-Id"] for call in session.post_calls] == [ |
| 476 | 478 | "visitor-token", |
| 477 | 479 | "visitor-token", |
| … | … | @@ -508,17 +510,14 @@ def test_call_player_api_sends_origin_and_html5_preference(): |
| 508 | 510 | _, post_kwargs = session.post_calls[0] |
| 509 | 511 | assert post_kwargs["headers"]["Origin"] == "https://www.youtube.com" |
| 510 | 512 | 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 " | |
| 513 | 514 | "(Linux; U; Android 12; en_US; Quest 3; Build/SQ3A.220605.009.A1) gzip" |
| 514 | 515 | ) |
| 515 | 516 | assert post_kwargs["headers"]["X-YouTube-Client-Name"] == "28" |
| 516 | 517 | assert post_kwargs["headers"]["X-YouTube-Client-Version"] == "1.56.21" |
| 517 | 518 | assert post_kwargs["headers"]["X-Goog-Visitor-Id"] == "visitor-token" |
| 518 | 519 | assert ( |
| 519 | post_kwargs["json"]["playbackContext"]["contentPlaybackContext"][ | |
| 520 | "html5Preference" | |
| 521 | ] | |
| 520 | post_kwargs["json"]["playbackContext"]["contentPlaybackContext"]["html5Preference"] | |
| 522 | 521 | == "HTML5_PREF_WANTS" |
| 523 | 522 | ) |
| 524 | 523 | |
| … | … | @@ -551,8 +550,7 @@ def test_resolve_trailer_playback_logs_player_api_error_details(): |
| 551 | 550 | assert resolve_trailer_playback("dQw4w9WgXcQ", session=session) is None |
| 552 | 551 | |
| 553 | 552 | 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 | |
| 556 | 554 | ) |
| 557 | 555 | |
| 558 | 556 | |
| … | … | @@ -648,7 +646,9 @@ def test_resolve_item_trailer_playback_falls_back_to_tmdb_trailer(monkeypatch): |
| 648 | 646 | ] |
| 649 | 647 | |
| 650 | 648 | |
| 651 | def test_resolve_item_trailer_playback_returns_none_when_no_direct_or_tmdb_trailer(monkeypatch): | |
| 649 | def test_resolve_item_trailer_playback_returns_none_when_no_direct_or_tmdb_trailer( | |
| 650 | monkeypatch, | |
| 651 | ): | |
| 652 | 652 | monkeypatch.setattr( |
| 653 | 653 | "lib.clients.youtube_resolver.resolve_tmdb_trailer", |
| 654 | 654 | lambda tmdb_id, media_type: None, |
| … | … | @@ -1,5 +1,5 @@ |
| 1 | import sys | |
| 2 | 1 | import os |
| 2 | import sys | |
| 3 | 3 | from unittest.mock import MagicMock |
| 4 | 4 | |
| 5 | 5 | if "pytest" in sys.modules: |
| … | … | @@ -58,11 +58,11 @@ sys.modules["lib.api.tvdbapi.tvdbapi"] = MagicMock() |
| 58 | 58 | sys.modules["lib.api.tvdbapi"] = MagicMock() |
| 59 | 59 | |
| 60 | 60 | # 4. Now import the clients |
| 61 | from lib.api.debrid.realdebrid import RealDebrid | |
| 62 | 61 | from lib.api.debrid.alldebrid import AllDebrid |
| 63 | from lib.api.debrid.torbox import Torbox | |
| 64 | from lib.api.debrid.premiumize import Premiumize | |
| 65 | 62 | from lib.api.debrid.debrider import Debrider |
| 63 | from lib.api.debrid.premiumize import Premiumize | |
| 64 | from lib.api.debrid.realdebrid import RealDebrid | |
| 65 | from lib.api.debrid.torbox import Torbox | |
| 66 | 66 | |
| 67 | 67 | # === CONFIGURATION: ADD YOUR TOKENS HERE === |
| 68 | 68 | TOKENS = { |
| … | … | @@ -93,7 +93,7 @@ def test_service(name, client_class, token): |
| 93 | 93 | f"FAILED: {name} returned None. Check if token is valid or if account has premium." |
| 94 | 94 | ) |
| 95 | 95 | except Exception as e: |
| 96 | print(f"ERROR testing {name}: {str(e)}") | |
| 96 | print(f"ERROR testing {name}: {e!s}") | |
| 97 | 97 | |
| 98 | 98 | |
| 99 | 99 | if __name__ == "__main__": |