OverviewFilesCommitsIssuesDocsReleasesPeers

kodi-jacktook commits / 1d351e4

Import patched Jacktook 1.10.1 and repoint self-update at stortron
Upstream plugin.video.jacktook 1.10.0 as installed on the Pi, plus the local proxyHeaders work, imported so the patches stop living only on the Pi and a backup directory. lib/updater.py now fetches its version file, changelog and zip from http://192.168.50.38:7221 instead of the upstream GitHub repo, and the downgrade menu reads a generated index.json shaped like the GitHub contents API it previously consumed. This matters because update_addon() replaces the entire addon directory: pointed upstream, any accepted update wipes every local change. Pointed here, the package always carries them. build.sh produces exactly what the updater expects and validates -- the zip top-level dir and addon.xml version must match the filename, which the updater checks in the package and again after install. Version bumped 1.10.0 -> 1.10.1 so the Pi sees this build as an update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1d351e4ef74ef625314da743f158efa8fdae88d0
by plankatron <fleeky@gmail.com> · 2026-07-28T18:52:50.000Z

Changed files (353) +85869 -0

.gitignore +3 -0
……@@ -0,0 +1,3 @@
1dist/
2*.pyc
3__pycache__/
README.md +20 -0
……@@ -0,0 +1,20 @@
1# kodi-jacktook
2
3Patched fork of `plugin.video.jacktook` for the LibreELEC box (192.168.50.192),
4carrying changes upstream does not have:
5
6- Stremio `behaviorHints.proxyHeaders` plumbed end to end (model -> playback
7 data -> Kodi `|header` suffix), so hotlink-gated CDNs stop 403ing segments.
8- `lib/updater.py` repointed from the upstream GitHub repo to this build host.
9 `update_addon()` replaces the WHOLE addon directory, so an upstream update
10 silently destroys the patches above; self-updating from our own package is
11 what makes them survive.
12
13## Release
14
15 ./build.sh # reads version from plugin.video.jacktook/addon.xml
16 git commit -am ... # then push; stortron serves dist/ on :7221
17
18Bump `addon.xml`'s version before building or the Pi will not see an update --
19`version_less_than()` strips non-numerics, so use plain 1.10.2, not 1.10.1+fork.
20The `kodi-repo` user unit serves `dist/` to the Pi over LAN HTTP.
build.sh +42 -0
……@@ -0,0 +1,42 @@
1#!/usr/bin/env bash
2# Build the Kodi update payload Jacktook's own updater consumes.
3#
4# Layout mirrors what lib/updater.py expects (see UPDATE_HOST there):
5# packages/jacktook_version plain-text version string
6# packages/jacktook_changelog plain-text changelog
7# zips/<id>/<id>-<ver>.zip addon tree, top-level dir MUST be <id>
8# zips/<id>/index.json [{"name": "<zip>"}] -- shape of the GitHub
9# contents API the downgrade menu used to read
10#
11# The zip is validated twice by the updater (inside the package, then after
12# install), so addon.xml's version and the filename must agree.
13set -euo pipefail
14cd "$(dirname "$0")"
15
16ID="plugin.video.jacktook"
17VER=$(python3 -c "import xml.etree.ElementTree as ET;print(ET.parse('$ID/addon.xml').getroot().attrib['version'])")
18OUT="dist"
19ZIPDIR="$OUT/zips/$ID"
20
21mkdir -p "$OUT/packages" "$ZIPDIR"
22
23# Stale bytecode must never ship inside the package.
24find "$ID" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null || true
25
26rm -f "$ZIPDIR/$ID-$VER.zip"
27zip -qr "$ZIPDIR/$ID-$VER.zip" "$ID" -x '*.pyc' -x '*__pycache__*' -x '*.git*'
28
29printf '%s' "$VER" > "$OUT/packages/jacktook_version"
30[ -f CHANGELOG.md ] && cp CHANGELOG.md "$OUT/packages/jacktook_changelog" \
31 || printf 'Local build %s\n' "$VER" > "$OUT/packages/jacktook_changelog"
32
33python3 - "$ZIPDIR" <<'PY'
34import json, os, sys
35d = sys.argv[1]
36names = sorted(f for f in os.listdir(d) if f.endswith(".zip"))
37json.dump([{"name": n} for n in names], open(os.path.join(d, "index.json"), "w"))
38print("index.json ->", names)
39PY
40
41echo "built $ID $VER"
42ls -la "$ZIPDIR"
plugin.video.jacktook/AGENTS.md +195 -0
……@@ -0,0 +1,195 @@
1# AGENTS.md
2
3Guidance for agentic coding assistants working in `plugin.video.jacktook`.
4
5## 1) Project Context
6
7- Project type: Kodi video addon (`plugin.video.jacktook`)
8- Main language: Python
9- Runtime target: Kodi Python runtime (`xbmc.python` 3.00.0 in `addon.xml`)
10- Practical local interpreter: `python3` (the `python` command is not available here)
11- Primary entry points:
12 - `jacktook.py` (plugin source entry)
13 - `service.py` (background service entry)
14
15## 2) Repository Structure (high-value paths)
16
17- `lib/` core addon code
18 - `lib/router.py` action dispatcher with lazy imports
19 - `lib/navigation.py` menu/actions wiring
20 - `lib/clients/` integrations (Jackett, Stremio, debrid providers, etc.)
21 - `lib/api/` API wrappers
22 - `lib/utils/` utility functions, Kodi helpers, parsing, playback helpers
23 - `lib/gui/` Kodi windows/dialogs
24 - `lib/domain/` domain models (example: `TorrentStream`)
25- `resources/` settings XML, localization strings, skins, artwork
26- `tests/` pytest tests + manual scripts
27- `scripts/` utility scripts (release packaging, label conversion, py37 compatibility)
28
29## 3) Cursor/Copilot Rules
30
31- Checked for Cursor rules:
32 - `.cursor/rules/` -> not present
33 - `.cursorrules` -> not present
34- Checked for GitHub Copilot instructions:
35 - `.github/copilot-instructions.md` -> not present
36- Therefore: no repository-specific Cursor/Copilot rule files are currently defined.
37
38## 4) Setup Commands
39
40Use `python3` commands from repo root.
41
42```bash
43python3 -m pip install -r requirements-test.txt
44```
45
46Optional editable-style convenience for local imports is not required because tests already adjust `sys.path` in `tests/conftest.py`.
47
48## 5) Build / Package Commands
49
50There is no traditional compile/build pipeline. Packaging is done via script:
51
52```bash
53python3 scripts/create_release.py
54```
55
56Notes:
57- `scripts/create_release.py` uses hard-coded absolute paths for source and destination.
58- It creates a zip artifact under `/home/spider/Desktop/jacktook/releases/plugin.video.jacktook.zip`.
59- It excludes `.git`, `.venv`, `.agent`, `.worktrees`, `__pycache__`, `.pytest_cache`, and `*.pyc`.
60
61## 6) Test Commands
62
63Run tests from repository root.
64
65### Run full suite
66
67```bash
68python3 -m pytest -q
69```
70
71### Run a single file
72
73```bash
74python3 -m pytest tests/unit/test_jackett.py -q
75```
76
77### Run a single test (node id)
78
79```bash
80python3 -m pytest tests/unit/test_general_utils.py::test_is_url -q
81```
82
83### Run tests by keyword expression
84
85```bash
86python3 -m pytest -k "jackett and search_movie" tests/unit -q
87```
88
89### Run with coverage
90
91```bash
92python3 -m pytest --cov=lib --cov-report=term-missing
93```
94
95### Collect-only (debug discovery)
96
97```bash
98python3 -m pytest --collect-only -q
99```
100
101### Manual/integration helper scripts
102
103```bash
104python3 tests/manual_easynews_test.py
105python3 tests/verify_debrid.py
106```
107
108Notes:
109- These manual scripts require credentials/tokens and are not CI-safe.
110- Kodi modules are mocked in tests via `tests/conftest.py` and per-test stubs.
111
112## 7) Lint / Static Checks
113
114No dedicated linter config (`pyproject.toml`, `setup.cfg`, `tox.ini`, `pytest.ini`) is present.
115
116Recommended lightweight checks:
117
118```bash
119python3 -m py_compile jacktook.py service.py
120python3 scripts/check_py37_compat.py .
121```
122
123`scripts/check_py37_compat.py` scans for syntax/features that break Python 3.7 compatibility assumptions.
124
125## 8) Known Test Environment Caveats
126
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`.
131
132## 9) Code Style Guidelines
133
134Follow existing codebase conventions first; avoid style-only churn.
135
136### Imports
137
138- Prefer grouped imports in this order:
139 1. Standard library
140 2. Third-party
141 3. Local `lib.*`
142 4. Kodi modules (`xbmc`, `xbmcgui`, `xbmcplugin`, etc.)
143- Keep import names explicit; avoid wildcard imports.
144- Lazy-import heavy modules inside functions when startup cost matters (see `lib/router.py`).
145
146### Formatting
147
148- 4-space indentation, no tabs.
149- Keep functions focused; extract helpers for repeated routing/action maps.
150- Preserve readability over strict line-length refactors (project has mixed line widths).
151- Avoid large reformat-only diffs.
152
153### Types and Data Modeling
154
155- Type hints are used but not universally enforced; add hints for new/edited public helpers when practical.
156- Use `typing` compatibility style seen in repo (`List`, `Dict`, `Optional`) for consistency.
157- Preserve existing payload field names where external APIs demand them (e.g., `infoHash`, `isPack`).
158- Use dataclasses for small structured models (see `lib/domain/torrent.py`).
159
160### Naming
161
162- Functions/variables: `snake_case`
163- Classes: `PascalCase`
164- Constants: `UPPER_SNAKE_CASE`
165- Keep compatibility with existing action names and query params used by router/navigation.
166
167### Error Handling and Logging
168
169- Fail gracefully in UI/user-facing paths; avoid crashing Kodi navigation.
170- Wrap network/IO boundaries with defensive error handling.
171- Use Kodi-friendly messaging/logging patterns:
172 - `notification(...)` for user-visible failures
173 - `kodilog(...)` for diagnostics
174- Prefer specific exceptions when practical; broad `except` is tolerated only where Kodi API instability requires resilience.
175
176### Kodi-Specific Practices
177
178- Respect addon flow: build list items, set content type/category, then close directory correctly.
179- Use utility wrappers in `lib.utils.kodi.utils` instead of duplicating Kodi API calls.
180- Be careful with module-level side effects due to Kodi imports; tests rely on pre-import mocks.
181
182### Testing Practices
183
184- For pure logic, add/extend pytest unit tests in `tests/unit/`.
185- Mock Kodi modules before importing code under test when required.
186- Prefer deterministic fixtures over live network calls.
187- For debrid/provider behavior, keep unit tests isolated and use manual scripts for credentialed checks.
188
189## 10) Change Workflow for Agents
190
191- Make minimal, targeted changes.
192- Do not rewrite unrelated files for style consistency.
193- When changing routing/action names, update all call sites and query param builders.
194- Run relevant tests first (single-test command), then broader suite when feasible.
195- If tests fail due to known environment stubs, document clearly in your handoff.
plugin.video.jacktook/CHANGELOG.md +952 -0
……@@ -0,0 +1,952 @@
1## 1.10.0
2- Search: Add Search Title Language mode with localized first, English first, and English only ordering for Easynews/Jackett/Prowlarr context searches.
3- Search: Improve English-first fallback behavior to prioritize TMDB original titles before localized titles when English translations are missing.
4- Search/Cache: Scope search and debrid cache entries by active provider/addon state to avoid stale cross-provider reuse.
5- Source Select: Improve source labeling clarity for search results.
6- Playback/TorrServer: Harden torrent URL handling for Jackett sources and preserve movie year in generated download filenames.
7- TMDB: Fix multi-search movie context actions.
8- UX: Keep source focus cache outside addon settings to avoid unintended persistence issues.
9
10Spanish:
11- Busqueda: Añadido modo de idioma del titulo de busqueda con orden Localizado primero, Ingles primero y Solo ingles para busquedas de contexto en Easynews/Jackett/Prowlarr.
12- Busqueda: Mejorado el comportamiento de fallback en Ingles primero para priorizar el titulo original de TMDB antes del titulo localizado cuando falta traduccion al ingles.
13- Busqueda/Cache: Segmentada la cache de busqueda y debrid por estado activo de proveedores/addons para evitar reutilizacion obsoleta entre proveedores.
14- Seleccion de fuentes: Mejorada la claridad del etiquetado de fuentes en los resultados.
15- Reproduccion/TorrServer: Reforzado el manejo de URLs torrent para fuentes de Jackett y conservado el año de pelicula en los nombres de descarga generados.
16- TMDB: Corregidas las acciones de contexto de multi-busqueda para peliculas.
17- UX: Movida la cache de foco de fuente fuera de los ajustes del addon para evitar persistencia no deseada.
18
19## 1.9.0
20- Subtitle: Add subtitle upload from device or from a local webserver.
21- Downloads: Preserve the file extension and episode info in downloaded filenames.
22- Torrents: Fix torrent file handoff to TorrServer for Jackett trackers like Filelist.
23- UI: Stop the fallback poster viewtype from overriding the user's preference.
24
25Spanish:
26- Subtitulos: Añade la subida de subtitulos desde el dispositivo o desde un servidor web local.
27- Descargas: Conserva la extension y la informacion del episodio en los nombres descargados.
28- Torrents: Corrige el envio de archivos torrent a TorrServer para trackers de Jackett como Filelist.
29- UI: Evita que la vista de poster de respaldo sobrescriba la preferencia del usuario.
30
31## 1.8.0
32- Search: Add TMDB Search Modes for movies and episodes, including title editing, original title variants, and manual year/season/episode entry from TMDB context menus.
33- Performance: Rework menu and listing rendering with batch `addDirectoryItems`, `ListItem(offscreen=True)`, `reuselanguageinvoker`, cached navigator entries, and cached TMDB item metadata to improve navigation speed
34- TMDB: Remove repeated per-item image/detail rebuilding in major list views and avoid duplicate show-detail fetches in Calendar.
35- Settings: Cache addon settings from a profile `settings.xml` snapshot so changes made in Kodi settings are reflected more reliably across the addon.
36- Debrid/Cloud: Cache cloud download listings, move Cloud account info out of problematic list navigation, and improve cloud/provider modal flows.
37- UX: Fix multiple stuck spinner cases across Settings, Torrents, Jackgram, TMDB/MDblist search cancel flows, search history actions, and debrid account dialogs.
38- Easynews: Fix the account info dialog heading import so the modal opens without errors.
39- Improve and harden addon downgrade and added reload profile after addon update
40- Moved view selection controls into the addon settings menu.
41
42Spanish:
43- Busqueda: Añadidos Modos de busqueda de TMDB para peliculas y episodios, incluyendo edicion de titulo, variantes con titulo original y entrada manual de ano/temporada/episodio desde los menus contextuales de TMDB.
44- Rendimiento: Rehecha la representacion de menus y listados con `addDirectoryItems` por lotes, `ListItem(offscreen=True)`, `reuselanguageinvoker`, cache de entradas del navigator y cache de metadatos TMDB por item para mejorar la velocidad de navegacion.
45- TMDB: Eliminada la reconstruccion repetida de imagenes/detalles por item en los principales listados y evitadas las consultas duplicadas de detalles de series en Calendar.
46- Ajustes: Añadida cache de ajustes del addon a partir de un snapshot del `settings.xml` del perfil para reflejar con mas fiabilidad los cambios hechos desde los ajustes de Kodi.
47- Debrid/Cloud: Añadida cache a los listados de descargas cloud, movida la informacion de cuenta de Cloud fuera de la navegacion problematica del listado y mejorados los flujos modales de cloud/proveedores.
48- UX: Corregidos multiples casos de spinner bloqueado en Ajustes, Torrents, Jackgram, cancelaciones de busqueda TMDB/MDblist, acciones del historial de busqueda y dialogos de informacion de cuenta de debrid.
49- Easynews: Corregida la importacion del titulo del dialogo de informacion de cuenta para que el modal se abra sin errores.
50- Mejorada y reforzada la seguridad de la funcion de downgrade y añadida la recarga del perfil despues de la actualizacion del addon.
51- Movidos los controles de seleccion de vista al menu de ajustes del addon.
52
53## 1.7.2
54- Trailers: Add YouTube trailer playback flow.
55- Playback: Add setting to skip the replay/resume dialog.
56- Settings: Add per-section view settings and improve description length handling by allowing `0` and applying truncation correctly.
57- Stremio: Preserve catalog metadata, exact addon routes, exact season routes, and improve catalog search history flows.
58- Stremio: Speed up catalog menus and keep search results bound to the correct addon route.
59- Library: Keep Stremio entries linked to Stremio metadata, add `clear all` actions, and improve timestamp parsing robustness.
60- Debrid: Harden source handling across providers and fix Premiumize authorization issues.
61- Search: Remove Zilean search task handling.
62
63Spanish:
64- Trailers: Añadida la reproduccion de trailers de YouTube.
65- Reproduccion: Añadido ajuste para omitir el dialogo de reanudacion/repeticion.
66- Ajustes: Añadidos ajustes de vista por seccion y mejorado el manejo de la longitud de descripcion permitiendo `0` y aplicando truncado correctamente.
67- Stremio: Conserva metadatos del catalogo, rutas exactas de addons, rutas exactas de temporadas y mejora los flujos del historial de busqueda del catalogo.
68- Stremio: Aceleracion de los menus del catalogo y mantiene los resultados de busqueda vinculados a la ruta correcta del addon.
69- Biblioteca: Mantiene las entradas de Stremio enlazadas a sus metadatos, añade acciones de `clear all` y mejora la robustez del parseo de timestamps.
70- Debrid: Refuerza el manejo de fuentes entre proveedores y corrige problemas de autorizacion con Premiumize.
71- Busqueda: Eliminado el manejo de tareas de busqueda de Zilean.
72
73## 1.7.1
74- Localization: Add Russian language support and expand/update translations for Spanish, Russian, and Portuguese.
75- UI: Replace remaining hardcoded dialog text with translated strings.
76- AllDebrid: Keep the authorization dialog open during reauthorization when a previous token exists.
77- Playback: Make the skip intro/recap popup interactive so the user can manually trigger the skip action.
78- Stremio: Show `Local URL` instead of `Pastebin Link` in Manage from Phone QR dialogs.
79- Updater: Clarify automatic update action handling and preserve explicit `Ask`, `Notify`, and `None` modes.
80
81Spanish:
82- Localizacion: Añadido soporte para ruso y ampliadas/actualizadas las traducciones de espanol, ruso y portugues.
83- UI: Sustituidos los textos de dialogos que quedaban hardcodeados por cadenas traducidas.
84- AllDebrid: Mantiene abierto el dialogo de autorizacion durante la reautorizacion cuando existe un token previo.
85- Reproduccion: Hace interactivo el popup de saltar intro/recap para que el usuario pueda ejecutar manualmente el salto.
86- Stremio: Muestra `URL local` en lugar de `Pastebin Link` en los dialogos QR de Manage from Phone.
87- Actualizador: Aclara el manejo de acciones de actualizacion automatica y mantiene los modos explicitos `Ask`, `Notify` y `None`.
88
89## 1.7.0
90- Torrents: Add source actions to send torrents directly to Debrid cloud or TorrServer from the source select menu.
91- Torbox: Add Cloud downloads browsing.
92- Downloads: Fix `Download video` routing so source downloads start correctly.
93- Filters: Fix unknown source filtering and add a separate toggle for unknown quality results.
94- Debrid: Block direct playback of packed releases (zip, rar, etc) across debrid providers.
95- Fix: Update TMDBHelper json when outdated.
96- TMDB: Fix the Collections menu circular import crash.
97- Fix Prowlarr client search failing when receiving season and episode from TMDBHelper.
98
99Spanish:
100- Torrents: Añadidas acciones en la seleccion de fuentes para enviar torrents directamente a la nube de Debrid o a TorrServer.
101- Torbox: Añadida navegacion de descargas en Cloud.
102- Descargas: Corregido el enrutado de `Download video` para que las descargas desde fuentes se inicien correctamente.
103- Filtros: Corregido el filtrado de fuentes desconocidas y añadido un ajuste independiente para calidad desconocida.
104- Debrid: Bloqueada la reproducción directa de lanzamientos empaquetados (zip, rar, etc) en los proveedores de debrid.
105- Corregida la actualización del json de TMDBHelper cuando está desactualizado.
106- TMDB: Corregido el fallo por importacion circular en el menu de Collections.
107- Corregido el fallo en la búsqueda del cliente de Prowlarr al recibir temporada y episodio de TMDBHelper.
108
109## 1.6.0
110- Search: Add English TMDB title fallback for Jackett and Prowlarr when localized titles return no matches.
111- Playback: Handle magnet redirects and torrent URLs from indexers more reliably.
112- Search: Fix result sorting across all sort modes.
113- Search: Clarify the final no-results notification when no playable sources remain.
114- UI: Hide peers and seeders metadata when a source is already cached.
115
116Spanish:
117- Busqueda: Anadido fallback al titulo en ingles de TMDB para Jackett y Prowlarr cuando los titulos localizados no devuelven coincidencias.
118- Reproduccion: Mejorado el manejo de redirecciones magnet y URLs torrent de indexadores.
119- Busqueda: Corregida la ordenacion de resultados en todos los modos de ordenacion.
120- Busqueda: Aclarada la notificacion final cuando no quedan fuentes reproducibles.
121- UI: Ocultados los metadatos de peers y seeders cuando una fuente ya esta en cache.
122
123## 1.5.3
124- Trakt: Add 'Continue Watching' option for movies to easily resume playback.
125- Trakt: Fix Unprocessable Entity (422) error when scrobbling items with no playback progress.
126- Trakt: Filter hidden items in Up Next and remove list limits.
127- UI: Overhaul source select window with advanced technical metadata, modern layout, and dynamic color themes.
128- Stremio: Improve source labeling to show real indexer names.
129- Torbox: Fix authentication response parsing.
130- Settings: Add Trakt credentials migration and update defaults.
131
132Spanish:
133- Trakt: Añadida opción 'Continuar Viendo' para películas para reanudar fácilmente la reproducción.
134- Trakt: Corregido error 'Unprocessable Entity' (422) al registrar progreso (scrobbling) de elementos sin avance.
135- Trakt: Filtrar elementos ocultos en Up Next y eliminar límites de listas.
136- UI: Renovación de la ventana de selección de fuentes con metadatos técnicos avanzados, diseño moderno y temas de color dinámicos.
137- Stremio: Mejorado el etiquetado de fuentes para mostrar los nombres reales de los indexadores.
138- Torbox: Corregido el análisis de la respuesta de autenticación.
139- Ajustes: Añadida migración de credenciales de Trakt y actualizados los valores por defecto.
140
141
142## 1.5.2
143- Stremio: Distinguish addon instances by transport URL to support multiple accounts/configurations of the same addon with different flows.
144- Stremio: Support bypassing specific addon instances from Jacktook's filtering.
145- Settings: Reorganize backup options and add URL restore
146
147Spanish:
148- Stremio: Diferenciación de instancias de addons por URL de transporte para soportar múltiples cuentas/configuraciones del mismo addon con diferentes flujos.
149- Stremio: Soporte para omitir el filtrado de Jacktook en instancias específicas de addons.
150- Ajustes: Reorganización de las opciones de backup y añadido restauración por URL.
151
152
153## 1.5.1
154- Stremio: Limit imported addons to account and custom sources and improved large catalog handling.
155- Settings: Simplify Stremio-related visibility rules, remove the old Torrentio settings group, and set safer disabled-by-default values for Stremio, torrents, and bypassed addons.
156- Localization: Fix conflicting string IDs.
157
158Spanish:
159- Stremio: Limitadas las importaciones de addons a las fuentes de cuenta y personalizadas y mejorado el manejo de catalogos grandes.
160- Ajustes: Simplificadas las reglas de visibilidad relacionadas con Stremio, eliminado el antiguo grupo de ajustes de Torrentio y establecidos valores mas seguros desactivados por defecto para Stremio, torrents y addons omitidos.
161- Localizacion: Corregidos los IDs de cadenas en conflicto.
162
163## 1.5.0
164- Trakt Expansion: Added personal lists, search lists by keyword, create custom lists, favorites, account info.
165- Enhanced Trakt Sync: Improved scrobbling, progress tracking, and added periodic syncing service.
166- Added backup/reset tool for addon settings.
167- Modernized routing, provider registries, and service-based auth flows.
168- Fix Update flow that was partially broken cause of using incorrect addon identifier.
169- Improved scrobbler notifications and calendar item rendering.
170- Aligned logging with Kodi levels.
171- Other minor improvements.
172
173Spanish:
174- Expansión de Trakt: Añadidas listas personales, búsqueda de listas por palabra clave, creación de listas personalizadas, favoritos, información de la cuenta.
175- Sincronización mejorada de Trakt: Mejor scrobbling, seguimiento del progreso y servicio de sincronización periódica añadido.
176- Añadida herramienta de respaldo/restablecimiento de los ajustes del addon.
177- Modernizado el enrutamiento, los registros de proveedores y los flujos de autenticación basados en servicios.
178- Corregido el flujo de actualización que estaba parcialmente roto debido al uso de un identificador del addon incorrecto.
179- Mejoradas las notificaciones del scrobbler y la representación de los elementos del calendario.
180- Alineado el registro con los niveles de Kodi.
181- Otras mejoras menores.
182
183## 1.4.4
184
185- Real-Debrid: Fix multi-file movie torrents by selecting the largest playable file
186- Split subtitle feedback so missing subtitles and unselected subtitles show different messages.
187- Localize subtitle download dialogs and subtitle status notifications.
188
189Spanish:
190
191- Real-Debrid: Corregidos los torrents de peliculas con multiples archivos seleccionando el archivo reproducible mas grande.
192- Separados los mensajes de subtitulos no encontrados y subtitulos no seleccionados.
193- Localizados los dialogos de descarga de subtitulos y las notificaciones de estado de subtitulos.
194
195## 1.4.3
196
197- Improve loading speed for My Movies and My Shows by caching resolved library views.
198- Fix Continue Watching and Last Files labels when season or episode values are stored as strings.
199- Stremio: Fix scoped searches for TMDB-only addons and improve addon compatibility detection in the local web manager.
200- Stremio: Normalize manifest URLs, add better local HTTP fallback, and mark direct streams correctly for playback.
201- Jackett: Increase the configurable timeout range up to 300 seconds.
202- Jackgram: Harden latest endpoints and restore title source routing.
203
204Spanish:
205
206- Mejora la velocidad de carga de Mis Peliculas y Mis Series cacheando las vistas de biblioteca ya resueltas.
207- Corregidas las etiquetas de Seguir viendo y Ultimos archivos cuando los valores de temporada o episodio se guardan como texto.
208- Stremio: Corregidas las busquedas dirigidas para addons que solo usan TMDB y mejorada la deteccion de compatibilidad de addons en el gestor web local.
209- Stremio: Normalizadas las URLs de manifest, mejorado el fallback local por HTTP y corregido el marcado de streams directos para la reproduccion.
210- Jackett: Aumentado el rango configurable del tiempo de espera hasta 300 segundos.
211- Jackgram: Endurecidos los endpoints de ultimos y restaurado el enrutado del origen del titulo.
212
213## 1.4.2
214
215- Add new token setting for Jackgram.
216- Refactor internal Jackgram functions based on new API changes.
217
218Spanish:
219
220- Añadida nueva configuración de token para Jackgram.
221- Refactorización de funciones internas de Jackgram basado en los nuevos cambios de la API.
222
223## 1.4.1
224
225- Add fullscreen search status window with live task tracking.
226- Improve source provider name display for direct indexers.
227
228Spanish:
229
230- Añadida ventana de estado de búsqueda a pantalla completa con seguimiento de tareas en vivo.
231- Mejora en la visualización del nombre del proveedor de origen para indexadores directos.
232
233## 1.3.2
234
235- Fix: Easynews crash on Python versions older than 3.9 (e.g. Kodi 20).
236- Fix 'No Stremio addon selected' error when adding custom addons with commas in the URL.
237- Implement lazy loading for client modules to improve startup performance and stability.
238- Improvements to the update flow.
239
240Spanish:
241
242- Corregido: Error de Easynews en versiones de Python anteriores a 3.9 (ej. Kodi 20).
243- Corregido error: 'No Stremio addon selected' al añadir addons personalizados con comas en la URL.
244- Implementación de carga diferida para los módulos de clientes para mejorar el rendimiento y la estabilidad.
245- Mejora del mecanismo de actualización.
246
247## 1.3.1
248
249- Add 'Search by Keyword' functionality for Movies and TV Shows.
250- Add Easynews search support.
251- Add debrid subscription expiration notifications.
252- Add automatic volume safety check before starting playback.
253- Open actor info window when selecting a person from People menu.
254- Add client key support for Fanart.tv
255- Improve updater mechanism with progress visibility and better UI refresh.
256
257Spanish:
258
259- Añadida funcionalidad de 'Búsqueda por palabra clave' para películas y series.
260- Añadido soporte de búsqueda en Easynews.
261- Añadidas notificaciones de expiración de suscripción de debrid.
262- Añadido control automático de seguridad de volumen antes de iniciar la reproducción.
263- Abre la ventana de información del actor al seleccionar una persona del menú de Personas.
264- Añadido soporte de clave personal para Fanart.tv
265- Mejora del mecanismo de actualización con visibilidad del progreso y mejor refresco de la interfaz.
266
267## 1.2.1
268
269- Skin: Add 'Extras' and 'Actor Info' windows to media details.
270- Add IMDB scraper for fetching extras and cast information.
271
272Spanish:
273
274- Skin: Añadidas ventanas de 'Extras' e 'Información del actor' a los detalles de los medios.
275- Añadido scraper de IMDB para obtener extras e información del reparto.
276
277## 1.1.1
278
279- Stremio: Support multiple configurations of the same addon by identifying them via transport URL.
280- Stremio: Increase configurable request timeout range up to 120s and apply it globally to all requests.
281
282Spanish:
283
284- Stremio: Soporte para múltiples configuraciones del mismo addon identificándolos mediante la URL de transporte.
285- Stremio: Aumento del rango de tiempo de espera (timeout) configurable hasta 120 segundos y aplicación global a todas las peticiones.
286
287## 1.1.0
288
289- Add 'Downgrade Addon Version' functionality in settings to easily revert to previous releases.
290- Add 'Bypass Addon Filters' for selected Stremio addons, allowing them to skip Jacktook's native size/quality filtering.
291- Improve metadata resolution and movie detection for Stremio providers.
292- Improve Super Quick Play logic with better cache key handling for TV shows and movies.
293- Performance optimizations and improved error handling across several modules.
294
295Spanish:
296
297- Añadida función 'Revertir versión del addon' en los ajustes para volver fácilmente a versiones anteriores.
298- Añadida funcionalidad 'Omitir filtros de addons' para que los addons de Stremio seleccionados salten el filtrado nativo.
299- Mejora en la resolución de metadatos y detección de películas para los proveedores de Stremio.
300- Mejora en la lógica de 'Reproducción súper rápida' con un mejor manejo de claves de caché para series y películas.
301- Optimizaciones de rendimiento y mejora en el manejo de errores en varios módulos.
302
303## 1.0.0
304
305- Implement lazy loading for router and navigation for better performance.
306- Add local web server for managing Stremio addons via phone/browser.
307- Add skip intro and recap feature using IntroDB API.
308- Add support for Bingie Helper in TMDB auto-installer.
309- Add 'Continue Watching' and 'Library' section to main menu.
310- Grouped the main menu's 'Search' and 'Direct Search' entries into a single 'Search' menu to reduce clutter.
311- Add history management and clear options in the search menu.
312- Move the donation menu to addon settings.
313- Reordered main menu to place 'Library' below 'Live TV'.
314- Fix playback failure when subtitles were not found or selected
315- Fix QR code visibility in some custom dialogs (including donation).
316- Improved Kodi 21+ compatibility by addressing `setInfo()` deprecations.
317- Other minor fixes and improvements.
318
319Spanish:
320
321- Implementación de carga diferida (lazy loading) para el router y la navegación para mejorar el rendimiento.
322- Añadido servidor web local para gestionar addons de Stremio desde el teléfono/navegador.
323- Añadida función para saltar intros y resúmenes usando la API de IntroDB.
324- Añadida compatibilidad con Bingie Helper en el instalador automático de TMDB.
325- Añadida sección 'Seguir viendo' y 'Biblioteca' al menú principal.
326- Agrupadas las entradas 'Buscar' y 'Búsqueda directa' del menú principal en un único menú 'Buscar' para mejor organizacion.
327- Añadido gestión de historial y opciones de borrado en el menú de búsqueda.
328- Movido el menú de donaciones a los ajustes del addon.
329- Reordenado el menú principal para colocar 'Biblioteca' debajo de 'TV en vivo'.
330- Corregido el fallo de reproducción cuando los subtítulos no se encontraban o no se seleccionaban.
331- Corregida la visibilidad del código QR en algunos diálogos personalizados (incluyendo donaciones).
332- Mejora de la compatibilidad con Kodi 21+ al abordar las depreciaciones de `setInfo()`.
333- Otras correcciones menores y mejoras.
334
335## 0.24.0
336
337- Stremio: Add manual addon availability ping and filtering.
338- Stremio: Support for movie items with custom IDs.
339- TMDB: Add 'Airing Today' section for TV shows.
340- TMDB: Display clearlogos in category listings.
341- Anime: Add new anime categories.
342- Fix: Trakt sync error related to 'set_bulk_tvshow_progress'.
343- Fix: Mark Stremio Live TV streams as direct type for proper playback.
344- Fix: JacktookBurst using wrong ID type (replace tmdb_id with imdb_id).
345- Fix: Routing conflict by renaming internal 'play_media' helper.
346
347## 0.23.0
348
349- Implement hybrid cache and background preloading for better performance.
350- Add dedicated selection on Stremio settings for Stremio Live TV addons.
351- Add setting to show only Stremio catalogs instead of native catalogs.
352- Add granular cache cleaning options.
353- Add setting to enable the precaching of next episodes.
354- Add Trakt synchronization service, QR code authentication and improved resume capability.
355- Reorganized and improved Stremio settings with better grouping.
356- Improve Torbox integration with better pack resolution and localization.
357- Improve WebDAV connection robustness and fix playback URL issue.
358- Improve Stremio addon compatibility and manifest parsing.
359- Fix next episode skip bug and prevent player crashes.
360- Fix search results loss when P2P is enabled in Stremio.
361- Fix duplicate providers in Torrentio URL.
362- Fix various issues with Trakt authentication and sync.
363- Localize many UI strings.
364- Many other internal refactors and improvements to the Stremio client.
365
366Spanish:
367
368- Implementación de caché híbrida y precarga en segundo plano para un mejor rendimiento.
369- Añadida en la configuración de Stremio la selección dedicada para addons de TV en vivo.
370- Añadido ajuste para mostrar solo catálogos de Stremio en lugar de los catálogos nativos.
371- Añadido limpiado granular de caché.
372- Añadido ajuste para habilitar la precarga de los siguientes episodios.
373- Añadido servicio de sincronización con Trakt, autenticación con QR y mejora en la capacidad de reanudación.
374- Reorganización y mejora de los ajustes de Stremio con una mejor agrupación.
375- Mejora en la integración con Torbox, con mejor resolución de paquetes y localización.
376- Mejora en la robustez de la conexión WebDAV y corrección del error en la URL de reproducción.
377- Mejora en la compatibilidad de addons de Stremio y el análisis del manifest.
378- Corregido el error de salto al siguiente episodio y prevención de cuelgues del reproductor.
379- Corregida la pérdida de resultados de búsqueda cuando P2P está habilitado en Stremio.
380- Corregida la duplicación de proveedores en la URL de Torrentio.
381- Corregidos varios problemas con la autenticación y sincronización de Trakt.
382- Localización de varias cadenas de la interfaz.
383- Muchas otras refactorizaciones internas y mejoras en el cliente de Stremio.
384
385## 0.22.0
386
387- Add super quick play feature (last played dialog) for faster playback.
388- Add torrent and debrid filter options on source select window.
389- Add precaching of next episodes with settings option.
390- Add automatic subtitle selection.
391- Add broadcast day adjustment setting for the weekly calendar.
392- Add context menu actions for last files
393- Add context menu actions for weekly calendar episodes
394- Implement accurate search progress indicator.
395- Fix search with non-ASCII characters.
396- Improve magnet link extraction from torrent files.
397
398Spanish:
399
400- Añadida función de reproducción súper rápida (diálogo de última reproducción) para una reproducción más veloz.
401- Añadidas opciones de filtrado de torrent y debrid en la ventana de selección de fuente.
402- Añadida precarga (precaching) de los siguientes episodios con opción en los ajustes.
403- Añadida selección automática de subtítulos.
404- Añadido ajuste del día de emisión para el calendario semanal.
405- Añadidas acciones del menú contextual para los últimos archivos.
406- Añadidas acciones del menú contextual para los episodios del calendario semanal.
407- Implementado un indicador preciso del progreso de búsqueda.
408- Corregida la búsqueda con caracteres no ASCII.
409- Mejorada la extracción de enlaces magnet de archivos torrent.
410
411## 0.21.0
412
413- Implemented parallel Debrid/P2P resolution.
414- Redesigned settings for more intuitive setup.
415- Fixed next dialog triggering at wrong time in player.
416- Enhanced update notifications for better user feedback.
417- Other minor fixes and improvements.
418
419## 0.20.0
420
421- Added functionality to remove custom addons from settings.
422- Fixed subtitle translation not working.
423
424## 0.19.0
425
426- Added WebDAV client integration with browsing and playback support.
427- Improved magnet extraction and fixed URLs not being passed correctly to torrent clients.
428- Increased the default maximum size and adjusted the slider step for better control.
429- Added configurable timeout setting for Stremio requests.
430- Removed debrid cached search logic to avoid inconsistencies.
431- Fixed correct values display for addons like UsenetStreamer.
432- Fixed correct rendering of addons names on source selection when using AIOStreams addon.
433- Other minor fixes and improvements.
434
435## 0.18.0
436
437- Rewrite TMDB directory handling to include full metadata only when needed and simplify search result rendering.
438- Add automatic subtitle download and improved storage/retrieval for individual episodes.
439- Automatically convert URLs starting with stremio:// to https:// when adding custom addons.
440- Fix Jackett search not working when a password is set
441- Other minor fixes and improvements.
442
443Spanish:
444
445- Reescritura del manejo de directorios de TMDb para incluir solo los metadatos completos cuando sea necesario y simplificar la visualización de resultados de búsqueda.
446- Añadida descarga automática de subtítulos y mejora en el almacenamiento/recuperación de episodios individuales.
447- Conversión automática de URLs que comienzan con stremio:// a https:// al añadir addons personalizados.
448- Corrección en la búsqueda de Jackett cuando se establece una contraseña.
449- Otras mejoras y correcciones menores.
450
451Note:
452Si 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!
453
454If you enjoy this addon, please consider supporting the project with a donation. Your contributions helps keep improving and maintaining it. Thank you!
455
456## 0.17.0
457
458- Add AllDebrid support
459- Add cached search for AllDebrid
460- Fix RealDebrid episodes match not reliable.
461- Other minor fixes and improvements.
462
463Spanish:
464
465- Se añadió soporte para AllDebrid
466- Se añadió búsqueda con caché para AllDebrid
467- Se arreglo errores con la coincidencia de episodios en RealDebrid que no era 100% fiable.
468- Otras mejoras y correcciones menores.
469
470Note:
471Si 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!
472
473If you enjoy this addon, please consider supporting the project with a donation. Your contributions helps keep improving and maintaining it. Thank you!
474
475## 0.16.0
476
477- Add TMDB logo support for clearlogo.
478- Prowlarr: add parallel query execution and refined TV search.
479- Jackett: add multi-indexer parallel search and improved URL building.
480- Add port configuration on settings for Jackett and Prowlarr clients.
481- Enhance season pack pattern matching with additional regex options.
482- Stremio/UI: add Rutor provider and localized title for Torrentio provider selection.
483- Player: notify and handle missing magnet or URL.
484- Fanart: simplify fanart fetching.
485- Updater: replace dialog_ok with a notification for “no update” messages.
486- Skin: refactor resolver.xml layout and adjust clearlogo/fanart positioning.
487- Skin: minor UI layout improvements in resolver window and clearlogo positioning.
488- Better grouping of addon and settings configuration options.
489- Many other minor fixes and improvements.
490
491## 0.15.1
492
493- HOTFIXED: Resolved multiple issues preventing addon updates.
494- Ensured ordered results in show and episode listings.
495
496## 0.15.0
497
498- Delay closing resolve source window until playback starts.
499- Added context menu options for selecting stremio sources and opening addon settings.
500- Added option on settings to clear all history
501- Updated several menu icons for better consistency.
502- Improved tvshow and movie metadata handling.
503- Improved cast/crew extraction.
504- Improved display and sorting of last files and titles.
505- Other minor improvements and fixes.
506
507Note:
508Si 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!
509
510If you enjoy this addon, please consider supporting the project with a donation. Your contributions helps keep improving and maintaining it. Thank you!
511
512## 0.14.0
513
514- Updated menu icons for Telegram and Downloads.
515- Added filtering options setting for quality/source.
516- Improved initialization and handling of invalid tokens on Real Debrid
517- Simplified addon display name in jacktook.select.json.
518- Other minor improvements.
519
520Note:
521Si 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!
522
523If you enjoy this addon, please consider supporting the project with a donation. Your contributions helps keep improving and maintaining it. Thank you!
524
525## 0.13.0
526
527- Added support for [MDBList](https://mdblist.com/)
528- Added people category for movies and TV shows.
529- Added context menu option to search people for movies and TV shows.
530- Added setting to include/exclude TV show specials.
531- Added metadata info to playback info for richer UI display.
532- Added subtitle download option from the source select window.
533- Added pagination to “last titles” view.
534- Added export Kodi logs to paste.kodi.tv.
535- Added excluded addons filter to Stremio toggle dialog to show only functional and manually tested addons.
536- Other minor improvements and fixes.
537
538Spanish:
539
540- Se añadió soporte para [MDBList](https://mdblist.com/)
541- Se añadió la categoría de personas para películas y series de TV.
542- Se añadió una opción en el menú contextual para buscar personas en películas y series de TV.
543- Se añadió la opción para incluir/excluir episodios especiales de series de TV.
544- Se añadió información de metadatos en la información de reproducción para una interfaz más completa.
545- Se añadió la opción de descarga de subtítulos desde la ventana de selección de fuente.
546- Se añadió paginación a la vista de “últimos títulos”.
547- Se añadió la exportación de registros de Kodi a paste.kodi.tv.
548- Se añadió un filtro interno de addons excluidos al cuadro de diálogo de seleccion de addons de Stremio para mostrar solo addons funcionales y testeados.
549- Otras mejoras y correcciones menores.
550
551Note:
552Si 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!
553
554If you enjoy this addon, please consider supporting the project with a donation. Your contributions helps keep improving and maintaining it. Thank you!
555
556## 0.12.0
557
558- Added TMDB collections, languages, networks, and popular filters.
559- Added QR code dialog to simplify authentication for debrid services
560- Updated Stremio community addons URL to stable version (https://stremio-addons.net/).
561- Added notification messages to users when no stremio addons or catalogs are selected.
562- Added plugin categories for clearer window identification
563- Other minor improvements and fixes.
564
565Spanish:
566
567- Se añadieron colecciones, idiomas, cadenas de televisión y filtros populares de TMDB.
568- Se añadió un diálogo de código QR para simplificar la autenticación de los servicios de debrid.
569- Se actualizó la URL de los addons de la comunidad de Stremio a la version estable (https://stremio-addons.net/).
570- Se añadieron mensajes de notificación a los usuarios cuando no se seleccionan addons o catálogos de Stremio.
571- Se añadieron categorías para una identificación más clara de las ventanas.
572- Otras mejoras y correcciones menores.
573
574Note:
575Si 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!
576
577If you enjoy this addon, please consider supporting the project with a donation. Your contributions helps keep improving and maintaining it. Thank you!
578
579## 0.11.0
580
581- Added support for Debrider (https://debrider.app/)
582- Other minor improvements and fixes.
583
584## 0.10.0
585
586- Added external cached search support for Real-Debrid.
587- Updated URL for fetching community addons (migrated to beta.stremio-addons.net).
588- Added resumable downloads and active download status.
589- Added option for size range filtering.
590- Added option to view Kodi logs and Kodi old logs.
591- Added option to adjust number of threads.
592- Improved Stremio catalog fetching to shows more catalogs.
593- Other minor improvements and fixes.
594
595Spanish:
596
597- Se añadió compatibilidad con búsqueda externa en caché para Real-Debrid.
598- Se actualizó la URL para obtener addons de la comunidad (migracion a beta.stremio-addons.net).
599- Se añadieron descargas reanudables y estado de descarga activa.
600- Se añadió la opción de filtrado por rango de tamaño.
601- Se añadió la opción de ver los registros de Kodi y los registros antiguos de Kodi.
602- Se añadió la opción de ajustar el número de hilos.
603- Se mejoró la obtención de catálogos de Stremio para mostrar más catálogos.
604- Otras mejoras y correcciones menores.
605
606Note:
607Si 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!
608
609If you enjoy this addon, please consider supporting the project with a donation. Your contributions helps keep improving and maintaining it. Thank you!
610
611## 0.9.0
612
613- Added Torrentio providers selection and settings UI.
614- Episodes are now marked as playable in TMDB and calendar views.
615- Replaced hardcoded context menu labels with translation entries (English and Spanish).
616- Replaced hardcoded menus labels with translation entries. (English and Spanish).
617
618Note:
619Si 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!
620
621If you enjoy this addon, please consider supporting the project with a donation. Your contributions helps keep improving and maintaining it. Thank you!
622
623## 0.8.0
624
625Features:
626
627- Implement TV calendar on History and TV Shows menus, to show weekly episodes.
628- Add context menu items for TMDB recommendations and similar content.
629- Add installation prompts for missing addons: Elementum, Jacktorr, and Torrest.
630- Update filter options label to indicate usage of the left key.
631- Adjust thread pool max workers calculation for better performance on low end devices.
632 Refactors:
633- Replace usage of main_db with PickleDatabase across the addon.
634 Fixes:
635- Handle empty Stremio addons to prevent crashes.
636- Make Cache class thread-safe and more robust.
637
638## 0.7.0
639
640- Add audio language selection for playback.
641- Add functionality to add custom Stremio addons via URL.
642- Add support for searching and filtering season packs (Jackett & Prowlarr).
643- Add 'Show Changelog' setting and functionality to display addon updates.
644- Renamed history menu items for clarity.
645- Enabled scrolling for the title label in source selection.
646- Implement token validation and adjust expiration time for Trakt authentication.
647- Enhance subtitle file naming and slugify paths for better organization.
648- Other minor improvements and fixes.
649
650## 0.6.0
651
652- Improve source select layout with clickable quality header.
653- Add more filtering options on source select window (languages, sources)
654- Minor improvements and fixes
655
656## 0.5.4
657
658- HOTFIX: Fix Burst client not working.
659
660## 0.5.3
661
662- Added support to downloasd subtitles using Stremio OpenSubtitle addon.
663- Added support for subtitle translation using DeepL (AI translation).
664- Added auto-play with quality selection.
665- Added auto-subtitle activation and selection on playback.
666
667## 0.5.2
668
669- Added download functionality
670- Added Trakt scrobbling
671- Added new menu item to add to Trakt watchlist.
672- Added new menu item to mark as watched/unwatched on Trakt.
673- Added new window to filter fetched results (right arrow or button to show).
674- Many other improvements.
675
676## 0.5.1
677
678- UI Improvements
679- TMDB Helper json updated
680- Some fixes and improvements
681
682## 0.5.0
683
684- Add support to import and use Stremio catalogs.
685- Add setting button action to update Stremio addons after logged in.
686- Some fixes and improvements with with Real Debrid packs detection.
687
688## 0.4.10
689
690- Hotfix: not detecting season pack when using debrid
691
692## 0.4.9
693
694- Add Priority Language selection setting to filter results.
695- Fix missing some supported Stremio Addons.
696- Fix autoplay
697
698## 0.4.8
699
700- Add support to import Stremio Addons from Stremio.io Account.
701
702## 0.4.7
703
704- Add Peerflix indexer support.
705- Fix Mediafusion not playing sources when using torrents
706- Other minor improvements and fixes
707
708## 0.4.6
709
710- Fix a bug that was causing fanart media details not been updating correctly
711
712## 0.4.5
713
714- Add language and region selection setting for TMDB.
715- Fix RealDebrid episode selection on season packs
716- Fix and handle torrents where its url redirects to magnets.
717- Refactor settings.xml to new Kodi format.
718- Other minor improvements and fixes
719
720## 0.4.4
721
722- some fixes and improvements
723
724## 0.4.3
725
726- Add search capability across multiple indexers
727- Add filter by episode and season option
728- Some visual improvements for Arctic Horizon 2 skin.
729- Other minor improvements and fixes
730
731## 0.4.2
732
733- Add Telegram menu (with option to enable/disable) with submenus to access Jackgram cached files (for advanced users)
734- Add Anime year and genre search.
735- Add TMDB year search for TV and Movies.
736- Add Trakt popular lists.
737- Some visual improvements on direct search source selection.
738- Others minor improvements and fixes.
739
740## 0.4.1
741
742- Add play next dialog for episodes playback
743- Add automatic playlist creation for shows episodes.
744- Update tmdb helper to work with the new routing system.
745- Update Jackgram Indexer.
746- Others minor improvements and fixes.
747
748## 0.4.0
749
750- Add custom source select and resolve windows dialog.
751- Improvements and fixes.
752
753## 0.3.9
754
755- Add EasyDebrid support.
756- Add MediaFusion indexer.
757
758## 0.3.8
759
760- Real Debrid Improvements.
761 - check if the torrent is on user torrents list, so not to add again.
762 - add a check for max activate downloads, and delete one if limit achieved.
763- Direct Search Improvements.
764 - save search queries, and added menu item option to modify search queries by the user
765- Torbox Improvements
766 - use public ip address of user for torbox cdn selecction
767- Multi Debrid Search support
768- Add jackgram_results_per_page setting option for Jackgram client.
769
770## 0.3.7
771
772- Update Premiumize.
773- Update Zilean (now supports movies and tv search)
774- Update TMDB Helper json file with zip file
775
776## 0.3.6
777
778- Fix tmdb movies results not showing on main search
779- Improve Jackett/Prowlarr integration with Debrid for faster results
780- Add mechanism to detect indexers change when making a search
781- Minor fixes and improvements.
782
783## 0.3.5
784
785- Fix Real Debrid Authentification not working
786- Add Real Debrid remove authorization button and username info on settings
787- Add option to select Kodi language for TMDB metadata.
788- Playback improvements
789
790## 0.3.4
791
792- Improvements on Torbox
793
794## 0.3.3
795
796- Refactor Real Debrid due to recent API changes
797- Fixed Torrentio not getting results
798- Improvements on Torbox
799- Other minor improvements and fixes
800
801## 0.3.2
802
803- Switch to tmdb and trakt for Anime section
804- Add Jackgram Support
805- Remove Plex Support
806- Bug fixes and improvements
807
808## 0.3.1
809
810- Add "rescrape item" context menu item to anime shows results
811- Several bug fixes and some improvements
812
813## 0.3.0
814
815- Add Trakt support with several categories
816- Update on genres menu and zilean indexer
817
818## 0.2.9
819
820- Add Zilean indexer support
821- Minor bug fixes
822
823## 0.2.8
824
825- Add resume playback for videos
826- Add new icons for main menu
827
828## 0.2.7
829
830- add "check if pack" context menu item
831- several fixes and improvements
832
833## 0.2.6
834
835- Added service for auto-update addon
836- Added update addon setting action
837- Whole refactoring of jacktook repository
838
839## 0.2.5
840
841- Added Plex support (beta)
842- Added support for torbox debrid
843- Major anime improvements
844- Faster debrid fetch
845
846## 0.2.4 (2024-04-12)
847
848- Added jacktorr burst support
849- Improvements on torrent fetch (faster resolve)
850- Other minor improvements and fixes
851
852## 0.2.3 (2024-04-06)
853
854- Added jacktorr torrent client support
855- Added jacktorr managment from addon instead of torrest
856- Other minor improvements and fixes
857
858## 0.2.2 (2024-03-22)
859
860- Added autoplay feature
861- Added clients timeouts and cache activation settings
862- Added new Tmdb helper addon json
863- Torrentio language improvements
864- Fixed kodi 19 compatibility
865- Other minor improvements and fixes
866
867## 0.2.1 (2024-03-12)
868
869- torrentio improvements (priority language, sort by language, etc)
870- added portugese translation
871- added trakt scrobbling support
872- others improvements and fixes
873
874## 0.2.0 (2024-03-04)
875
876- Add option to select and use all torrents clients at the same time.
877- Add pagination to tmdb and anilist search
878- Fix for real debrid download history issue (only played videos will be saved)
879
880## 0.1.9 (2024-02-21)
881
882- Fixed RD torrent stored on cloud
883- Add sort by quality to torrentio and elfhosted
884
885## 0.1.8 (2024-02-21)
886
887- Add TMDB helper Addon support
888- Add new torrents menu
889- Add Elementum as another torrent client
890
891## 0.1.7 (2024-02-21)
892
893- Add anime episode search
894- Add Premiumize support
895- Add Elfhosted support
896
897## 0.1.6 (2024-02-12)
898
899 * Fix a critical bug for Prowlarr
900
901## 0.1.5 (2024-02-12)
902
903 * Improvements for Prowlarr
904 * Some UI changes
905
906## 0.1.4 (2024-02-10)
907
908 * Add Torrentio support
909
910## 0.1.3 (2024-02-7)
911
912 * Add "clear all cache" option on addon settings
913 * Caching of indexers results
914 * Improvements and fixes
915
916## 0.1.2 (2024-02-5)
917
918### Features
919
920 * Add download uncached magnet to debrid option menu
921 * Add show uncached option
922 * Switch to torznab api for Jackett
923
924## 0.1.1 (2024-01-30)
925
926### Features
927
928 * Add authentifcation flow for Realdebrid from Kodi
929
930 * Fix and Update Debrid with Prowlarr Integration
931
932 * Add new title history menu
933
934 * Improvements on tmdb search
935
936## 0.1.0 (2024-01-27)
937
938### Features
939
940 * Real Debrid Support
941
942 * Improvements on tv search results
943
944## 0.0.1 (2023-09-02)
945
946### Features
947
948 * Jackett integration
949
950 * Torrest Integration
951
952 * TMDB Integration
plugin.video.jacktook/LICENSE +674 -0
……@@ -0,0 +1,674 @@
1GNU GENERAL PUBLIC LICENSE
2 Version 3, 29 June 2007
3
4 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5 Everyone is permitted to copy and distribute verbatim copies
6 of this license document, but changing it is not allowed.
7
8 Preamble
9
10 The GNU General Public License is a free, copyleft license for
11software and other kinds of works.
12
13 The licenses for most software and other practical works are designed
14to take away your freedom to share and change the works. By contrast,
15the GNU General Public License is intended to guarantee your freedom to
16share and change all versions of a program--to make sure it remains free
17software for all its users. We, the Free Software Foundation, use the
18GNU General Public License for most of our software; it applies also to
19any other work released this way by its authors. You can apply it to
20your programs, too.
21
22 When we speak of free software, we are referring to freedom, not
23price. Our General Public Licenses are designed to make sure that you
24have the freedom to distribute copies of free software (and charge for
25them if you wish), that you receive source code or can get it if you
26want it, that you can change the software or use pieces of it in new
27free programs, and that you know you can do these things.
28
29 To protect your rights, we need to prevent others from denying you
30these rights or asking you to surrender the rights. Therefore, you have
31certain responsibilities if you distribute copies of the software, or if
32you modify it: responsibilities to respect the freedom of others.
33
34 For example, if you distribute copies of such a program, whether
35gratis or for a fee, you must pass on to the recipients the same
36freedoms that you received. You must make sure that they, too, receive
37or can get the source code. And you must show them these terms so they
38know their rights.
39
40 Developers that use the GNU GPL protect your rights with two steps:
41(1) assert copyright on the software, and (2) offer you this License
42giving you legal permission to copy, distribute and/or modify it.
43
44 For the developers' and authors' protection, the GPL clearly explains
45that there is no warranty for this free software. For both users' and
46authors' sake, the GPL requires that modified versions be marked as
47changed, so that their problems will not be attributed erroneously to
48authors of previous versions.
49
50 Some devices are designed to deny users access to install or run
51modified versions of the software inside them, although the manufacturer
52can do so. This is fundamentally incompatible with the aim of
53protecting users' freedom to change the software. The systematic
54pattern of such abuse occurs in the area of products for individuals to
55use, which is precisely where it is most unacceptable. Therefore, we
56have designed this version of the GPL to prohibit the practice for those
57products. If such problems arise substantially in other domains, we
58stand ready to extend this provision to those domains in future versions
59of the GPL, as needed to protect the freedom of users.
60
61 Finally, every program is threatened constantly by software patents.
62States should not allow patents to restrict development and use of
63software on general-purpose computers, but in those that do, we wish to
64avoid the special danger that patents applied to a free program could
65make it effectively proprietary. To prevent this, the GPL assures that
66patents cannot be used to render the program non-free.
67
68 The precise terms and conditions for copying, distribution and
69modification follow.
70
71 TERMS AND CONDITIONS
72
73 0. Definitions.
74
75 "This License" refers to version 3 of the GNU General Public License.
76
77 "Copyright" also means copyright-like laws that apply to other kinds of
78works, such as semiconductor masks.
79
80 "The Program" refers to any copyrightable work licensed under this
81License. Each licensee is addressed as "you". "Licensees" and
82"recipients" may be individuals or organizations.
83
84 To "modify" a work means to copy from or adapt all or part of the work
85in a fashion requiring copyright permission, other than the making of an
86exact copy. The resulting work is called a "modified version" of the
87earlier work or a work "based on" the earlier work.
88
89 A "covered work" means either the unmodified Program or a work based
90on the Program.
91
92 To "propagate" a work means to do anything with it that, without
93permission, would make you directly or secondarily liable for
94infringement under applicable copyright law, except executing it on a
95computer or modifying a private copy. Propagation includes copying,
96distribution (with or without modification), making available to the
97public, and in some countries other activities as well.
98
99 To "convey" a work means any kind of propagation that enables other
100parties to make or receive copies. Mere interaction with a user through
101a computer network, with no transfer of a copy, is not conveying.
102
103 An interactive user interface displays "Appropriate Legal Notices"
104to the extent that it includes a convenient and prominently visible
105feature that (1) displays an appropriate copyright notice, and (2)
106tells the user that there is no warranty for the work (except to the
107extent that warranties are provided), that licensees may convey the
108work under this License, and how to view a copy of this License. If
109the interface presents a list of user commands or options, such as a
110menu, a prominent item in the list meets this criterion.
111
112 1. Source Code.
113
114 The "source code" for a work means the preferred form of the work
115for making modifications to it. "Object code" means any non-source
116form of a work.
117
118 A "Standard Interface" means an interface that either is an official
119standard defined by a recognized standards body, or, in the case of
120interfaces specified for a particular programming language, one that
121is widely used among developers working in that language.
122
123 The "System Libraries" of an executable work include anything, other
124than the work as a whole, that (a) is included in the normal form of
125packaging a Major Component, but which is not part of that Major
126Component, and (b) serves only to enable use of the work with that
127Major Component, or to implement a Standard Interface for which an
128implementation is available to the public in source code form. A
129"Major Component", in this context, means a major essential component
130(kernel, window system, and so on) of the specific operating system
131(if any) on which the executable work runs, or a compiler used to
132produce the work, or an object code interpreter used to run it.
133
134 The "Corresponding Source" for a work in object code form means all
135the source code needed to generate, install, and (for an executable
136work) run the object code and to modify the work, including scripts to
137control those activities. However, it does not include the work's
138System Libraries, or general-purpose tools or generally available free
139programs which are used unmodified in performing those activities but
140which are not part of the work. For example, Corresponding Source
141includes interface definition files associated with source files for
142the work, and the source code for shared libraries and dynamically
143linked subprograms that the work is specifically designed to require,
144such as by intimate data communication or control flow between those
145subprograms and other parts of the work.
146
147 The Corresponding Source need not include anything that users
148can regenerate automatically from other parts of the Corresponding
149Source.
150
151 The Corresponding Source for a work in source code form is that
152same work.
153
154 2. Basic Permissions.
155
156 All rights granted under this License are granted for the term of
157copyright on the Program, and are irrevocable provided the stated
158conditions are met. This License explicitly affirms your unlimited
159permission to run the unmodified Program. The output from running a
160covered work is covered by this License only if the output, given its
161content, constitutes a covered work. This License acknowledges your
162rights of fair use or other equivalent, as provided by copyright law.
163
164 You may make, run and propagate covered works that you do not
165convey, without conditions so long as your license otherwise remains
166in force. You may convey covered works to others for the sole purpose
167of having them make modifications exclusively for you, or provide you
168with facilities for running those works, provided that you comply with
169the terms of this License in conveying all material for which you do
170not control copyright. Those thus making or running the covered works
171for you must do so exclusively on your behalf, under your direction
172and control, on terms that prohibit them from making any copies of
173your copyrighted material outside their relationship with you.
174
175 Conveying under any other circumstances is permitted solely under
176the conditions stated below. Sublicensing is not allowed; section 10
177makes it unnecessary.
178
179 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180
181 No covered work shall be deemed part of an effective technological
182measure under any applicable law fulfilling obligations under article
18311 of the WIPO copyright treaty adopted on 20 December 1996, or
184similar laws prohibiting or restricting circumvention of such
185measures.
186
187 When you convey a covered work, you waive any legal power to forbid
188circumvention of technological measures to the extent such circumvention
189is effected by exercising rights under this License with respect to
190the covered work, and you disclaim any intention to limit operation or
191modification of the work as a means of enforcing, against the work's
192users, your or third parties' legal rights to forbid circumvention of
193technological measures.
194
195 4. Conveying Verbatim Copies.
196
197 You may convey verbatim copies of the Program's source code as you
198receive it, in any medium, provided that you conspicuously and
199appropriately publish on each copy an appropriate copyright notice;
200keep intact all notices stating that this License and any
201non-permissive terms added in accord with section 7 apply to the code;
202keep intact all notices of the absence of any warranty; and give all
203recipients a copy of this License along with the Program.
204
205 You may charge any price or no price for each copy that you convey,
206and you may offer support or warranty protection for a fee.
207
208 5. Conveying Modified Source Versions.
209
210 You may convey a work based on the Program, or the modifications to
211produce it from the Program, in the form of source code under the
212terms of section 4, provided that you also meet all of these conditions:
213
214 a) The work must carry prominent notices stating that you modified
215 it, and giving a relevant date.
216
217 b) The work must carry prominent notices stating that it is
218 released under this License and any conditions added under section
219 7. This requirement modifies the requirement in section 4 to
220 "keep intact all notices".
221
222 c) You must license the entire work, as a whole, under this
223 License to anyone who comes into possession of a copy. This
224 License will therefore apply, along with any applicable section 7
225 additional terms, to the whole of the work, and all its parts,
226 regardless of how they are packaged. This License gives no
227 permission to license the work in any other way, but it does not
228 invalidate such permission if you have separately received it.
229
230 d) If the work has interactive user interfaces, each must display
231 Appropriate Legal Notices; however, if the Program has interactive
232 interfaces that do not display Appropriate Legal Notices, your
233 work need not make them do so.
234
235 A compilation of a covered work with other separate and independent
236works, which are not by their nature extensions of the covered work,
237and which are not combined with it such as to form a larger program,
238in or on a volume of a storage or distribution medium, is called an
239"aggregate" if the compilation and its resulting copyright are not
240used to limit the access or legal rights of the compilation's users
241beyond what the individual works permit. Inclusion of a covered work
242in an aggregate does not cause this License to apply to the other
243parts of the aggregate.
244
245 6. Conveying Non-Source Forms.
246
247 You may convey a covered work in object code form under the terms
248of sections 4 and 5, provided that you also convey the
249machine-readable Corresponding Source under the terms of this License,
250in one of these ways:
251
252 a) Convey the object code in, or embodied in, a physical product
253 (including a physical distribution medium), accompanied by the
254 Corresponding Source fixed on a durable physical medium
255 customarily used for software interchange.
256
257 b) Convey the object code in, or embodied in, a physical product
258 (including a physical distribution medium), accompanied by a
259 written offer, valid for at least three years and valid for as
260 long as you offer spare parts or customer support for that product
261 model, to give anyone who possesses the object code either (1) a
262 copy of the Corresponding Source for all the software in the
263 product that is covered by this License, on a durable physical
264 medium customarily used for software interchange, for a price no
265 more than your reasonable cost of physically performing this
266 conveying of source, or (2) access to copy the
267 Corresponding Source from a network server at no charge.
268
269 c) Convey individual copies of the object code with a copy of the
270 written offer to provide the Corresponding Source. This
271 alternative is allowed only occasionally and noncommercially, and
272 only if you received the object code with such an offer, in accord
273 with subsection 6b.
274
275 d) Convey the object code by offering access from a designated
276 place (gratis or for a charge), and offer equivalent access to the
277 Corresponding Source in the same way through the same place at no
278 further charge. You need not require recipients to copy the
279 Corresponding Source along with the object code. If the place to
280 copy the object code is a network server, the Corresponding Source
281 may be on a different server (operated by you or a third party)
282 that supports equivalent copying facilities, provided you maintain
283 clear directions next to the object code saying where to find the
284 Corresponding Source. Regardless of what server hosts the
285 Corresponding Source, you remain obligated to ensure that it is
286 available for as long as needed to satisfy these requirements.
287
288 e) Convey the object code using peer-to-peer transmission, provided
289 you inform other peers where the object code and Corresponding
290 Source of the work are being offered to the general public at no
291 charge under subsection 6d.
292
293 A separable portion of the object code, whose source code is excluded
294from the Corresponding Source as a System Library, need not be
295included in conveying the object code work.
296
297 A "User Product" is either (1) a "consumer product", which means any
298tangible personal property which is normally used for personal, family,
299or household purposes, or (2) anything designed or sold for incorporation
300into a dwelling. In determining whether a product is a consumer product,
301doubtful cases shall be resolved in favor of coverage. For a particular
302product received by a particular user, "normally used" refers to a
303typical or common use of that class of product, regardless of the status
304of the particular user or of the way in which the particular user
305actually uses, or expects or is expected to use, the product. A product
306is a consumer product regardless of whether the product has substantial
307commercial, industrial or non-consumer uses, unless such uses represent
308the only significant mode of use of the product.
309
310 "Installation Information" for a User Product means any methods,
311procedures, authorization keys, or other information required to install
312and execute modified versions of a covered work in that User Product from
313a modified version of its Corresponding Source. The information must
314suffice to ensure that the continued functioning of the modified object
315code is in no case prevented or interfered with solely because
316modification has been made.
317
318 If you convey an object code work under this section in, or with, or
319specifically for use in, a User Product, and the conveying occurs as
320part of a transaction in which the right of possession and use of the
321User Product is transferred to the recipient in perpetuity or for a
322fixed term (regardless of how the transaction is characterized), the
323Corresponding Source conveyed under this section must be accompanied
324by the Installation Information. But this requirement does not apply
325if neither you nor any third party retains the ability to install
326modified object code on the User Product (for example, the work has
327been installed in ROM).
328
329 The requirement to provide Installation Information does not include a
330requirement to continue to provide support service, warranty, or updates
331for a work that has been modified or installed by the recipient, or for
332the User Product in which it has been modified or installed. Access to a
333network may be denied when the modification itself materially and
334adversely affects the operation of the network or violates the rules and
335protocols for communication across the network.
336
337 Corresponding Source conveyed, and Installation Information provided,
338in accord with this section must be in a format that is publicly
339documented (and with an implementation available to the public in
340source code form), and must require no special password or key for
341unpacking, reading or copying.
342
343 7. Additional Terms.
344
345 "Additional permissions" are terms that supplement the terms of this
346License by making exceptions from one or more of its conditions.
347Additional permissions that are applicable to the entire Program shall
348be treated as though they were included in this License, to the extent
349that they are valid under applicable law. If additional permissions
350apply only to part of the Program, that part may be used separately
351under those permissions, but the entire Program remains governed by
352this License without regard to the additional permissions.
353
354 When you convey a copy of a covered work, you may at your option
355remove any additional permissions from that copy, or from any part of
356it. (Additional permissions may be written to require their own
357removal in certain cases when you modify the work.) You may place
358additional permissions on material, added by you to a covered work,
359for which you have or can give appropriate copyright permission.
360
361 Notwithstanding any other provision of this License, for material you
362add to a covered work, you may (if authorized by the copyright holders of
363that material) supplement the terms of this License with terms:
364
365 a) Disclaiming warranty or limiting liability differently from the
366 terms of sections 15 and 16 of this License; or
367
368 b) Requiring preservation of specified reasonable legal notices or
369 author attributions in that material or in the Appropriate Legal
370 Notices displayed by works containing it; or
371
372 c) Prohibiting misrepresentation of the origin of that material, or
373 requiring that modified versions of such material be marked in
374 reasonable ways as different from the original version; or
375
376 d) Limiting the use for publicity purposes of names of licensors or
377 authors of the material; or
378
379 e) Declining to grant rights under trademark law for use of some
380 trade names, trademarks, or service marks; or
381
382 f) Requiring indemnification of licensors and authors of that
383 material by anyone who conveys the material (or modified versions of
384 it) with contractual assumptions of liability to the recipient, for
385 any liability that these contractual assumptions directly impose on
386 those licensors and authors.
387
388 All other non-permissive additional terms are considered "further
389restrictions" within the meaning of section 10. If the Program as you
390received it, or any part of it, contains a notice stating that it is
391governed by this License along with a term that is a further
392restriction, you may remove that term. If a license document contains
393a further restriction but permits relicensing or conveying under this
394License, you may add to a covered work material governed by the terms
395of that license document, provided that the further restriction does
396not survive such relicensing or conveying.
397
398 If you add terms to a covered work in accord with this section, you
399must place, in the relevant source files, a statement of the
400additional terms that apply to those files, or a notice indicating
401where to find the applicable terms.
402
403 Additional terms, permissive or non-permissive, may be stated in the
404form of a separately written license, or stated as exceptions;
405the above requirements apply either way.
406
407 8. Termination.
408
409 You may not propagate or modify a covered work except as expressly
410provided under this License. Any attempt otherwise to propagate or
411modify it is void, and will automatically terminate your rights under
412this License (including any patent licenses granted under the third
413paragraph of section 11).
414
415 However, if you cease all violation of this License, then your
416license from a particular copyright holder is reinstated (a)
417provisionally, unless and until the copyright holder explicitly and
418finally terminates your license, and (b) permanently, if the copyright
419holder fails to notify you of the violation by some reasonable means
420prior to 60 days after the cessation.
421
422 Moreover, your license from a particular copyright holder is
423reinstated permanently if the copyright holder notifies you of the
424violation by some reasonable means, this is the first time you have
425received notice of violation of this License (for any work) from that
426copyright holder, and you cure the violation prior to 30 days after
427your receipt of the notice.
428
429 Termination of your rights under this section does not terminate the
430licenses of parties who have received copies or rights from you under
431this License. If your rights have been terminated and not permanently
432reinstated, you do not qualify to receive new licenses for the same
433material under section 10.
434
435 9. Acceptance Not Required for Having Copies.
436
437 You are not required to accept this License in order to receive or
438run a copy of the Program. Ancillary propagation of a covered work
439occurring solely as a consequence of using peer-to-peer transmission
440to receive a copy likewise does not require acceptance. However,
441nothing other than this License grants you permission to propagate or
442modify any covered work. These actions infringe copyright if you do
443not accept this License. Therefore, by modifying or propagating a
444covered work, you indicate your acceptance of this License to do so.
445
446 10. Automatic Licensing of Downstream Recipients.
447
448 Each time you convey a covered work, the recipient automatically
449receives a license from the original licensors, to run, modify and
450propagate that work, subject to this License. You are not responsible
451for enforcing compliance by third parties with this License.
452
453 An "entity transaction" is a transaction transferring control of an
454organization, or substantially all assets of one, or subdividing an
455organization, or merging organizations. If propagation of a covered
456work results from an entity transaction, each party to that
457transaction who receives a copy of the work also receives whatever
458licenses to the work the party's predecessor in interest had or could
459give under the previous paragraph, plus a right to possession of the
460Corresponding Source of the work from the predecessor in interest, if
461the predecessor has it or can get it with reasonable efforts.
462
463 You may not impose any further restrictions on the exercise of the
464rights granted or affirmed under this License. For example, you may
465not impose a license fee, royalty, or other charge for exercise of
466rights granted under this License, and you may not initiate litigation
467(including a cross-claim or counterclaim in a lawsuit) alleging that
468any patent claim is infringed by making, using, selling, offering for
469sale, or importing the Program or any portion of it.
470
471 11. Patents.
472
473 A "contributor" is a copyright holder who authorizes use under this
474License of the Program or a work on which the Program is based. The
475work thus licensed is called the contributor's "contributor version".
476
477 A contributor's "essential patent claims" are all patent claims
478owned or controlled by the contributor, whether already acquired or
479hereafter acquired, that would be infringed by some manner, permitted
480by this License, of making, using, or selling its contributor version,
481but do not include claims that would be infringed only as a
482consequence of further modification of the contributor version. For
483purposes of this definition, "control" includes the right to grant
484patent sublicenses in a manner consistent with the requirements of
485this License.
486
487 Each contributor grants you a non-exclusive, worldwide, royalty-free
488patent license under the contributor's essential patent claims, to
489make, use, sell, offer for sale, import and otherwise run, modify and
490propagate the contents of its contributor version.
491
492 In the following three paragraphs, a "patent license" is any express
493agreement or commitment, however denominated, not to enforce a patent
494(such as an express permission to practice a patent or covenant not to
495sue for patent infringement). To "grant" such a patent license to a
496party means to make such an agreement or commitment not to enforce a
497patent against the party.
498
499 If you convey a covered work, knowingly relying on a patent license,
500and the Corresponding Source of the work is not available for anyone
501to copy, free of charge and under the terms of this License, through a
502publicly available network server or other readily accessible means,
503then you must either (1) cause the Corresponding Source to be so
504available, or (2) arrange to deprive yourself of the benefit of the
505patent license for this particular work, or (3) arrange, in a manner
506consistent with the requirements of this License, to extend the patent
507license to downstream recipients. "Knowingly relying" means you have
508actual knowledge that, but for the patent license, your conveying the
509covered work in a country, or your recipient's use of the covered work
510in a country, would infringe one or more identifiable patents in that
511country that you have reason to believe are valid.
512
513 If, pursuant to or in connection with a single transaction or
514arrangement, you convey, or propagate by procuring conveyance of, a
515covered work, and grant a patent license to some of the parties
516receiving the covered work authorizing them to use, propagate, modify
517or convey a specific copy of the covered work, then the patent license
518you grant is automatically extended to all recipients of the covered
519work and works based on it.
520
521 A patent license is "discriminatory" if it does not include within
522the scope of its coverage, prohibits the exercise of, or is
523conditioned on the non-exercise of one or more of the rights that are
524specifically granted under this License. You may not convey a covered
525work if you are a party to an arrangement with a third party that is
526in the business of distributing software, under which you make payment
527to the third party based on the extent of your activity of conveying
528the work, and under which the third party grants, to any of the
529parties who would receive the covered work from you, a discriminatory
530patent license (a) in connection with copies of the covered work
531conveyed by you (or copies made from those copies), or (b) primarily
532for and in connection with specific products or compilations that
533contain the covered work, unless you entered into that arrangement,
534or that patent license was granted, prior to 28 March 2007.
535
536 Nothing in this License shall be construed as excluding or limiting
537any implied license or other defenses to infringement that may
538otherwise be available to you under applicable patent law.
539
540 12. No Surrender of Others' Freedom.
541
542 If conditions are imposed on you (whether by court order, agreement or
543otherwise) that contradict the conditions of this License, they do not
544excuse you from the conditions of this License. If you cannot convey a
545covered work so as to satisfy simultaneously your obligations under this
546License and any other pertinent obligations, then as a consequence you may
547not convey it at all. For example, if you agree to terms that obligate you
548to collect a royalty for further conveying from those to whom you convey
549the Program, the only way you could satisfy both those terms and this
550License would be to refrain entirely from conveying the Program.
551
552 13. Use with the GNU Affero General Public License.
553
554 Notwithstanding any other provision of this License, you have
555permission to link or combine any covered work with a work licensed
556under version 3 of the GNU Affero General Public License into a single
557combined work, and to convey the resulting work. The terms of this
558License will continue to apply to the part which is the covered work,
559but the special requirements of the GNU Affero General Public License,
560section 13, concerning interaction through a network will apply to the
561combination as such.
562
563 14. Revised Versions of this License.
564
565 The Free Software Foundation may publish revised and/or new versions of
566the GNU General Public License from time to time. Such new versions will
567be similar in spirit to the present version, but may differ in detail to
568address new problems or concerns.
569
570 Each version is given a distinguishing version number. If the
571Program specifies that a certain numbered version of the GNU General
572Public License "or any later version" applies to it, you have the
573option of following the terms and conditions either of that numbered
574version or of any later version published by the Free Software
575Foundation. If the Program does not specify a version number of the
576GNU General Public License, you may choose any version ever published
577by the Free Software Foundation.
578
579 If the Program specifies that a proxy can decide which future
580versions of the GNU General Public License can be used, that proxy's
581public statement of acceptance of a version permanently authorizes you
582to choose that version for the Program.
583
584 Later license versions may give you additional or different
585permissions. However, no additional obligations are imposed on any
586author or copyright holder as a result of your choosing to follow a
587later version.
588
589 15. Disclaimer of Warranty.
590
591 THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599
600 16. Limitation of Liability.
601
602 IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610SUCH DAMAGES.
611
612 17. Interpretation of Sections 15 and 16.
613
614 If the disclaimer of warranty and limitation of liability provided
615above cannot be given local legal effect according to their terms,
616reviewing courts shall apply local law that most closely approximates
617an absolute waiver of all civil liability in connection with the
618Program, unless a warranty or assumption of liability accompanies a
619copy of the Program in return for a fee.
620
621 END OF TERMS AND CONDITIONS
622
623 How to Apply These Terms to Your New Programs
624
625 If you develop a new program, and you want it to be of the greatest
626possible use to the public, the best way to achieve this is to make it
627free software which everyone can redistribute and change under these terms.
628
629 To do so, attach the following notices to the program. It is safest
630to attach them to the start of each source file to most effectively
631state the exclusion of warranty; and each file should have at least
632the "copyright" line and a pointer to where the full notice is found.
633
634 <one line to give the program's name and a brief idea of what it does.>
635 Copyright (C) <year> <name of author>
636
637 This program is free software: you can redistribute it and/or modify
638 it under the terms of the GNU General Public License as published by
639 the Free Software Foundation, either version 3 of the License, or
640 (at your option) any later version.
641
642 This program is distributed in the hope that it will be useful,
643 but WITHOUT ANY WARRANTY; without even the implied warranty of
644 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 GNU General Public License for more details.
646
647 You should have received a copy of the GNU General Public License
648 along with this program. If not, see <https://www.gnu.org/licenses/>.
649
650Also add information on how to contact you by electronic and paper mail.
651
652 If the program does terminal interaction, make it output a short
653notice like this when it starts in an interactive mode:
654
655 <program> Copyright (C) <year> <name of author>
656 This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 This is free software, and you are welcome to redistribute it
658 under certain conditions; type `show c' for details.
659
660The hypothetical commands `show w' and `show c' should show the appropriate
661parts of the General Public License. Of course, your program's commands
662might be different; for a GUI interface, you would use an "about box".
663
664 You should also get your employer (if you work as a programmer) or school,
665if any, to sign a "copyright disclaimer" for the program, if necessary.
666For more information on this, and how to apply and follow the GNU GPL, see
667<https://www.gnu.org/licenses/>.
668
669 The GNU General Public License does not permit incorporating your program
670into proprietary programs. If your program is a subroutine library, you
671may consider it more useful to permit linking proprietary applications with
672the library. If this is what you want to do, use the GNU Lesser General
673Public License instead of this License. But first, please read
674<https://www.gnu.org/licenses/why-not-lgpl.html>.
plugin.video.jacktook/README.md +146 -0
……@@ -0,0 +1,146 @@
1<div align="center">
2
3# 🚀 Jacktook
4
5*An advanced meta-scraper and streaming Kodi addon for enthusiasts.*
6
7[![Kodi Version](https://img.shields.io/badge/Kodi-20+-blue.svg)](https://kodi.tv)
8[![License](https://img.shields.io/badge/License-GPL%20v3-green.svg)](LICENSE)
9[![Support on Ko-fi](https://img.shields.io/badge/Support%20on%20Ko--fi-F16061?logo=ko-fi&logoColor=white)](https://ko-fi.com/sammax09)
10
11---
12
13[Features](#-features) • [Installation](#-installation) • [Configuration](#-configuration--usage) • [External Engines](#-external-engines-optional) • [Screenshots](#-screenshots)
14
15</div>
16
17## 🌟 Overview
18
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
21---
22
23## ✨ Features
24
25### 🔍 Search & Scraping
26- **Aggregated Sources**: Stremio Addons, Jacktook Burst, Jackgram, Jackett, Prowlarr, Usenet, Debrid Services.
27- **Debrid Support**: Native support for RealDebrid, AllDebrid, Premiumize, and Torbox.
28- **Torrent Scraping**: Integration with Jackett and Prowlarr for torrent sources.
29- **Usenet Scraping**: EasyNews integration for Usenet sources.
30- **Telegram Scraping**: Jackgram integration for streaming from Telegram channels.
31- **Rich Metadata**: Integration with TMDB, MDBList, Trakt, Fanart.tv, and Stremio catalogs.
32- **Smart Filtering**: Advanced results filtering by quality, size, and source.
33
34### 🧩 Stremio Ecosystem
35- **Cloud Configuration**: Import and sync your entire configuration, including installed addons and Debrid tokens, directly from your Stremio account.
36- **Catalog Navigation**: Browse and search Stremio's extensive metadata catalogs.
37- **Stream Scraper**: Scraping and filtering of Stremio streams by resolution, file size, and quality.
38- **Flexible Management**: Add custom addons via URL, or toggle existing ones on/off on the fly.
39- **Web Server Manager**: A local web interface to manage your Stremio sources from your phone or PC.
40
41### 🎥 Playback & Engines
42- **Torrent Engines**: Integrated support for **Jacktorr**, **Torrest**, and **Elementum** for seamless P2P streaming.
43- **Debrid Streaming**: Stream directly from your Debrid accounts.
44- **Usenet Support**: Stream from Usenet sources via EasyNews.
45- **Telegram Streaming**: Stream directly from Telegram via Jackgram.
46
47### 🛠️ Utilities
48- **Automatic Subtitles**: Download subtitles directly from Stremio OpenSubtitles addon.
49- **DeepL Translation**: Real-time translation of subtitles and metadata using the DeepL API.
50- **Subtitle Uploader**: Upload your own subtitles from local device or local web server.
51- **TMDB Helper Integration**: Full compatibility with TMDB Helper via custom players.
52- **Backup & Restore**: Easily backup and restore your entire configuration, including Stremio addons and Debrid tokens.
53- **IntroDB**: Integration with IntroDB for automatic movie intros.
54- **WebDav**: Integration with WebDav for remote file management and streaming.
55- **Youtube Trailer**: Instant access to trailers from YouTube.
56---
57
58## 📥 Installation
59
60The recommended installation method is via the official repository to ensure you receive automatic updates.
61
621. **Download the Repository**: [Sam-Max Repository](https://github.com/Sam-Max/repository.jacktook)
632. **Install via Kodi**: `Add-ons > Install from zip file`
643. **Install Jacktook**: `Add-ons > Install from repository > Sam-Max Repository > Video add-ons > Jacktook`
65
66---
67
68## ⚙️ Configuration & Usage
69
70### 🚀 Stremio Integration
71Jacktook can import configurations and Debrid services directly from your Stremio account.
72- Enable Stremio in settings.
73- Login to your Stremio account to sync your installed addons and Debrid tokens.
74- Or manually add addons via URL or search for community addons.
75
76### 🔍 Jackett & Prowlarr
77To optimize search performance:
78- Select only 3–4 high-quality trackers.
79- Avoid trackers behind Cloudflare unless you use **FlareSolverr**.
80- For **Prowlarr**, you can specify Indexer IDs (space-separated) in the settings to target specific sources.
81
82### 🌐 DeepL Integration
83Translate subtitles and metadata instantly.
841. Get a free API key at [DeepL API](https://www.deepl.com/pro-api).
852. Enter the key in Jacktook settings.
863. Enjoy up to 500,000 characters of free translation per month.
87
88### 🧩 TMDB Helper
89For the best experience with TMDB Helper, use the following configuration file:
90- [jacktook.select.json](https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/master/jacktook.select.json)
91
92
93### 🏗️ External Engines
94
95If you are not using Debrid services, you can use these engines for P2P streaming:
96
97| Engine | Description | Platform |
98| :--- | :--- | :--- |
99| **Jacktorr** | Advanced TorrServer wrapper | Android / Docker |
100| **Torrest** | Lightweight C++ BitTorrent engine | Linux / Docker |
101| **Elementum** | Proven P2P streaming solution | All Kodi platforms |
102
103---
104
105## 📸 Screenshots
106
107| Home Screen | TV Shows |
108| :---: | :---: |
109| ![Home](https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/master/resources/screenshots/home.png) | ![TV](https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/master/resources/screenshots/tv.png) |
110| **Settings** | **Extras** |
111| ![Settings](https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/master/resources/screenshots/settings.png) | ![Extras](https://raw.githubusercontent.com/Sam-Max/plugin.video.jacktook/master/resources/screenshots/extras.png) |
112
113---
114
115## ☕ Support
116
117If 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[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/sammax09)
120
121---
122
123## ⚖️ Disclaimer
124
125This 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---
128
129## 🤝 Collaborators
130
131A huge thanks to everyone who has contributed to this project:
132
133<p align="left">
134 <a href="https://github.com/coffelius"><img src="https://github.com/coffelius.png?size=50" width="50" height="50" alt="Gabriel Ortega" title="Gabriel Ortega"/></a>
135 <a href="https://github.com/addon-rajada"><img src="https://github.com/addon-rajada.png?size=50" width="50" height="50" alt="addon-rajada" title="addon-rajada"/></a>
136 <a href="https://github.com/cleanhands"><img src="https://github.com/cleanhands.png?size=50" width="50" height="50" alt="cleanhands" title="cleanhands"/></a>
137 <a href="https://github.com/icarok99"><img src="https://github.com/icarok99.png?size=50" width="50" height="50" alt="Ícaro Maicon" title="Ícaro Maicon"/></a>
138 <a href="https://github.com/asylumexp"><img src="https://github.com/asylumexp.png?size=50" width="50" height="50" alt="Sam Heinz" title="Sam Heinz"/></a>
139 <a href="https://github.com/saucepanlid"><img src="https://github.com/saucepanlid.png?size=50" width="50" height="50" alt="saucepanlid" title="saucepanlid"/></a>
140</p>
141
142---
143
144<div align="center">
145Made with ❤️ for the Kodi Community
146</div>
plugin.video.jacktook/addon.xml +26 -0
……@@ -0,0 +1,26 @@
1<?xml version="1.0" encoding="utf-8"?>
2 <addon id="plugin.video.jacktook" name="Jacktook" version="1.10.1" provider-name="Sam-Max">
3 <requires>
4 <import addon="xbmc.python" version="3.00.0"/>
5 <import addon="script.module.requests" version="2.25.1"/>
6 <import addon="script.module.routing" version="0.2.3"/>
7 </requires>
8 <extension point="xbmc.python.pluginsource" library="jacktook.py">
9 <provides>video</provides>
10 </extension>
11 <extension point="xbmc.service" library="service.py" />
12 <extension point="xbmc.python.module" library="lib"/>
13 <extension point="xbmc.addon.metadata">
14 <reuselanguageinvoker>true</reuselanguageinvoker>
15 <summary lang="en">jacktook</summary>
16 <description lang="en">Kodi addon for torrent streaming</description>
17 <license>GPL-2.0-only</license>
18 <source>https://github.com/Sam-Max/plugin.video.jacktook</source>
19 <assets>
20 <icon>icon.png</icon>
21 <fanart>fanart.png</fanart>
22 <screenshot>resources/screenshots/home.png</screenshot>
23 <screenshot>resources/screenshots/tv.png</screenshot>
24 </assets>
25 </extension>
26</addon>
plugin.video.jacktook/fanart.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/icon.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/jacktook.py +7 -0
……@@ -0,0 +1,7 @@
1#!/usr/bin/env python3
2
3from lib.router import addon_router
4from lib.utils.kodi.utils import load_saved_view_properties
5
6load_saved_view_properties()
7addon_router()
plugin.video.jacktook/jacktook.py.bak +7 -0
……@@ -0,0 +1,7 @@
1#!/usr/bin/env python3
2
3from lib.router import addon_router
4from lib.utils.kodi.utils import load_saved_view_properties
5
6load_saved_view_properties()
7addon_router()
plugin.video.jacktook/jacktook.select.json +10 -0
……@@ -0,0 +1,10 @@
1{
2 "name": "Jacktook",
3 "plugin": "plugin.video.jacktook",
4 "priority": 200,
5 "is_resolvable": "true",
6 "play_movie": "plugin://plugin.video.jacktook/?action=search&mode=movies&rescrape=True&query={title}&ids=%7B%22tmdb_id%22%3A+%22{tmdb}%22%2C+%22tvdb_id%22%3A+%22{tvdb}%22%2C+%22imdb_id%22%3A+%22{imdb}%22%7D",
7 "play_episode": "plugin://plugin.video.jacktook/?action=search&mode=tv&rescrape=True&query={showname}&ids=%7B%22tmdb_id%22%3A+%22{tmdb}%22%2C+%22tvdb_id%22%3A+%22{tvdb}%22%2C+%22imdb_id%22%3A+%22{imdb}%22%7D&tv_data=%7B%22name%22%3A+%22{title}%22%2C+%22episode%22%3A+%22{episode}%22%2C+%22season%22%3A+%22{season}%22%7D"
8}
9
10
plugin.video.jacktook/jacktook.select.zip +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/lib/__init__.py +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/lib/actions.py +93 -0
……@@ -0,0 +1,93 @@
1import json
2from lib.domain.torrent import TorrentStream
3from lib.gui.resolver_window import ResolverWindow
4from lib.utils.kodi.utils import ADDON_PATH
5from lib.downloader import handle_download_file
6
7def resolve_for_pack_selection(params):
8 data = json.loads(params["data"])
9 info_hash = data.get("infoHash")
10
11 source = TorrentStream(
12 guid="",
13 url=data.get("url"),
14 infoHash=info_hash,
15 title=data.get("title", ""),
16 size=data.get("size", 0),
17 type=data.get("type", "direct"),
18 debridType=data.get("debridType"),
19 provider=data.get("provider"),
20 indexer=data.get("indexer"),
21 subindexer=data.get("subindexer"),
22 addonKey=data.get("addonKey", ""),
23 addonName=data.get("addonName", ""),
24 addonInstanceLabel=data.get("addonInstanceLabel", ""),
25 quality=data.get("quality"),
26 isPack=data.get("isPack", False),
27 languages=data.get("languages", []),
28 fullLanguages=data.get("fullLanguages", []),
29 publishDate=data.get("publishDate", ""),
30 seeders=data.get("seeders", 0),
31 )
32
33 item_information = {
34 "title": data.get("title"),
35 "mode": data.get("mode"),
36 "ids": data.get("ids"),
37 "tv_data": data.get("tv_data"),
38 }
39
40 resolver_window = ResolverWindow(
41 "resolver.xml",
42 ADDON_PATH,
43 source=source,
44 item_information=item_information,
45 )
46 resolver_window.doModal(pack_select=True)
47 del resolver_window
48
49
50def resolve_for_subtitles(params):
51 data = json.loads(params["data"])
52 info_hash = data.get("infoHash")
53
54 source = TorrentStream(
55 url=data.get("url"),
56 guid=data.get("guid"),
57 infoHash=info_hash,
58 title=data.get("title"),
59 size=data.get("size"),
60 type=data.get("type"),
61 debridType=data.get("debridType"),
62 provider=data.get("provider"),
63 indexer=data.get("indexer"),
64 subindexer=data.get("subindexer"),
65 addonKey=data.get("addonKey", ""),
66 addonName=data.get("addonName", ""),
67 addonInstanceLabel=data.get("addonInstanceLabel", ""),
68 quality=data.get("quality"),
69 isPack=data.get("isPack"),
70 languages=data.get("languages"),
71 fullLanguages=data.get("fullLanguages"),
72 publishDate=data.get("publishDate"),
73 seeders=data.get("seeders"),
74 )
75
76 item_information = {
77 "title": data.get("title"),
78 "mode": data.get("mode"),
79 "ids": data.get("ids"),
80 "tv_data": data.get("tv_data"),
81 }
82
83 resolver_window = ResolverWindow(
84 "resolver.xml",
85 ADDON_PATH,
86 source=source,
87 item_information=item_information,
88 is_subtitle_download=True,
89 )
90 resolver_window.doModal()
91 del resolver_window
92
93
plugin.video.jacktook/lib/api/__init__.py +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/lib/api/debrid/alldebrid.py +189 -0
……@@ -0,0 +1,189 @@
1import requests
2from lib.api.debrid.base import DebridClient, ProviderException
3from lib.jacktook.utils import kodilog
4from lib.utils.kodi.utils import dialog_ok, set_setting, translation
5from lib.services.debrid.auth import run_alldebrid_auth
6
7
8class AllDebrid(DebridClient):
9 BASE_URL = "https://api.alldebrid.com/v4.1"
10 USER_AGENT = "jacktook"
11
12 def __init__(self, token: str):
13 super().__init__(token)
14
15 def initialize_headers(self):
16 self.headers = {"Authorization": f"Bearer {self.token}"}
17
18 def disable_access_token(self):
19 pass
20
21 def _handle_service_specific_errors(self, error_data: dict, status_code: int):
22 pass
23
24 def _make_request(
25 self,
26 method: str,
27 url: str,
28 data=None,
29 params=None,
30 json=None,
31 is_return_none: bool = False,
32 is_expected_to_fail: bool = False,
33 version_number: str = "v4.1",
34 ):
35 params = params or {}
36 if version_number == "v4":
37 self.BASE_URL = "https://api.alldebrid.com/v4.0"
38 url = f"{self.BASE_URL}{url}"
39 return super()._make_request(
40 method, url, data, json, params, is_return_none, is_expected_to_fail
41 )
42
43 def auth(self):
44 run_alldebrid_auth(self)
45
46 def poll_auth(self, check_id: str, pin: str):
47 response = self._make_request(
48 "POST",
49 "/pin/check",
50 data={"check": check_id, "pin": pin},
51 is_expected_to_fail=True,
52 )
53 kodilog(f"AllDebrid poll_auth response: {response}")
54 return response.get("data", {})
55
56 def get_ping(self):
57 return self._make_request("GET", "/pin/get")
58
59 def remove_auth(self):
60 self.token = ""
61 set_setting("alldebrid_token", "")
62 set_setting("alldebrid_authorized", "false")
63 set_setting("alldebrid_user", "")
64 dialog_ok(translation(90544), translation(90561))
65
66 @staticmethod
67 def _validate_error_response(response_data):
68 if response_data.get("status") != "success":
69 error_code = response_data.get("error", {}).get("code")
70 if error_code == "AUTH_BAD_APIKEY":
71 raise ProviderException("Invalid AllDebrid API key")
72 elif error_code == "NO_SERVER":
73 raise ProviderException(
74 f"Failed to add magnet link to AllDebrid {response_data}"
75 )
76 elif error_code == "AUTH_BLOCKED":
77 raise ProviderException("API got blocked on AllDebrid")
78 elif error_code == "MAGNET_MUST_BE_PREMIUM":
79 raise ProviderException("Torrent must be premium on AllDebrid")
80 elif error_code in {"MAGNET_TOO_MANY_ACTIVE", "MAGNET_TOO_MANY"}:
81 raise ProviderException("Too many active torrents on AllDebrid")
82 elif error_code == "MAGNET_INVALID_ID":
83 raise ProviderException("Invalid magnet link for AllDebrid")
84 else:
85 raise ProviderException(
86 f"Failed to add magnet link to AllDebrid {response_data}"
87 )
88
89 def add_magnet(self, magnet_link):
90 response = self._make_request(
91 "POST", "/magnet/upload", data={"magnets[]": magnet_link}
92 )
93 self._validate_error_response(response)
94 return response
95
96 def add_torrent_file(self, torrent_data, torrent_name="torrent.torrent"):
97 response = self.session.request(
98 method="POST",
99 url=f"{self.BASE_URL}/magnet/upload/file",
100 files={
101 "files[]": (
102 torrent_name or "torrent.torrent",
103 torrent_data,
104 "application/x-bittorrent",
105 )
106 },
107 headers=self.headers,
108 timeout=self.timeout,
109 )
110 self._handle_errors(response, is_expected_to_fail=False)
111 payload = self._parse_response(response, is_return_none=False)
112 self._validate_error_response(payload)
113 return payload
114
115 def get_user_torrent_list(self):
116 return self._make_request("GET", "/magnet/status")
117
118 def get_torrent_info(self, id):
119 return self._make_request("POST", "/magnet/status", data={"id": id})
120
121 def get_files_and_links(self, id):
122 response = self._make_request(
123 "POST",
124 "/magnet/files",
125 data={"id[]": id},
126 version_number="v4",
127 )
128 self._validate_error_response(response)
129 return response.get("data", {}).get("magnets", {})
130
131 def get_torrent_instant_availability(self, magnet_links):
132 response = self._make_request(
133 "POST", "/magnet/instant", data={"magnets[]": magnet_links}
134 )
135 return response.get("data", {}).get("magnets", [])
136
137 def get_available_torrent(self, info_hash):
138 available_torrents = self.get_user_torrent_list()
139 self._validate_error_response(available_torrents)
140 if not available_torrents.get("data"):
141 return None
142 for torrent in available_torrents["data"]["magnets"]:
143 if torrent["hash"] == info_hash:
144 return torrent
145 return None
146
147 def create_download_link(self, link: str):
148 return self._make_request(
149 "POST",
150 "/link/unlock",
151 data={"link": link},
152 is_expected_to_fail=True,
153 )
154
155 def get_redirected_link(self, link: str) -> str:
156 response = self._make_request(
157 "GET",
158 "/link/redirector",
159 params={"link": link},
160 is_expected_to_fail=True,
161 )
162 kodilog(f"AllDebrid get_redirected_link response: {response}")
163 return response.get("data", {}).get("link")
164
165 def delete_torrent(self, magnet_id):
166 return self._make_request("GET", "/magnet/delete", params={"id": magnet_id})
167
168 def get_user_info(self):
169 response = self._make_request("GET", "/user")
170 return response.get("data", {})
171
172 def days_remaining(self):
173 try:
174 user = self.get_user_info()
175 if not user or "subscription" not in user:
176 return None
177
178 premium_until = user["subscription"].get("premium_until")
179 if not premium_until:
180 return None
181
182 import datetime
183
184 expires = datetime.datetime.fromtimestamp(premium_until)
185 days = (expires - datetime.datetime.now()).days
186 return days
187 except Exception as e:
188 kodilog(f"Error calculating AllDebrid days remaining: {e}")
189 return None
plugin.video.jacktook/lib/api/debrid/base.py +183 -0
……@@ -0,0 +1,183 @@
1import requests
2import traceback
3from abc import ABC, abstractmethod
4from json.decoder import JSONDecodeError
5
6from lib.utils.kodi.utils import kodilog, notification
7from typing import Optional, Dict, Any
8
9
10class DebridClient(ABC):
11 """
12 Abstract base class for Debrid service clients.
13 Handles HTTP requests, error handling, and session management.
14 """
15
16 def __init__(
17 self, token: str, timeout: int = 15, session: Optional[requests.Session] = None
18 ):
19 """
20 Args:
21 token (str): API token for authentication.
22 timeout (int): Request timeout in seconds.
23 session (requests.Session, optional): Custom session for requests.
24 """
25 self.token = token
26 self.timeout = timeout
27 self.session = session or requests.Session()
28 self.headers = {}
29 self.initialize_headers()
30
31 def _make_request(
32 self,
33 method: str,
34 url: str,
35 data: Optional[Dict[str, Any]] = None,
36 params: Optional[Dict[str, Any]] = None,
37 json: Optional[Dict[str, Any]] = None,
38 is_return_none: bool = False,
39 is_expected_to_fail: bool = False,
40 ) -> Dict[str, Any]:
41 """
42 Make an HTTP request and handle errors.
43 """
44 response = self._perform_request(method, url, data, params, json)
45 self._handle_errors(response, is_expected_to_fail)
46 return self._parse_response(response, is_return_none)
47
48 def _perform_request(
49 self,
50 method: str,
51 url: str,
52 data: Optional[Dict[str, Any]] = None,
53 params: Optional[Dict[str, Any]] = None,
54 json: Optional[Dict[str, Any]] = None,
55 ) -> requests.Response:
56 """
57 Perform the actual HTTP request.
58 """
59 try:
60 return self.session.request(
61 method=method,
62 url=url,
63 params=params,
64 data=data,
65 json=json,
66 headers=self.headers,
67 timeout=self.timeout,
68 )
69 except requests.exceptions.Timeout as e:
70 kodilog(f"Timeout: {e}")
71 raise ProviderException("Request timed out.")
72 except requests.exceptions.ConnectionError as e:
73 kodilog(f"ConnectionError: {e}")
74 raise ProviderException(f"ConnectionError: {e}")
75 except requests.exceptions.RequestException as e:
76 kodilog(f"RequestException: {e}")
77 raise ProviderException(f"Request failed: {str(e)}")
78 except Exception as e:
79 kodilog(f"Unexpected error: {e}")
80 raise ProviderException(f"Unexpected error: {str(e)}")
81
82 def _handle_errors(
83 self, response: requests.Response, is_expected_to_fail: bool
84 ) -> None:
85 """
86 Handle HTTP errors and raise ProviderException as needed.
87 """
88 try:
89 response.raise_for_status()
90 except requests.RequestException as error:
91 status_code = getattr(error.response, "status_code", 0)
92 url = getattr(error.response, "url", "Unknown URL")
93
94 if is_expected_to_fail:
95 return
96
97 error_content = None
98 content_type = response.headers.get("Content-Type", "")
99 if content_type.startswith("application/json"):
100 try:
101 error_content = response.json()
102 except ValueError:
103 error_content = response.text
104 else:
105 error_content = response.text
106
107 # Call service-specific error hook if JSON
108 if isinstance(error_content, dict):
109 self._handle_service_specific_errors(error_content, status_code)
110
111 # Specific cases
112 if status_code == 401:
113 raise ProviderException("Invalid token", status_code, error_content)
114 elif status_code == 403:
115 raise ProviderException("Forbidden", status_code, error_content)
116 elif status_code == 500:
117 raise ProviderException(
118 "Internal server error", status_code, error_content
119 )
120 else:
121 kodilog(
122 f"Error: {''.join(traceback.format_exception(type(error), error, error.__traceback__))}"
123 )
124 raise ProviderException(
125 f"API Error: {status_code} for {url}\nDetails: {error_content}",
126 status_code,
127 error_content,
128 )
129
130 @abstractmethod
131 def initialize_headers(self) -> None:
132 """
133 Initialize headers for requests. Must be implemented by subclasses.
134 """
135 raise NotImplementedError
136
137 @abstractmethod
138 def disable_access_token(self):
139 """
140 Disable or revoke the access token. Must be implemented by subclasses.
141 """
142 raise NotImplementedError
143
144 @staticmethod
145 def _parse_response(
146 response: requests.Response, is_return_none: bool
147 ) -> Dict[str, Any]:
148 """
149 Parse the HTTP response as JSON.
150 """
151 if is_return_none:
152 return {}
153 try:
154 return response.json()
155 except JSONDecodeError as error:
156 raise ProviderException(
157 f"Failed to parse response error: {error}. \nresponse: {response.text}"
158 )
159
160 @abstractmethod
161 def _handle_service_specific_errors(
162 self, error_data: dict, status_code: int
163 ) -> None:
164 """
165 Service specific errors on api requests. Must be implemented by subclasses.
166 """
167 raise NotImplementedError
168
169
170class ProviderException(Exception):
171 def __init__(
172 self, message: str, status_code: Optional[int] = None, error_content: Any = None
173 ):
174 self.message = message
175 self.status_code = status_code
176 self.error_content = error_content
177 details = f"{self.message}"
178 if self.status_code is not None:
179 details += f" (Status code: {self.status_code})"
180 if self.error_content is not None:
181 details += f"\nError content: {self.error_content}"
182 super().__init__(details)
183 notification(details)
plugin.video.jacktook/lib/api/debrid/debrider.py +124 -0
……@@ -0,0 +1,124 @@
1from lib.api.debrid.base import DebridClient
2from lib.jacktook.utils import kodilog
3from lib.utils.kodi.utils import dialog_ok, set_setting, translation
4from lib.services.debrid.auth import run_debrider_auth
5
6
7class Debrider(DebridClient):
8 BASE_URL = "https://debrider.app/api/v1"
9 AUTH_URL = "https://debrider.app/api/app"
10
11 def __init__(self, token, user_ip=None):
12 self.user_ip = user_ip
13 super().__init__(token)
14
15 def auth(self):
16 kodilog("Debrider: Requesting device code for authentication.")
17 run_debrider_auth(self)
18
19 def remove_auth(self):
20 set_setting("debrider_token", "")
21 set_setting("debrider_authorized", "false")
22 dialog_ok(translation(90544), translation(90561))
23
24 def initialize_headers(self):
25 self.headers = {
26 "Authorization": f"Bearer {self.token}",
27 "User-Agent": "Jacktook/1.0",
28 "Accept": "application/json",
29 }
30 if self.user_ip:
31 self.headers["X-Forwarded-For"] = self.user_ip
32
33 def disable_access_token(self):
34 pass
35
36 def _handle_service_specific_errors(self, error_data: dict, status_code: int):
37 pass
38
39 def _make_request(
40 self,
41 method: str,
42 url: str,
43 data=None,
44 params=None,
45 json=None,
46 is_return_none: bool = False,
47 is_expected_to_fail: bool = False,
48 ):
49 params = params or {}
50 return super()._make_request(
51 method,
52 url,
53 data,
54 params=params,
55 json=json,
56 is_return_none=is_return_none,
57 is_expected_to_fail=is_expected_to_fail,
58 )
59
60 def get_device_auth_status(self, code):
61 "Check the status of device authentication using a code."
62 return self._make_request(
63 "GET",
64 f"{self.AUTH_URL}/device/auth",
65 params={"code": code},
66 is_expected_to_fail=True,
67 )
68
69 def get_device_code(self):
70 return self._make_request(
71 "GET",
72 f"{self.AUTH_URL}/device/code",
73 )
74
75 def get_torrent_instant_availability(self, urls):
76 return self._make_request(
77 "POST",
78 f"{self.BASE_URL}/link/lookup",
79 json={"data": urls},
80 )
81
82 def create_download_link(self, magnet):
83 return self._make_request(
84 "POST",
85 f"{self.BASE_URL}/link/generate",
86 json={"data": magnet},
87 )
88
89 def add_torrent_file(self, magnet):
90 return self._make_request(
91 "POST",
92 f"{self.BASE_URL}/tasks",
93 json={"type": "magnet", "data": magnet},
94 )
95
96 def get_user_info(self):
97 return self._make_request("GET", f"{self.BASE_URL}/account")
98
99 def days_remaining(self):
100 try:
101 user = self.get_user_info()
102 if not user or "subscription" not in user:
103 return None
104
105 premium_until = user["subscription"].get("premium_until")
106 if not premium_until:
107 return None
108
109 import datetime
110
111 try:
112 expires = datetime.datetime.strptime(
113 premium_until, "%Y-%m-%dT%H:%M:%S.%fZ"
114 )
115 except ValueError:
116 expires = datetime.datetime.strptime(
117 premium_until, "%Y-%m-%dT%H:%M:%SZ"
118 )
119
120 days = (expires - datetime.datetime.utcnow()).days
121 return days
122 except Exception as e:
123 # kodilog(f"Error calculating Debrider days remaining: {e}")
124 return None
plugin.video.jacktook/lib/api/debrid/easydebrid.py +63 -0
……@@ -0,0 +1,63 @@
1from lib.api.debrid.base import DebridClient, ProviderException
2
3
4class EasyDebrid(DebridClient):
5 BASE_URL = "https://easydebrid.com/api/v1"
6
7 def __init__(self, token, user_ip):
8 self.user_ip = user_ip
9 super().__init__(token)
10
11 def initialize_headers(self):
12 self.headers = {"Authorization": f"Bearer {self.token}"}
13 if self.user_ip:
14 self.headers["X-Forwarded-For"] = self.user_ip
15
16 def disable_access_token(self):
17 pass
18
19 def _handle_service_specific_errors(self, error_data: dict, status_code: int):
20 error_code = error_data.get("error")
21 if error_code == "Unsupported link for direct download.":
22 raise ProviderException(
23 "Unsupported link for direct download.",
24 )
25
26 def _make_request(
27 self,
28 method: str,
29 url: str,
30 data=None,
31 params=None,
32 json=None,
33 is_return_none: bool = False,
34 is_expected_to_fail: bool = False,
35 ):
36 params = params or {}
37 url = self.BASE_URL + url
38 return super()._make_request(
39 method,
40 url,
41 data,
42 params=params,
43 json=json,
44 is_return_none=is_return_none,
45 is_expected_to_fail=is_expected_to_fail,
46 )
47
48 def get_torrent_instant_availability(self, urls):
49 return self._make_request(
50 "POST",
51 "/link/lookup",
52 json={"urls": urls},
53 )
54
55 def create_download_link(self, magnet):
56 return self._make_request(
57 "POST",
58 "/link/generate",
59 json={"url": magnet},
60 )
61
62 def get_user_info(self):
63 return self._make_request("GET", "/user/details")
plugin.video.jacktook/lib/api/debrid/premiumize.py +185 -0
……@@ -0,0 +1,185 @@
1from lib.api.debrid.base import DebridClient, ProviderException
2from lib.services.debrid.auth import run_premiumize_auth
3from lib.utils.kodi.utils import dialog_ok, translation
4
5
6class Premiumize(DebridClient):
7 BASE_URL = "https://www.premiumize.me/api"
8 OAUTH_TOKEN_URL = "https://www.premiumize.me/token"
9 OAUTH_URL = "https://www.premiumize.me/authorize"
10 CLIENT_ID = "215940813"
11
12 def initialize_headers(self):
13 self.headers = {
14 "Authorization": f"Bearer {self.token}",
15 "User-Agent": "Jacktook/1.0",
16 "Accept": "application/json",
17 }
18
19 def disable_access_token(self):
20 pass
21
22 def remove_auth(self):
23 from lib.utils.kodi.utils import set_setting
24 self.token = ""
25 set_setting("premiumize_token", "")
26 set_setting("premiumize_authorized", "false")
27 set_setting("premiumize_user", "")
28 dialog_ok(translation(90544), translation(90561))
29
30 def _handle_service_specific_errors(self, error_data, status_code: int):
31 pass
32
33 def _make_request(
34 self,
35 method,
36 url,
37 data=None,
38 params=None,
39 json=None,
40 is_return_none=False,
41 is_expected_to_fail=False,
42 ):
43 params = params or {}
44 return super()._make_request(
45 method,
46 url,
47 data=data,
48 params=params,
49 json=json,
50 is_return_none=is_return_none,
51 is_expected_to_fail=is_expected_to_fail,
52 )
53
54 def get_device_code(self):
55 return self._make_request(
56 "POST",
57 self.OAUTH_TOKEN_URL,
58 data={"response_type": "device_code", "client_id": self.CLIENT_ID},
59 )
60
61 def authorize(self, device_code):
62 return self._make_request(
63 "POST",
64 self.OAUTH_TOKEN_URL,
65 is_expected_to_fail=True,
66 data={
67 "grant_type": "device_code",
68 "client_id": self.CLIENT_ID,
69 "code": device_code,
70 },
71 )
72
73 def auth(self):
74 run_premiumize_auth(self)
75
76 def download(self):
77 pass
78
79 def create_or_get_folder_id(self, info_hash, folder_id: str):
80 folder_data = self.get_folder_list(folder_id)
81 for folder in folder_data["content"]:
82 if folder["name"] == info_hash:
83 return folder["id"]
84
85 folder_data = self.create_folder(info_hash)
86 if folder_data.get("status") != "success":
87 return
88 return folder_data.get("id")
89
90 def add_magnet_link(self, magnet_link: str, folder_id: str):
91 return self._make_request(
92 "POST",
93 f"{self.BASE_URL}/transfer/create",
94 data={"src": magnet_link, "folder_id": folder_id},
95 )
96
97 def create_download_link(self, magnet_link: str):
98 return self._make_request(
99 "POST",
100 f"{self.BASE_URL}/transfer/directdl",
101 data={"src": magnet_link},
102 )
103
104 def create_folder(self, name, parent_id=None):
105 return self._make_request(
106 "POST",
107 f"{self.BASE_URL}/folder/create",
108 data={"name": name, "parent_id": parent_id},
109 )
110
111 def get_transfer_list(self):
112 return self._make_request("GET", f"{self.BASE_URL}/transfer/list")
113
114 def get_torrent_info(self, torrent_id):
115 transfer_list = self.get_transfer_list()
116 torrent_info = next(
117 (
118 torrent
119 for torrent in transfer_list["transfers"]
120 if torrent["id"] == torrent_id
121 ),
122 None,
123 )
124 return torrent_info
125
126 def get_folder_list(self, folder_id: str):
127 return self._make_request(
128 "GET",
129 f"{self.BASE_URL}/folder/list",
130 params={"id": folder_id} if folder_id else None,
131 )
132
133 def delete_torrent(self, torrent_id):
134 return self._make_request(
135 "POST", f"{self.BASE_URL}/transfer/delete", data={"id": torrent_id}
136 )
137
138 def get_torrent_instant_availability(self, torrent_hashes):
139 results = self._make_request(
140 "GET", f"{self.BASE_URL}/cache/check", params={"items[]": torrent_hashes}
141 )
142 if results.get("status") != "success":
143 raise ProviderException(
144 "Failed to get instant availability from Premiumize",
145 )
146 return results
147
148 def get_available_torrent(self, info_hash: str, torrent_name):
149 torrent_list_response = self.get_transfer_list()
150 if torrent_list_response.get("status") != "success":
151 if torrent_list_response.get("message") == "Not logged in.":
152 raise ProviderException("Premiumize is not logged in.")
153 raise ProviderException("Failed to get torrent info from Premiumize")
154
155 available_torrents = torrent_list_response["transfers"]
156 for torrent in available_torrents:
157 src = torrent.get("src")
158 if (
159 (src and info_hash in src)
160 or info_hash == torrent["name"]
161 or torrent_name == torrent["name"]
162 ):
163 return torrent
164
165 def get_account_info(self):
166 return self._make_request("GET", f"{self.BASE_URL}/account/info")
167
168 def days_remaining(self):
169 try:
170 account = self.get_account_info()
171 if not account or account.get("status") != "success":
172 return None
173
174 premium_until = account.get("premium_until")
175 if not premium_until:
176 return None
177
178 import datetime
179
180 expires = datetime.datetime.fromtimestamp(premium_until)
181 days = (expires - datetime.datetime.now()).days
182 return days
183 except Exception as e:
184 # kodilog(f"Error calculating Premiumize days remaining: {e}")
185 return None
plugin.video.jacktook/lib/api/debrid/realdebrid.py +329 -0
……@@ -0,0 +1,329 @@
1from lib.api.debrid.base import DebridClient, ProviderException
2from base64 import b64encode, b64decode
3from lib.utils.kodi.utils import (
4 dialog_ok,
5 kodilog,
6 set_setting,
7 translation,
8)
9from lib.services.debrid.auth import run_realdebrid_auth
10from lib.services.debrid.download import run_realdebrid_download
11
12
13class RealDebrid(DebridClient):
14 BASE_URL = "https://api.real-debrid.com/rest/1.0"
15 OAUTH_URL = "https://api.real-debrid.com/oauth/v2"
16 CLIENT_ID = "X245A4XAIBGVM"
17
18 def initialize_headers(self):
19 self.headers = {"User-Agent": "Jacktook/1.0"}
20 if not self.token or not isinstance(self.token, str):
21 kodilog("Invalid token")
22 return
23 token_data = self.decode_token_str(self.token)
24 if "private_token" in token_data:
25 self.headers["Authorization"] = f"Bearer {token_data['private_token']}"
26 else:
27 # Exchange client_id/secret/code for an access token
28 access_token_data = self.get_token(
29 token_data["client_id"],
30 token_data["client_secret"],
31 token_data["code"],
32 )
33 self.headers["Authorization"] = (
34 f"Bearer {access_token_data['access_token']}"
35 )
36
37 def _handle_service_specific_errors(self, error_data: dict, status_code: int):
38 error_code = error_data.get("error_code")
39 messages = {
40 -1: "Internal error",
41 1: "Missing parameter",
42 2: "Bad parameter value",
43 3: "Unknown method",
44 4: "Method not allowed",
45 5: "Slow down",
46 6: "Resource unreachable",
47 7: "Resource not found",
48 8: "Bad token",
49 9: "Permission denied",
50 10: "Two-Factor authentication needed",
51 11: "Two-Factor authentication pending",
52 12: "Invalid login",
53 13: "Invalid password",
54 14: "Account locked",
55 15: "Account not activated",
56 16: "Unsupported hoster",
57 17: "Hoster in maintenance",
58 18: "Hoster limit reached",
59 19: "Hoster temporarily unavailable",
60 20: "Hoster not available for free users",
61 21: "Too many active downloads",
62 22: "IP address not allowed",
63 23: "Traffic exhausted",
64 24: "File unavailable",
65 25: "Service unavailable",
66 26: "Upload too big",
67 27: "Upload error",
68 28: "File not allowed",
69 29: "Torrent too big",
70 30: "Torrent file invalid",
71 31: "Action already done",
72 32: "Image resolution error",
73 33: "Torrent already active",
74 34: "Too many requests",
75 35: "Infringing file",
76 36: "Fair Usage Limit",
77 37: "Disabled endpoint",
78 }
79
80 if error_code in messages:
81 raise ProviderException(messages[error_code])
82 err = error_data.get("error") or error_data.get("message")
83 if err:
84 raise ProviderException(f"{err} (code {error_code})")
85 raise ProviderException(f"Real-Debrid error (code {error_code})")
86
87 def _make_request(
88 self,
89 method,
90 url,
91 data=None,
92 params=None,
93 json=None,
94 is_return_none=False,
95 is_expected_to_fail=False,
96 ):
97 params = params or {}
98 return super()._make_request(
99 method,
100 url,
101 data=data,
102 params=params,
103 json=json,
104 is_return_none=is_return_none,
105 is_expected_to_fail=is_expected_to_fail,
106 )
107
108 @staticmethod
109 def encode_token_data(client_id, client_secret, code):
110 token = f"{client_id}:{client_secret}:{code}"
111 return b64encode(str(token).encode()).decode()
112
113 @staticmethod
114 def decode_token_str(token):
115 try:
116 decoded = b64decode(token)
117 decoded_str = decoded.decode()
118 parts = decoded_str.split(":")
119 if len(parts) != 3:
120 raise ProviderException("Invalid token format")
121 client_id, client_secret, code = parts
122 return {
123 "client_id": client_id,
124 "client_secret": client_secret,
125 "code": code,
126 }
127 except Exception as e:
128 raise ProviderException(f"Invalid token {e}")
129
130 def get_device_code(self):
131 return self._make_request(
132 "GET",
133 f"{self.OAUTH_URL}/device/code",
134 params={"client_id": self.CLIENT_ID, "new_credentials": "yes"},
135 )
136
137 def get_token(self, client_id, client_secret, device_code):
138 return self._make_request(
139 "POST",
140 f"{self.OAUTH_URL}/token",
141 data={
142 "client_id": client_id,
143 "client_secret": client_secret,
144 "code": device_code,
145 "grant_type": "http://oauth.net/grant_type/device/1.0",
146 },
147 )
148
149 def authorize(self, device_code):
150 response_data = self._make_request(
151 "GET",
152 f"{self.OAUTH_URL}/device/credentials",
153 params={"client_id": self.CLIENT_ID, "code": device_code},
154 is_expected_to_fail=True,
155 )
156
157 if "client_secret" not in response_data:
158 return response_data
159
160 token_data = self.get_token(
161 response_data["client_id"], response_data["client_secret"], device_code
162 )
163 if "access_token" in token_data:
164 token = self.encode_token_data(
165 response_data["client_id"],
166 response_data["client_secret"],
167 token_data["refresh_token"],
168 )
169 return {"token": token}
170 else:
171 return token_data
172
173 def remove_auth(self):
174 self.token = ""
175 set_setting("real_debrid_token", "")
176 set_setting("real_debid_authorized", "false")
177 set_setting("real_debrid_user", "")
178 dialog_ok(translation(90544), translation(90561))
179
180 def auth(self):
181 run_realdebrid_auth(self)
182
183 def download(self, magnet_url, pack=False):
184 run_realdebrid_download(self, magnet_url, pack=pack)
185
186 def get_hosts(self):
187 return self._make_request("GET", f"{self.BASE_URL}/hosts")
188
189 def add_magnet_link(self, magnet_link):
190 return self._make_request(
191 "POST", f"{self.BASE_URL}/torrents/addMagnet", data={"magnet": magnet_link}
192 )
193
194 def get_available_torrent_hosts(self):
195 return self._make_request("GET", f"{self.BASE_URL}/torrents/availableHosts")
196
197 def add_torrent_file(self, torrent_data, torrent_name="torrent.torrent", host=""):
198 selected_host = host or self._get_default_torrent_host()
199 if not selected_host:
200 raise ProviderException("No available Real-Debrid torrent host found")
201
202 response = self.session.request(
203 method="PUT",
204 url=f"{self.BASE_URL}/torrents/addTorrent",
205 params={"host": selected_host},
206 files={
207 "file": (
208 torrent_name or "torrent.torrent",
209 torrent_data,
210 "application/x-bittorrent",
211 )
212 },
213 headers=self.headers,
214 timeout=self.timeout,
215 )
216 self._handle_errors(response, is_expected_to_fail=False)
217 return self._parse_response(response, is_return_none=False)
218
219 def _get_default_torrent_host(self):
220 hosts = self.get_available_torrent_hosts()
221 for host in self._iter_host_candidates(hosts):
222 if host:
223 return host
224 return ""
225
226 def _iter_host_candidates(self, payload):
227 if isinstance(payload, str):
228 yield payload
229 return
230
231 if isinstance(payload, dict):
232 preferred_host = payload.get("host") or payload.get("domain")
233 if isinstance(preferred_host, str) and preferred_host:
234 yield preferred_host
235
236 for key, value in payload.items():
237 if isinstance(key, str) and "." in key:
238 yield key
239 yield from self._iter_host_candidates(value)
240 return
241
242 if isinstance(payload, list):
243 for item in payload:
244 yield from self._iter_host_candidates(item)
245
246 def get_user_torrent_list(self):
247 return self._make_request("GET", f"{self.BASE_URL}/torrents")
248
249 def get_user_downloads_list(self, page=1):
250 return self._make_request(
251 "GET", f"{self.BASE_URL}/downloads", params={"page": page}
252 )
253
254 def get_user(self):
255 return self._make_request("GET", f"{self.BASE_URL}/user")
256
257 def days_remaining(self):
258 try:
259 user = self.get_user()
260 if not user or "expiration" not in user:
261 return None
262
263 expiration = user["expiration"]
264 if not expiration:
265 return None
266
267 import datetime
268
269 try:
270 expires = datetime.datetime.strptime(
271 expiration, "%Y-%m-%dT%H:%M:%S.%fZ"
272 )
273 except ValueError:
274 expires = datetime.datetime.strptime(expiration, "%Y-%m-%dT%H:%M:%SZ")
275
276 days = (expires - datetime.datetime.utcnow()).days
277 return days
278 except Exception as e:
279 kodilog(f"Error calculating RealDebrid days remaining: {e}")
280 return None
281
282 def get_torrent_active_count(self):
283 return self._make_request("GET", f"{self.BASE_URL}/torrents/activeCount")
284
285 def get_torrent_info(self, torrent_id):
286 return self._make_request("GET", f"{self.BASE_URL}/torrents/info/{torrent_id}")
287
288 def get_torrent_instant_availability(self, torrent_hash):
289 return self._make_request(
290 "GET", f"{self.BASE_URL}/torrents/instantAvailability/{torrent_hash}"
291 )
292
293 def disable_access_token(self):
294 pass
295
296 def select_files(self, torrent_id, file_ids="all"):
297 return self._make_request(
298 "POST",
299 f"{self.BASE_URL}/torrents/selectFiles/{torrent_id}",
300 data={"files": file_ids},
301 is_return_none=True,
302 )
303
304 def get_available_torrent(self, info_hash):
305 available_torrents = self.get_user_torrent_list()
306 for torrent in available_torrents:
307 if isinstance(torrent, dict) and torrent.get("hash") == info_hash:
308 return torrent
309
310 def create_download_link(self, link):
311 response = self._make_request(
312 "POST",
313 f"{self.BASE_URL}/unrestrict/link",
314 data={"link": link},
315 )
316 if "download" in response:
317 return response
318
319 if "error_code" in response:
320 if response["error_code"] == 23:
321 raise ProviderException("Exceed remote traffic limit")
322 raise ProviderException(f"Failed to create download link. response: {response}")
323
324 def delete_torrent(self, torrent_id):
325 return self._make_request(
326 "DELETE",
327 f"{self.BASE_URL}/torrents/delete/{torrent_id}",
328 is_return_none=True,
329 )
plugin.video.jacktook/lib/api/debrid/torbox.py +232 -0
……@@ -0,0 +1,232 @@
1from lib.api.debrid.base import DebridClient, ProviderException
2from lib.utils.kodi.utils import (
3 dialog_ok,
4 kodilog,
5 notification,
6 set_setting,
7 translation,
8)
9import datetime
10import requests
11from requests.adapters import HTTPAdapter
12from lib.services.debrid.auth import run_torbox_auth
13
14
15class Torbox(DebridClient):
16 def __init__(self, token, timeout=15):
17 session = requests.Session()
18 adapter = HTTPAdapter(max_retries=1)
19 session.mount("https://", adapter)
20 super().__init__(token, timeout, session)
21
22 BASE_URL = "https://api.torbox.app/v1/api"
23
24 def initialize_headers(self):
25 self.headers = {
26 "User-Agent": "Jacktook/1.0",
27 "Accept": "application/json",
28 }
29 if self.token:
30 self.headers["Authorization"] = f"Bearer {self.token}"
31
32 def disable_access_token(self):
33 pass
34
35 def _handle_service_specific_errors(self, error_data: dict, status_code: int):
36 error_code = error_data.get("error")
37 if error_code in {"BAD_TOKEN", "AUTH_ERROR", "OAUTH_VERIFICATION_ERROR"}:
38 raise ProviderException("Invalid Torbox token")
39 elif error_code == "DOWNLOAD_TOO_LARGE":
40 raise ProviderException("Download size too large for the user plan")
41 elif error_code in {"ACTIVE_LIMIT", "MONTHLY_LIMIT"}:
42 raise ProviderException("Download limit exceeded")
43 elif error_code in {"DOWNLOAD_SERVER_ERROR", "DATABASE_ERROR"}:
44 raise ProviderException("Torbox server error")
45
46 def _make_request(
47 self,
48 method,
49 url,
50 data=None,
51 params=None,
52 json=None,
53 is_return_none=False,
54 is_expected_to_fail=False,
55 ):
56 params = params or {}
57 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 )
67
68 def add_magnet_link(self, magnet_link):
69 return self._make_request(
70 "POST",
71 "/torrents/createtorrent",
72 data={"magnet": magnet_link, "seed": 3, "allow_zip": "false"},
73 is_expected_to_fail=False,
74 )
75
76 def add_torrent_file(self, torrent_data, torrent_name="torrent.torrent"):
77 response = self.session.request(
78 method="POST",
79 url=f"{self.BASE_URL}/torrents/createtorrent",
80 data={"seed": 3, "allow_zip": "false", "name": torrent_name or "torrent.torrent"},
81 files={
82 "file": (
83 torrent_name or "torrent.torrent",
84 torrent_data,
85 "application/x-bittorrent",
86 )
87 },
88 headers=self.headers,
89 timeout=self.timeout,
90 )
91 self._handle_errors(response, is_expected_to_fail=False)
92 return self._parse_response(response, is_return_none=False)
93
94 def get_user_torrent_list(self):
95 try:
96 return self._make_request(
97 "GET",
98 "/torrents/mylist",
99 params={"bypass_cache": "true"},
100 )
101 except ProviderException as exc:
102 if "Request timed out" in str(exc):
103 raise ProviderException(
104 "Torbox timed out while checking your cloud torrents. Please try again."
105 )
106 raise
107
108 def get_torrent_info(self, magnet_id):
109 response = self.get_user_torrent_list()
110 torrent_list = response.get("data", {})
111 for torrent in torrent_list:
112 if torrent.get("magnet", "") == magnet_id:
113 return torrent
114
115 def get_available_torrent(self, info_hash):
116 try:
117 response = self.get_user_torrent_list()
118 except ProviderException:
119 raise
120 torrent_list = response.get("data", {})
121 for torrent in torrent_list:
122 if torrent.get("hash", "") == info_hash:
123 return torrent
124
125 def get_torrent_instant_availability(self, torrent_hashes):
126 return self._make_request(
127 "POST",
128 "/torrents/checkcached",
129 params={"format": "list"},
130 json={"hashes": torrent_hashes},
131 )
132
133 def create_download_link(self, torrent_id, filename, user_ip=None):
134 params = {
135 "token": self.token,
136 "torrent_id": torrent_id,
137 "file_id": filename,
138 }
139 if user_ip:
140 params["user_ip"] = user_ip
141
142 response = self._make_request(
143 "GET",
144 "/torrents/requestdl",
145 params=params,
146 is_expected_to_fail=True,
147 )
148 if response.get("success"):
149 return response
150 else:
151 detail = response.get("detail", "Unknown error")
152 notification(f"Failed to create download link: {detail}")
153 return None
154
155 def delete_torrent(self, torrent_id):
156 self._make_request(
157 "POST",
158 "/torrents/controltorrent",
159 data={"torrent_id": torrent_id, "operation": "Delete"},
160 is_expected_to_fail=False,
161 )
162
163 def download(self, magnet_link):
164 pass
165
166 def get_device_code(self):
167 return self._make_request(
168 "GET",
169 "/user/auth/device/start",
170 params={"app": "Jacktook"},
171 )
172
173 def get_token(self, device_code, is_expected_to_fail=False):
174 return self._make_request(
175 "POST",
176 "/user/auth/device/token",
177 json={"device_code": device_code},
178 is_expected_to_fail=is_expected_to_fail,
179 )
180
181 def authorize(self, device_code):
182 response_data = self.get_token(device_code, is_expected_to_fail=True)
183 if response_data.get("success"):
184 token_data = response_data.get("data", {})
185 if isinstance(token_data, dict):
186 return {"token": token_data.get("access_token")}
187 return {"token": token_data}
188 return response_data
189
190 def remove_auth(self):
191 self.token = ""
192 set_setting("torbox_token", "")
193 set_setting("torbox_user", "")
194 set_setting("torbox_enabled", "false")
195 dialog_ok(translation(90544), translation(90561))
196
197 def auth(self):
198 run_torbox_auth(self)
199
200 def get_user(self):
201 return self._make_request("GET", "/user/me")
202
203 def days_remaining(self):
204 try:
205 account_info = self.get_user()
206 if not account_info.get("success"):
207 return None
208
209 data = account_info.get("data", {})
210 expires_at = data.get("premium_expires_at")
211 if not expires_at:
212 return None
213
214 # Try common ISO formats
215 for fmt in (
216 "%Y-%m-%dT%H:%M:%S.%fZ",
217 "%Y-%m-%dT%H:%M:%SZ",
218 "%Y-%m-%dT%H:%M:%S",
219 ):
220 try:
221 expires = datetime.datetime.strptime(expires_at, fmt)
222 break
223 except ValueError:
224 continue
225 else:
226 return None
227
228 days = (expires - datetime.datetime.utcnow()).days
229 return days
230 except Exception as e:
231 kodilog(f"Error calculating days remaining: {e}")
232 return None
plugin.video.jacktook/lib/api/fanart/fanart.py +136 -0
……@@ -0,0 +1,136 @@
1import requests
2from lib.utils.kodi.utils import get_setting
3
4API_KEY = "a7ad21743fd710fccb738232f2fbdcfc"
5CLIENT_KEY = "fe073550acf157bdb8a4217f215c0882"
6BASE_URL = "https://webservice.fanart.tv/v3/{media_type}/{media_id}"
7TIMEOUT = 3.0
8
9DEFAULT_FANART = {
10 "poster2": "",
11 "fanart2": "",
12 "banner": "",
13 "clearart": "",
14 "clearlogo": "",
15 "landscape": "",
16 "discart": "",
17 "fanart_added": False,
18}
19
20# Shared HTTP session (persistent, connection pooled)
21session = requests.Session()
22session.mount(
23 "https://webservice.fanart.tv", requests.adapters.HTTPAdapter(pool_maxsize=100)
24)
25
26
27def get_fanart(media_type: str, language: str, media_id: str) -> dict:
28 """
29 Fetch localized artwork (poster, fanart, clearlogo, etc.) from fanart.tv.
30 Supports movies and TV shows, with language fallback: local → English → universal.
31 """
32 if not media_id:
33 return DEFAULT_FANART.copy()
34
35 url = BASE_URL.format(media_type=media_type, media_id=media_id)
36 client_key = get_setting("fanart_client_key") or CLIENT_KEY
37 headers = {"client-key": client_key, "api-key": API_KEY}
38
39 try:
40 response = session.get(url, headers=headers, timeout=TIMEOUT)
41 data = response.json()
42 except Exception:
43 return DEFAULT_FANART.copy()
44
45 # Handle API errors
46 if not data or "error_message" in data:
47 return DEFAULT_FANART.copy()
48
49 # Convenience alias
50 get = data.get
51
52 if media_type == "movies":
53 return {
54 "poster": select_art(get("movieposter"), language),
55 "fanart": select_art(get("moviebackground"), language),
56 "banner": select_art(get("moviebanner"), language),
57 "clearart": select_art(
58 get("movieart", []) + get("hdmovieclearart", []), language
59 ),
60 "clearlogo": select_art(
61 get("movielogo", []) + get("hdmovielogo", []), language
62 ),
63 "landscape": select_art(get("moviethumb"), language),
64 "discart": select_art(get("moviedisc"), language),
65 "fanart_added": True,
66 }
67
68 # Default to TV shows
69 return {
70 "poster": select_art(get("tvposter"), language),
71 "fanart": select_art(get("showbackground"), language),
72 "banner": select_art(get("tvbanner"), language),
73 "clearart": select_art(get("clearart", []) + get("hdclearart", []), language),
74 "clearlogo": select_art(get("hdtvlogo", []) + get("clearlogo", []), language),
75 "landscape": select_art(get("tvthumb"), language),
76 "discart": "",
77 "fanart_added": True,
78 }
79
80
81# --- Artwork Selector ---
82def select_art(art_list: list, language: str) -> str:
83 """
84 Return the best matching artwork URL from a list of art objects.
85 Preference order: user's language → English → universal.
86 Sorts by number of likes (descending).
87 """
88 if not art_list:
89 return ""
90
91 try:
92 # 1. User's language (e.g. 'es')
93 matches = [
94 (x["url"], int(x.get("likes", 0)))
95 for x in art_list
96 if x.get("lang") == language
97 ]
98
99 # 2. English fallback
100 if not matches and language != "en":
101 matches = [
102 (x["url"], int(x.get("likes", 0)))
103 for x in art_list
104 if x.get("lang") == "en"
105 ]
106
107 # 3. Universal fallback ('00' or no lang)
108 if not matches:
109 matches = [
110 (x["url"], int(x.get("likes", 0)))
111 for x in art_list
112 if x.get("lang") in ("00", "", None)
113 ]
114
115 if matches:
116 # Sort by likes descending, return top URL
117 matches.sort(key=lambda x: x[1], reverse=True)
118 return matches[0][0]
119
120 except Exception:
121 pass
122
123 return ""
124
125
126# --- Helper: Merge Results into Existing Metadata ---
127def add_fanart(media_type: str, language: str, media_id: str, meta: dict) -> dict:
128 """
129 Update an existing metadata dictionary with fanart data.
130 Always returns a complete dict (never None).
131 """
132 try:
133 meta.update(get_fanart(media_type, language, media_id))
134 except Exception:
135 meta.update(DEFAULT_FANART)
136 return meta
plugin.video.jacktook/lib/api/imdb/imdb_scraper.py +196 -0
……@@ -0,0 +1,196 @@
1import requests
2import re
3import html
4from lib.utils.kodi.utils import kodilog
5from lib.db.cached import cache
6from datetime import timedelta
7
8
9def clean_html(text):
10 if not text:
11 return ""
12 # Remove all HTML tags
13 text = re.sub(r"<[^>]+>", " ", text)
14 # Unescape HTML entities
15 text = html.unescape(text)
16 # Cleanup whitespace
17 text = " ".join(text.split())
18 return text.strip()
19
20
21def get_imdb_trivia(imdb_id):
22 """
23 Scrapes the IMDb trivia page for a given IMDb ID.
24 Returns a list of trivia strings.
25 """
26 if not imdb_id:
27 return []
28
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
33
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 }
39
40 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
62
63 except Exception as e:
64 kodilog(f"Error scraping IMDb trivia for {imdb_id}: {str(e)}")
65 return []
66
67
68def get_imdb_goofs(imdb_id):
69 """
70 Scrapes the IMDb goofs page for a given IMDb ID.
71 Returns a list of goof strings.
72 """
73 if not imdb_id:
74 return []
75
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
80
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 }
86
87 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
108 except Exception as e:
109 kodilog(f"Error scraping IMDb goofs for {imdb_id}: {str(e)}")
110 return []
111
112
113def get_imdb_parentsguide(imdb_id):
114 """
115 Scrapes the IMDb parents guide page for a given IMDb ID.
116 Returns a list of parental guide strings grouped by category.
117 """
118 if not imdb_id:
119 return []
120
121 cache_key = f"imdb_parentsguide_regex_v1_{imdb_id}"
122 cached_data = cache.get(cache_key)
123 if cached_data is not None:
124 return cached_data
125
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
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 }
145
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
194 except Exception as e:
195 kodilog(f"Error scraping IMDb parental guide for {imdb_id}: {str(e)}")
196 return []
plugin.video.jacktook/lib/api/jacktorr/jacktorr.py +152 -0
……@@ -0,0 +1,152 @@
1from json import dumps
2import requests
3from requests.auth import HTTPBasicAuth
4from urllib.parse import quote
5from lib.utils.kodi.utils import notification
6
7
8class TorrServer(object):
9 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 )
13 self._username = username
14 self._password = password
15 self._auth = HTTPBasicAuth(self._username, self._password)
16 self._session = session or requests
17
18 @property
19 def torr_version(self):
20 """tests server status"""
21 return self._get("/echo").content
22
23 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"]
37
38 def add_torrent(self, path, title="", poster="", data=""):
39 with open(path, "rb") as file:
40 return self._post(
41 "/torrent/upload",
42 files={"file": file},
43 data={
44 "save": "true",
45 "title": title,
46 "poster": poster,
47 "data": data,
48 },
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"]
62
63 def torrents(self):
64 """read info about all torrents (doesn't fill file_stats info)"""
65 return self._post("/torrents", data=dumps({"action": "list"})).json()
66
67 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()
72
73 def get_torrent_info(self, link):
74 """read extended info of one torrent"""
75 return self._get("/stream", params={"link": link, "stat": "true"}).json()
76
77 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()
82
83 def drop_torrent(self, hash):
84 return self._post("/torrents", data=dumps({"action": "drop", "hash": hash}))
85
86 def remove_torrent(self, info_hash, save_to_db=True):
87 """delete torrent from TorrServer"""
88 return self._post(
89 "/torrents",
90 data=dumps({"action": "rem", "hash": info_hash, "save_to_db": save_to_db}),
91 )
92
93 def play_torrent(self, hash, id):
94 """Play given torrent referenced by hash"""
95 """ application/octet-stream """
96 return self._get("/play", params={"hash": hash, "id": id})
97
98 def play_stream(self, link, title="", poster=""):
99 """Play given torrent referenced by link"""
100 """ application/octet-stream """
101 return self._get(
102 "/stream",
103 params={"link": link, "title": title, "poster": poster, "play": "true"},
104 )
105
106 def preload_torrent(self, link, file_id=1, title=""):
107 """preload torrent"""
108 return self._get(
109 "/stream",
110 params={
111 "link": link,
112 "index": file_id,
113 "title": title,
114 "stat": "true",
115 "preload": "true",
116 },
117 )
118
119 def get_stream_url(self, link, path, file_id):
120 """returns the stream url"""
121 return f"{self._base_url}/stream/{quote(path)}?link={link}&index={file_id}&play"
122
123 def get_settings(self):
124 res = self._post("/settings", data=dumps({"action": "get"}))
125 return res.json()
126
127 def _post(self, url, **kwargs):
128 return self._request("post", url, **kwargs)
129
130 def _put(self, url, **kwargs):
131 return self._request("put", url, **kwargs)
132
133 def _get(self, url, **kwargs):
134 return self._request("get", url, **kwargs)
135
136 def _delete(self, url, **kwargs):
137 return self._request("delete", url, **kwargs)
138
139 def _request(self, method, url, **kwargs):
140 try:
141 return self._session.request(
142 method, self._base_url + url, auth=self._auth, **kwargs
143 )
144 except Exception as e:
145 raise TorrServerError(str(e))
146
147
148class TorrServerError(Exception):
149 def __init__(self, message):
150 self.message = message
151 super().__init__(self.message)
152 notification(self.message)
plugin.video.jacktook/lib/api/mdblist/mdblist.py +152 -0
……@@ -0,0 +1,152 @@
1import requests
2from typing import List, Dict, Any, Optional
3from lib.utils.kodi.utils import get_setting
4
5
6class MDblistAPIError(Exception):
7 pass
8
9
10class MDblistAPI:
11 BASE_URL = "https://api.mdblist.com/"
12
13 def __init__(self, provider: str = "tmdb"):
14 self.api_key = str(get_setting("mdblist_api_key", ""))
15 self.provider = provider
16 self.session = requests.Session()
17
18 def search_lists(self, query: str) -> List[Dict[str, Any]]:
19 """
20 Searches lists on MDblist by query string.
21 Returns a list of dictionaries representing each matching list.
22 """
23 if not query:
24 raise ValueError("Query string must not be empty.")
25 url = f"{self.BASE_URL}lists/search"
26 params = {"query": query, "apikey": self.api_key}
27 try:
28 response = self.session.get(url, params=params)
29 response.raise_for_status()
30 return response.json()
31 except requests.RequestException as e:
32 raise MDblistAPIError(f"Failed to search lists: {e}")
33
34 def get_user_lists(self) -> List[Dict[str, Any]]:
35 """
36 Fetches user lists from MDblist API.
37 Returns a list of dictionaries representing each user list.
38 """
39 url = f"{self.BASE_URL}lists/user"
40 params = {"apikey": self.api_key}
41 try:
42 response = self.session.get(url, params=params)
43 response.raise_for_status()
44 return response.json()
45 except requests.RequestException as e:
46 raise MDblistAPIError(f"Failed to fetch user lists: {e}")
47
48 def get_top_lists(self) -> List[Dict[str, Any]]:
49 """
50 Fetches top lists from MDblist API.
51 Returns a list of dictionaries representing each top list.
52 """
53 url = f"{self.BASE_URL}lists/top"
54 params = {"apikey": self.api_key}
55 try:
56 response = self.session.get(url, params=params)
57 response.raise_for_status()
58 return response.json()
59 except requests.RequestException as e:
60 raise MDblistAPIError(f"Failed to fetch top lists: {e}")
61
62 def get_list_by_id(self, list_id: str) -> Optional[Dict[str, Any]]:
63 """
64 Fetches a single list by its ID.
65 Returns a dictionary representing the list, or None if not found.
66 """
67 if not list_id:
68 raise ValueError("List ID must not be empty.")
69 url = f"{self.BASE_URL}lists/{list_id}"
70 params = {"apikey": self.api_key}
71 try:
72 response = self.session.get(url, params=params)
73 response.raise_for_status()
74 return response.json()
75 except requests.HTTPError as e:
76 raise MDblistAPIError(f"Failed to fetch list by ID: {e}")
77 except requests.RequestException as e:
78 raise MDblistAPIError(f"Failed to fetch list by ID: {e}")
79
80 def get_bulk_ratings(
81 self,
82 media_type: str,
83 return_rating: str,
84 ids: List[str],
85 provider: Optional[str] = None,
86 ) -> Dict[str, Any]:
87 """
88 Fetches bulk ratings for a list of IDs from MDblist API.
89 Returns a dictionary with ratings for each ID.
90 """
91 if not ids or not isinstance(ids, list):
92 raise ValueError("IDs must be a non-empty list.")
93 provider = provider or self.provider
94 url = f"{self.BASE_URL}rating/{media_type}/{return_rating}"
95 params = {"apikey": self.api_key}
96 payload = {"ids": ids, "provider": provider}
97 headers = {"Content-Type": "application/json"}
98 try:
99 response = self.session.post(
100 url, params=params, json=payload, headers=headers
101 )
102 response.raise_for_status()
103 return response.json()
104 except requests.RequestException as e:
105 raise MDblistAPIError(f"Failed to fetch bulk ratings: {e}")
106
107 def get_list_items(
108 self,
109 list_id: str,
110 limit: int = 50,
111 offset: int = 0,
112 append_to_response: str = "genre,poster",
113 filter_title: str = "",
114 filter_genre: str = "",
115 genre_operator: str = "or",
116 released_from: str = "",
117 released_to: str = "",
118 sort: str = "rank",
119 order: str = "asc",
120 unified: bool = True,
121 ) -> Dict[str, Any]:
122 """
123 Fetches items from a specified list with pagination and filters.
124 Returns a dict with 'movies', 'shows', and pagination info.
125 """
126 if not list_id:
127 raise ValueError("List ID must not be empty.")
128 url = f"{self.BASE_URL}lists/{list_id}/items"
129 params = {
130 "limit": limit,
131 "offset": offset,
132 "append_to_response": append_to_response,
133 "genre_operator": genre_operator,
134 "sort": sort,
135 "order": order,
136 "unified": str(unified).lower(),
137 "apikey": self.api_key,
138 }
139 if filter_title:
140 params["filter_title"] = filter_title
141 if filter_genre:
142 params["filter_genre"] = filter_genre
143 if released_from:
144 params["released_from"] = released_from
145 if released_to:
146 params["released_to"] = released_to
147 try:
148 response = self.session.get(url, params=params)
149 response.raise_for_status()
150 return response.json()
151 except requests.RequestException as e:
152 raise MDblistAPIError(f"Failed to fetch list items: {e}")
plugin.video.jacktook/lib/api/plex/__init__.py +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/lib/api/plex/media_server.py +195 -0
……@@ -0,0 +1,195 @@
1import json
2import requests
3
4from http import HTTPStatus
5from requests.exceptions import ConnectionError, Timeout
6from lib.api.plex.settings import settings
7from lib.api.plex.models.plex_models import (
8 PlexEpisodeMeta,
9 PlexLibrarySection,
10 PlexMediaMeta,
11 PlexMediaType,
12 PlexServer,
13)
14from lib.api.plex.utils import PlexUnauthorizedError
15from lib.utils.kodi.utils import kodilog
16
17
18def check_server_connection(url, access_token):
19 try:
20 response = requests.get(
21 url,
22 params={
23 "X-Plex-Token": access_token,
24 },
25 timeout=settings.plex_requests_timeout,
26 )
27 if response.status_code != HTTPStatus.OK:
28 return False
29 return True
30 except (Timeout, ConnectionError):
31 return False
32
33
34def get_servers(url, token):
35 json = get_json(
36 url=f"{url}/resources",
37 params={
38 "includeHttps": 1,
39 "includeRelay": 1,
40 "X-Plex-Token": token,
41 "X-Plex-Client-Identifier": settings.identifier,
42 },
43 )
44 return [
45 PlexServer(**server)
46 for server in json
47 if "server" in server["provides"] and "accessToken" in server
48 ]
49
50
51def get_sections(url, token):
52 json = get_json(
53 url=f"{url}/library/sections",
54 params={
55 "X-Plex-Token": token,
56 },
57 )
58 return [
59 PlexLibrarySection(**section)
60 for section in json["MediaContainer"]["Directory"]
61 if section["type"] in {PlexMediaType.movie, PlexMediaType.show}
62 ]
63
64
65def get_section_media(
66 url,
67 token: str,
68 section_id: str,
69 skip: int,
70 search: str,
71):
72 params = {
73 "includeGuids": 1,
74 "X-Plex-Container-Start": skip,
75 "X-Plex-Container-Size": 100,
76 "X-Plex-Token": token,
77 }
78 if search:
79 params["title"] = search
80 json = get_json(
81 url=f"{url}/library/sections/{section_id}/all",
82 params=params,
83 )
84 metadata = json["MediaContainer"].get("Metadata", [])
85 return [PlexMediaMeta(**meta) for meta in metadata]
86
87
88def get_media(url, token, guid, get_only_first=False):
89 json = get_json(
90 url=f"{url}/library/all",
91 params={
92 "guid": guid,
93 "X-Plex-Token": token,
94 },
95 )
96 kodilog(f"get_media/library/all: {json}")
97 media_sections = json["MediaContainer"].get("Metadata", [])
98 media_metas = []
99 for section in media_sections:
100 json = get_json(
101 url=f"{url}/library/metadata/{section['ratingKey']}",
102 params={
103 "X-Plex-Token": token,
104 "includeElements": "Stream",
105 },
106 )
107 metadata = json["MediaContainer"]["Metadata"][0]
108 kodilog(f"get_media/library/metadata/: {json}")
109 media_metas.append(PlexMediaMeta(**metadata))
110 if get_only_first:
111 break
112 return media_metas
113
114
115def get_all_episodes(url, token, key):
116 json = get_json(
117 url=str(f"{url}/{key[1:]}").replace("/children", "/allLeaves"),
118 params={
119 "X-Plex-Token": token,
120 },
121 )
122 metadata = json["MediaContainer"].get("Metadata", [])
123 return [PlexEpisodeMeta(**meta) for meta in metadata]
124
125
126def imdb_to_plex_id(imdb_id, token, mode, media_type):
127 json = get_json(
128 url="https://metadata.provider.plex.tv/library/metadata/matches",
129 params={
130 "X-Plex-Token": token,
131 "type": 1 if (media_type == "movies" or mode =="movies") else 2,
132 "title": f"imdb-{imdb_id}",
133 "guid": f"com.plexapp.agents.imdb://{imdb_id}?lang=en",
134 },
135 )
136 media_container = json["MediaContainer"]
137 if media_container["totalSize"]:
138 return media_container["Metadata"][0]["guid"]
139
140
141def get_episode_guid(url, token, show_guid, season, episode):
142 all_episodes = get_all_episodes(
143 url=url,
144 token=token,
145 key=show_guid,
146 )
147 for metadata in all_episodes:
148 if str(metadata.parent_index) == season and str(metadata.index) == episode:
149 return metadata.guid
150
151
152def convert_to_plex_id(url, access_token, auth_token, id, mode, media_type, season, episode):
153 plex_id = imdb_to_plex_id(id, auth_token, mode, media_type)
154 if not plex_id:
155 return None
156
157 if mode == "tv" or media_type == "tv":
158 media = get_media(
159 url=url,
160 token=access_token,
161 guid=plex_id,
162 )
163 for meta in media:
164 plex_id = get_episode_guid(
165 url=url,
166 token=access_token,
167 show_guid=meta.key,
168 season=season,
169 episode=episode,
170 )
171 if plex_id:
172 break
173 else:
174 return None
175
176 return plex_id
177
178
179def get_json(url, params=None):
180 if params is None:
181 params = {}
182 try:
183 response = requests.get(
184 url,
185 params=params,
186 headers={"accept": "application/json"},
187 timeout=settings.plex_requests_timeout,
188 )
189 if response.status_code in (401, 403):
190 raise PlexUnauthorizedError()
191 if response.status_code >= 400:
192 kodilog("Received error from plex server")
193 return json.loads(response.content)
194 except TimeoutError:
195 kodilog("Plex server timeout error")
plugin.video.jacktook/lib/api/plex/models/plex_models.py +173 -0
……@@ -0,0 +1,173 @@
1import os
2from datetime import datetime
3from enum import Enum
4from urllib import parse
5from lib.utils.kodi_utils import get_setting
6from lib.utils.utils import Indexer
7
8
9class AuthPin:
10 def __init__(self, **kwargs):
11 self.id = kwargs["id"]
12 self.code = kwargs["code"]
13
14
15class PlexUser:
16 def __init__(self, **kwargs):
17 self.username = kwargs["username"]
18 self.thumb = kwargs["thumb"]
19
20
21class PlexServer:
22 def __init__(self, **kwargs):
23 self.name = kwargs["name"]
24 self.source_title = kwargs["sourceTitle"]
25 self.public_address = kwargs["publicAddress"]
26 self.access_token = kwargs["accessToken"]
27 self.relay = kwargs["relay"]
28 self.https_required = kwargs["httpsRequired"]
29 self.connections = kwargs["connections"]
30
31
32class PlexMediaType(str, Enum):
33 show = "show"
34 movie = "movie"
35 episode = "episode"
36
37
38class PlexLibrarySection:
39 def __init__(self, **kwargs):
40 self.key = kwargs.get("key")
41 self.title = kwargs.get("title")
42 self.type = kwargs.get("type")
43
44
45class PlexMediaMeta:
46 def __init__(self, **kwargs):
47 self.guid = kwargs.get("guid")
48 self.type = kwargs.get("type")
49 self.title = kwargs.get("title")
50 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)
59 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)
72 self.media = kwargs.get("Media")
73 self.genre = kwargs.get("Genre")
74 self.country = kwargs.get("Country")
75 self.guids = kwargs.get("Guid")
76 self.director = kwargs.get("Director")
77 self.writer = kwargs.get("Writer")
78 self.role = kwargs.get("Role")
79 self.producer = kwargs.get("Producer")
80
81 def get_year(self):
82 if self.year:
83 return str(self.year)
84 return datetime.fromtimestamp(self.added_at).strftime("%Y")
85
86 def get_streams(self):
87 streams = []
88 for i, media in enumerate(self.media):
89 name = f"{get_setting('plex_server_name')} {self.library_section_title}"
90 filename = os.path.basename(media["Part"][0]["file"])
91 audio_languages = set()
92 subtitles_languages = set()
93 for part_stream in media["Part"][0].get("Stream", []):
94 if part_stream["streamType"] == 2:
95 audio_languages.add(part_stream.get("languageTag", "Unknown"))
96 elif part_stream["streamType"] == 3:
97 subtitles_languages.add(part_stream.get("languageTag", "Unknown"))
98
99 description_template = (
100 f"{filename}\n"
101 f'{"/".join(sorted(audio_languages))} '
102 f'({"/".join(sorted(subtitles_languages))})'
103 )
104
105 encoded_params = parse.urlencode(
106 {
107 "X-Plex-Token": get_setting("plex_server_token"),
108 }
109 )
110 streams.append(
111 {
112 "title": f"Direct Play - {name} - {description_template}",
113 "indexer": Indexer.PLEX,
114 "quality_description": f'Direct Play {media["videoResolution"]}',
115 "url": f"{get_setting('plex_streaming_url')}/{media['Part'][0]['key'][1:]}?{encoded_params}",
116 }
117 )
118
119 encoded_params = parse.urlencode(
120 {
121 "path": self.key,
122 "mediaIndex": i,
123 "protocol": "hls",
124 "fastSeek": 1,
125 "copyts": 1,
126 "autoAdjustQuality": 0,
127 "X-Plex-Platform": "Chrome",
128 "X-Plex-Token": get_setting("plex_server_token"),
129 "videoQuality": 100,
130 }
131 )
132 streams.append(
133 {
134 "title": f"Transcode - {name} - {description_template}",
135 "indexer": Indexer.PLEX,
136 "quality_description": f'Transcode {media["videoResolution"]} (original)',
137 "url": f"{get_setting('plex_streaming_url')}/video/:/transcode/universal/start.m3u8?{encoded_params}",
138 }
139 )
140
141 return streams
142
143
144class PlexEpisodeMeta:
145 def __init__(self, **kwargs):
146 self.guid = kwargs.get("guid")
147 self.title = kwargs.get("title")
148 self.index = kwargs.get("index")
149 self.parent_index = kwargs.get("parentIndex")
150 self.added_at = kwargs.get("addedAt")
151 self.type = kwargs.get("type")
152 self.rating_key = kwargs.get("ratingKey")
153 self.key = kwargs.get("key")
154 self.parent_rating_key = kwargs.get("parentRatingKey")
155 self.grandparent_rating_key = kwargs.get("grandparentRatingKey")
156 self.studio = kwargs.get("studio")
157 self.grandparent_key = kwargs.get("grandparentKey")
158 self.parent_key = kwargs.get("parentKey")
159 self.grandparent_title = kwargs.get("grandparentTitle")
160 self.parent_title = kwargs.get("parentTitle")
161 self.content_rating = kwargs.get("contentRating")
162 self.summary = kwargs.get("summary", "")
163 self.year = kwargs.get("year")
164 self.thumb = kwargs.get("thumb")
165 self.art = kwargs.get("art")
166 self.parent_thumb = kwargs.get("parentThumb")
167 self.grandparent_thumb = kwargs.get("grandparentThumb")
168 self.grandparent_art = kwargs.get("grandparentArt")
169 self.grandparent_theme = kwargs.get("grandparentTheme")
170 self.duration = kwargs.get("duration")
171 self.originally_available_at = kwargs.get("originallyAvailableAt")
172 self.updated_at = kwargs.get("updatedAt")
173 self.media = kwargs.get("media", [])
plugin.video.jacktook/lib/api/plex/plex.py +127 -0
……@@ -0,0 +1,127 @@
1from http import HTTPStatus
2import json
3from time import sleep, time
4import requests
5
6from lib.api.plex.settings import settings
7from lib.api.plex.models.plex_models import AuthPin, PlexUser
8from lib.api.plex.utils import HTTPException, PlexUnauthorizedError
9from lib.utils.kodi.utils import copy2clip, dialog_ok, kodilog, set_setting
10
11import xbmcgui
12
13class PlexApi:
14 def __init__(self) -> None:
15 self.PLEX_AUTH_URL = "https://app.plex.tv/auth#?"
16 self.PLEX_API_URL = "https://plex.tv/api/v2"
17 self.client = requests.Session()
18 self.headers = {"accept": "application/json"}
19 self.auth_token = None
20
21 def login(self):
22 auth_pin = self.create_auth_pin()
23 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] "
27 )
28 progressDialog = xbmcgui.DialogProgress()
29 progressDialog.create("Plex Auth")
30 progressDialog.update(-1, content)
31 kodilog("Start polling plex.tv for token")
32 start_time = time()
33 while time() - start_time < 300:
34 auth_token = self.get_auth_token(auth_pin)
35 if auth_token is not None:
36 self.auth_token = auth_token
37 set_setting("plex_token", self.auth_token)
38 progressDialog.close()
39 dialog_ok("Success", "Authentication completed.")
40 return True
41 if progressDialog.iscanceled():
42 progressDialog.close()
43 return False
44 sleep(1)
45 else:
46 progressDialog.close()
47 dialog_ok("Error:", "Pin timed out.")
48 return False
49
50 def create_auth_pin(self) -> AuthPin:
51 response = self.client.post(
52 f"{self.PLEX_API_URL}/pins",
53 data={
54 "strong": "false",
55 "X-Plex-Product": settings.product_name,
56 "X-Plex-Client-Identifier": settings.identifier,
57 },
58 headers=self.headers,
59 timeout=settings.plex_requests_timeout,
60 )
61 json = response.json()
62 return AuthPin(**json)
63
64 def get_auth_token(self, auth_pin):
65 json = self.get_json(
66 url=f"{self.PLEX_API_URL}/pins/{auth_pin.id}",
67 params={
68 "code": auth_pin.code,
69 "X-Plex-Client-Identifier": settings.identifier,
70 },
71 )
72 return json["authToken"]
73
74 def get_json(self, url, params=None):
75 if params is None:
76 params = {}
77 try:
78 response = self.client.get(
79 url,
80 params=params,
81 headers=self.headers,
82 timeout=settings.plex_requests_timeout,
83 )
84 if response.status_code in (401, 403):
85 raise PlexUnauthorizedError()
86 if response.status_code >= 400:
87 raise HTTPException(
88 status_code=502,
89 detail="Received error from plex server",
90 )
91 return json.loads(response.content)
92 except TimeoutError:
93 raise HTTPException(
94 status_code=504,
95 detail="Plex server timeout error",
96 )
97
98 def get_plex_user(self):
99 response = self.client.get(
100 f"{self.PLEX_API_URL}/user",
101 params={
102 "X-Plex-Product": settings.product_name,
103 "X-Plex-Client-Identifier": settings.identifier,
104 "X-Plex-Token": self.auth_token,
105 },
106 headers=self.headers,
107 timeout=settings.plex_requests_timeout,
108 )
109 if response.status_code != HTTPStatus.OK:
110 return
111 json = response.json()
112 return PlexUser(**json)
113
114 def logout(self):
115 self.auth_token = ""
116 set_setting("plex_token", "")
117 try:
118 self.client.close()
119 except:
120 pass
121 set_setting("plex_user", "")
122 set_setting("plex_server_name", "")
123 set_setting("plex_discovery_url", "")
124 set_setting("plex_streaming_url", "")
125 set_setting("plex_token", "")
126 set_setting("plex_server_token", "")
127
plugin.video.jacktook/lib/api/plex/settings.py +10 -0
……@@ -0,0 +1,10 @@
1from uuid import uuid4
2
3class Settings():
4 identifier: str = str(uuid4())
5 product_name: str = 'Jacktook'
6 plex_requests_timeout: int = 30
7 plex_matching_token: str
8
9
10settings = Settings()
plugin.video.jacktook/lib/api/plex/utils.py +16 -0
……@@ -0,0 +1,16 @@
1
2class PlexUnauthorizedError(BaseException):
3 pass
4
5
6class HTTPException(BaseException):
7 def __init__(
8 self,
9 status_code: int,
10 detail: str,
11 ):
12 self.status_code = status_code
13 self.detail = detail
14
15
16
plugin.video.jacktook/lib/api/stremio/__init__.py +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/lib/api/stremio/addon_manager.py +335 -0
……@@ -0,0 +1,335 @@
1import json
2from typing import List, Optional, Any
3from urllib.parse import urlsplit, urlunsplit
4
5
6def normalize_transport_url(url: str) -> str:
7 if not url:
8 return ""
9
10 normalized_url = str(url).strip()
11 if normalized_url.startswith("stremio://"):
12 normalized_url = normalized_url.replace("stremio://", "https://", 1)
13
14 parts = urlsplit(normalized_url)
15 path = parts.path.rstrip("/")
16 if path.endswith("/manifest.json"):
17 path = path[: -len("/manifest.json")]
18
19 return urlunsplit(
20 (parts.scheme.lower(), parts.netloc.lower(), path, parts.query, "")
21 )
22
23
24def build_addon_base_url(transport_url: str) -> str:
25 return normalize_transport_url(transport_url)
26
27
28def build_addon_instance_key(addon_like: Any) -> str:
29 manifest = {}
30 addon_id = ""
31 transport_url = ""
32
33 if isinstance(addon_like, Addon):
34 addon_id = addon_like.manifest.id or ""
35 transport_url = addon_like.transport_url or ""
36 elif isinstance(addon_like, dict):
37 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 )
44 transport_url = addon_like.get("transportUrl") or addon_like.get("transport_url") or ""
45
46 normalized_url = normalize_transport_url(transport_url)
47 if addon_id and normalized_url:
48 return f"{addon_id}|{normalized_url}"
49 return addon_id or normalized_url
50
51
52def build_addon_instance_label(addon_like: Any) -> str:
53 if isinstance(addon_like, Addon):
54 name = addon_like.manifest.name or addon_like.manifest.id or "Unknown Addon"
55 transport_url = addon_like.transport_url
56 transport_name = addon_like.transport_name
57 else:
58 manifest = addon_like.get("manifest") or {}
59 name = (
60 manifest.get("name")
61 or manifest.get("id")
62 or addon_like.get("name")
63 or "Unknown Addon"
64 )
65 transport_url = addon_like.get("transportUrl") or addon_like.get("transport_url") or ""
66 transport_name = addon_like.get("transportName") or addon_like.get("transport_name") or ""
67
68 normalized_url = normalize_transport_url(transport_url)
69 parts = urlsplit(normalized_url)
70 host = parts.netloc or "local"
71
72 if transport_name == "custom":
73 origin = "custom"
74 elif transport_name:
75 origin = "account"
76 else:
77 origin = "addon"
78
79 return f"{name} ({host}, {origin})"
80
81
82class Catalog:
83 def __init__(self, type: str, id: str, name: str, extra: List[dict] = None):
84 self.type = type
85 self.id = id
86 self.name = name
87 self.extra = extra or []
88 self.genres = []
89 for item in self.extra:
90 if item.get("name") == "genre" and "options" in item:
91 self.genres = item["options"]
92
93
94class Resource:
95 def __init__(
96 self,
97 name: str,
98 types: List[str],
99 id_prefixes: Optional[List[str]] = None,
100 extra: Optional[List[dict]] = None,
101 ):
102 self.name = name
103 self.types = types
104 self.id_prefixes = id_prefixes or []
105 self.extra = extra or []
106
107
108class Config:
109 def __init__(
110 self,
111 key: str,
112 type: str,
113 default: Optional[str] = None,
114 title: Optional[str] = None,
115 options: Optional[List[str]] = None,
116 required: bool = False,
117 ):
118 self.key = key
119 self.type = type
120 self.default = default
121 self.title = title
122 self.options = options
123 self.required = required
124
125
126class Manifest:
127 def __init__(
128 self,
129 id: str,
130 version: str,
131 name: str,
132 description: str,
133 catalogs: List[dict],
134 resources: List[Resource],
135 types: List[str],
136 behavior_hints: dict,
137 addon_catalogs: Optional[List[dict]] = None,
138 config: Optional[List[dict]] = None,
139 contact_email: Optional[str] = None,
140 logo: Optional[str] = None,
141 background: Optional[str] = None,
142 ):
143 self.id = id
144 self.version = version
145 self.name = name
146 self.description = description
147 self.catalogs = [
148 Catalog(
149 type=cat["type"],
150 id=cat["id"],
151 name=cat.get("name"),
152 extra=cat.get("extra"),
153 )
154 for cat in catalogs
155 ]
156 self.addon_catalogs = [
157 Catalog(
158 type=cat["type"],
159 id=cat["id"],
160 name=cat.get("name"),
161 extra=cat.get("extra"),
162 )
163 for cat in (addon_catalogs or [])
164 ]
165 self.config = [
166 Config(
167 key=c["key"],
168 type=c["type"],
169 default=c.get("default"),
170 title=c.get("title"),
171 options=c.get("options"),
172 required=c.get("required", False),
173 )
174 for c in (config or [])
175 ]
176 self.resources = resources
177 self.types = types
178 self.behavior_hints = behavior_hints
179 self.contact_email = contact_email
180 self.logo = logo
181 self.background = background
182
183 def isConfigurationRequired(self):
184 return self.behavior_hints.get("configurationRequired", False)
185
186 def isConfigurable(self):
187 return self.behavior_hints.get("configurable", False) or bool(self.config)
188
189 def isAdult(self):
190 return self.behavior_hints.get("adult", False)
191
192 def isP2P(self):
193 return self.behavior_hints.get("p2p", False)
194
195
196class Addon:
197 def __init__(self, transport_url: str, transport_name: str, manifest: Manifest):
198 self.transport_url = transport_url
199 self.transport_name = transport_name
200 self.manifest = manifest
201
202 def url(self):
203 return build_addon_base_url(self.transport_url)
204
205 def key(self):
206 return build_addon_instance_key(self)
207
208 def label(self):
209 return build_addon_instance_label(self)
210
211 def isSupported(self, resource_name: str, type: str, id_prefix: str) -> bool:
212 norm_prefix = id_prefix.rstrip(":")
213 for resource in self.manifest.resources:
214 if resource.name == resource_name and type in resource.types:
215 for rp in resource.id_prefixes:
216 if rp.rstrip(":") == norm_prefix:
217 return True
218 return False
219
220
221class AddonManager:
222 def __init__(self, src):
223 if isinstance(src, str):
224 src = json.loads(src)
225
226 self.addons = self._parse_addons(src)
227
228 def _parse_addons(self, data: List[dict]) -> List[Addon]:
229 addons = []
230 for item in data:
231 resources = [
232 (
233 Resource(
234 name=resource,
235 types=item["manifest"].get("types", []),
236 id_prefixes=item["manifest"].get("idPrefixes", []),
237 )
238 if isinstance(resource, str)
239 else Resource(
240 name=(
241 resource["name"] if isinstance(resource, dict) else resource
242 ),
243 types=resource.get("types", item["manifest"].get("types", [])),
244 id_prefixes=resource.get(
245 "idPrefixes", item["manifest"].get("idPrefixes", [])
246 ),
247 )
248 )
249 for resource in item["manifest"].get("resources", [])
250 ]
251
252 manifest_data = item["manifest"]
253 transport_url = item.get("transportUrl") or ""
254
255 logo = manifest_data.get("logo")
256 background = manifest_data.get("background")
257
258 if transport_url:
259 from urllib.parse import urljoin
260
261 if logo and not logo.startswith(("http://", "https://")):
262 logo = urljoin(transport_url, logo)
263 if background and not background.startswith(("http://", "https://")):
264 background = urljoin(transport_url, background)
265
266 manifest = Manifest(
267 id=manifest_data.get("id") or manifest_data.get("name") or "unknown",
268 version=manifest_data.get("version", "0.0.1"),
269 name=manifest_data.get("name", "Unknown Addon"),
270 description=manifest_data.get("description", ""),
271 catalogs=manifest_data.get("catalogs", []),
272 addon_catalogs=manifest_data.get("addonCatalogs", []),
273 config=manifest_data.get("config", []),
274 resources=resources,
275 types=manifest_data.get("types", []),
276 behavior_hints=manifest_data.get("behaviorHints", {}),
277 contact_email=manifest_data.get("contactEmail"),
278 logo=logo,
279 background=background,
280 )
281
282 addons.append(
283 Addon(
284 transport_url=transport_url,
285 transport_name=item.get("transportName", ""),
286 manifest=manifest,
287 )
288 )
289 return addons
290
291 def get_addons_with_resource(
292 self,
293 resource_name: str,
294 ) -> List[Addon]:
295 result = []
296 for addon in self.addons:
297 if addon.manifest.id == "org.stremio.local":
298 continue
299 for resource in addon.manifest.resources:
300 if isinstance(resource, str):
301 if resource == resource_name:
302 result.append(addon)
303 break
304
305 if resource.name == resource_name:
306 result.append(addon)
307 break
308 return result
309
310 def get_addons_with_resource_and_id_prefix(
311 self, resource_name: str, id_prefix: str
312 ) -> List[Addon]:
313 result = []
314 for addon in self.addons:
315 if addon.manifest.id == "org.stremio.local":
316 continue
317 for resource in addon.manifest.resources:
318 if isinstance(resource, str):
319 if (
320 resource == resource_name
321 and id_prefix in addon.manifest.id_prefixes
322 ):
323 result.append(addon)
324 break
325
326 if resource.name == resource_name and id_prefix in resource.id_prefixes:
327 result.append(addon)
328 break
329 return result
330
331 def get_addon_by_url(self, url: str) -> Optional[Addon]:
332 for addon in self.addons:
333 if addon.transport_url == url:
334 return addon
335 return None
plugin.video.jacktook/lib/api/stremio/api_client.py +82 -0
……@@ -0,0 +1,82 @@
1from json import JSONDecodeError
2from requests.exceptions import RequestException, Timeout, TooManyRedirects
3from requests import Session
4from lib.utils.general.utils import USER_AGENT_HEADER
5from lib.utils.kodi.utils import kodilog
6from lib.utils.kodi.settings import get_int_setting
7
8
9class Stremio:
10 def __init__(self, authKey=None):
11 self.authKey = authKey
12 self.session = Session()
13 self.session.headers.update(USER_AGENT_HEADER)
14
15 def _request(self, method, url, data=None):
16 try:
17 if method == "GET":
18 resp = self.session.get(url, timeout=get_int_setting("stremio_timeout"))
19 elif method == "POST":
20 resp = self.session.post(
21 url, json=data, timeout=get_int_setting("stremio_timeout")
22 )
23 else:
24 raise ValueError(f"Unsupported HTTP method: {method}")
25
26 if resp.status_code != 200:
27 kodilog(
28 f"Status code {resp.status_code} received for URL: {url}. Response: {resp.text}"
29 )
30 resp.raise_for_status()
31
32 try:
33 return resp.json()
34 except JSONDecodeError:
35 kodilog(
36 f"Failed to decode JSON response for URL: {url}. Response: {resp.text}"
37 )
38 raise
39 except Timeout:
40 kodilog(f"Request timed out for URL: {url}")
41 raise
42 except TooManyRedirects:
43 kodilog(f"Too many redirects encountered for URL: {url}")
44 raise
45 except RequestException as e:
46 kodilog(f"Failed to fetch data from {url}: {e}")
47 raise
48
49 def _get(self, url):
50 return self._request("GET", url)
51
52 def _post(self, url, data):
53 return self._request("POST", url, data)
54
55 def login(self, email, password):
56 """Login to Stremio account."""
57
58 data = {
59 "authKey": self.authKey,
60 "email": email,
61 "password": password,
62 }
63
64 res = self._post("https://api.strem.io/api/login", data)
65 self.authKey = res.get("result", {}).get("authKey", None)
66
67 def dataExport(self):
68 """Export user data."""
69 assert self.authKey, "Login first"
70 data = {"authKey": self.authKey}
71 res = self._post("https://api.strem.io/api/dataExport", data)
72 exportId = res.get("result", {}).get("exportId", None)
73
74 dataExport = self._get(
75 f"https://api.strem.io/data-export/{exportId}/export.json"
76 )
77 return dataExport
78
79 def get_my_addons(self):
80 """Get user addons."""
81 response = self.dataExport()
82 return response.get("addons", {}).get("addons", [])
plugin.video.jacktook/lib/api/stremio/models.py +271 -0
……@@ -0,0 +1,271 @@
1from typing import List, Optional, Any, Dict, Union
2from dataclasses import dataclass, field
3
4@dataclass
5class MetaLink:
6 name: str
7 category: str
8 url: str
9
10 @classmethod
11 def from_dict(cls, data: Dict[str, Any]) -> 'MetaLink':
12 return cls(
13 name=data.get("name", ""),
14 category=data.get("category", ""),
15 url=data.get("url", "")
16 )
17
18def extract_proxy_headers(raw: Any) -> Dict[str, str]:
19 """Normalise a stream's proxyHeaders into flat request headers.
20
21 Stremio nests them as proxyHeaders.request; some addons flatten them
22 straight onto proxyHeaders, so accept both shapes. Accepts the raw value
23 from either a parsed Stream or a plain dict off the wire.
24 """
25 if not isinstance(raw, dict) or not raw:
26 return {}
27 request = raw.get("request", raw)
28 if not isinstance(request, dict):
29 return {}
30 return {str(k): str(v) for k, v in request.items() if k and v}
31
32
33@dataclass
34class StreamBehaviorHints:
35 countryWhitelist: List[str] = field(default_factory=list)
36 notWebReady: bool = False
37 bingeGroup: Optional[str] = None
38 proxyHeaders: Dict[str, str] = field(default_factory=dict)
39 videoHash: Optional[str] = None
40 videoSize: Optional[int] = None
41 filename: Optional[str] = None
42
43 @classmethod
44 def from_dict(cls, data: Dict[str, Any]) -> 'StreamBehaviorHints':
45 return cls(
46 countryWhitelist=data.get("countryWhitelist", []),
47 notWebReady=data.get("notWebReady", False),
48 bingeGroup=data.get("bingeGroup"),
49 proxyHeaders=data.get("proxyHeaders", {}),
50 videoHash=data.get("videoHash"),
51 videoSize=data.get("videoSize"),
52 filename=data.get("filename")
53 )
54
55@dataclass
56class Stream:
57 url: Optional[str] = None
58 ytId: Optional[str] = None
59 infoHash: Optional[str] = None
60 fileIdx: Optional[int] = None
61 externalUrl: Optional[str] = None
62 name: Optional[str] = None
63 title: Optional[str] = None
64 description: Optional[str] = None
65 behaviorHints: Optional[StreamBehaviorHints] = None
66
67 # Custom/Extended fields
68 fileMustInclude: Optional[List[str]] = None
69 nzbUrl: Optional[str] = None
70 servers: List[str] = field(default_factory=list)
71 rarUrls: List[str] = field(default_factory=list)
72 zipUrls: List[str] = field(default_factory=list)
73 sevenZipUrls: List[str] = field(default_factory=list)
74 tgzUrls: List[str] = field(default_factory=list)
75 tarUrls: List[str] = field(default_factory=list)
76
77 meta: Dict[str, Any] = field(default_factory=dict)
78
79 @classmethod
80 def from_dict(cls, data: Dict[str, Any]) -> 'Stream':
81 hints = data.get("behaviorHints")
82 return cls(
83 url=data.get("url"),
84 ytId=data.get("ytId"),
85 infoHash=data.get("infoHash"),
86 fileIdx=data.get("fileIdx"),
87 externalUrl=data.get("externalUrl"),
88 name=data.get("name"),
89 title=data.get("title"),
90 description=data.get("description"),
91 behaviorHints=StreamBehaviorHints.from_dict(hints) if hints else None,
92 fileMustInclude=data.get("fileMustInclude"),
93 nzbUrl=data.get("nzbUrl"),
94 servers=data.get("servers", []),
95 rarUrls=data.get("rarUrls", []),
96 zipUrls=data.get("zipUrls", []),
97 sevenZipUrls=data.get("7zipUrls", []),
98 tgzUrls=data.get("tgzUrls", []),
99 tarUrls=data.get("tarUrls", []),
100 meta=data.get("meta", {})
101 )
102
103 def get_parsed_title(self) -> str:
104 filename = self.behaviorHints.filename if self.behaviorHints else None
105 title = filename or self.description or self.title
106 return title.splitlines()[0] if title else ""
107
108 def get_sub_indexer(self, addon: Any) -> str:
109 if not self.name:
110 return ""
111
112 name_parts = self.name.split()
113 if len(name_parts) > 1:
114 return name_parts[1]
115
116 return ""
117
118 def get_proxy_headers(self) -> Dict[str, str]:
119 """Request headers this stream needs at playback time."""
120 return extract_proxy_headers(
121 self.behaviorHints.proxyHeaders if self.behaviorHints else None
122 )
123
124 def get_parsed_size(self) -> int:
125 size = self.behaviorHints.videoSize if self.behaviorHints else None
126 return size or self.meta.get("size") or 0
127
128 def get_provider(self) -> str:
129 return self.meta.get("indexer") or ""
130
131@dataclass
132class Video:
133 id: str
134 title: str
135 released: str
136 thumbnail: Optional[str] = None
137 streams: List[Stream] = field(default_factory=list)
138 available: bool = False
139 episode: Optional[int] = None
140 season: Optional[int] = None
141 trailers: List[Stream] = field(default_factory=list)
142 overview: Optional[str] = None
143 imdbSeason: Optional[Union[int, str]] = None
144 imdbEpisode: Optional[Union[int, str]] = None
145
146 @classmethod
147 def from_dict(cls, data: Dict[str, Any]) -> 'Video':
148 return cls(
149 id=data.get("id", ""),
150 title=data.get("title", ""),
151 released=data.get("released", ""),
152 thumbnail=data.get("thumbnail"),
153 streams=[Stream.from_dict(s) for s in data.get("streams", [])],
154 available=data.get("available", False),
155 episode=data.get("episode"),
156 season=data.get("season"),
157 trailers=[Stream.from_dict(t) for t in data.get("trailers", [])],
158 overview=data.get("overview"),
159 imdbSeason=data.get("imdbSeason"),
160 imdbEpisode=data.get("imdbEpisode")
161 )
162
163@dataclass
164class MetaBehaviorHints:
165 defaultVideoId: Optional[str] = None
166
167 @classmethod
168 def from_dict(cls, data: Dict[str, Any]) -> 'MetaBehaviorHints':
169 return cls(defaultVideoId=data.get("defaultVideoId"))
170
171@dataclass
172class Meta:
173 id: str
174 type: str
175 name: str
176 poster: Optional[str] = None
177 posterShape: Optional[str] = None
178 background: Optional[str] = None
179 logo: Optional[str] = None
180 description: Optional[str] = None
181 releaseInfo: Optional[str] = None
182 director: List[str] = field(default_factory=list)
183 cast: List[str] = field(default_factory=list)
184 imdbRating: Optional[str] = None
185 released: Optional[str] = None
186 trailers: List[Dict[str, str]] = field(default_factory=list) # simplified for now
187 links: List[MetaLink] = field(default_factory=list)
188 videos: List[Video] = field(default_factory=list)
189 runtime: Optional[str] = None
190 language: Optional[str] = None
191 country: Optional[str] = None
192 awards: Optional[str] = None
193 website: Optional[str] = None
194 behaviorHints: Optional[MetaBehaviorHints] = None
195 moviedb_id: Optional[str] = None
196 imdb_id: Optional[str] = None
197 genres: List[str] = field(default_factory=list)
198
199 @classmethod
200 def from_dict(cls, data: Dict[str, Any]) -> 'Meta':
201 hints = data.get("behaviorHints")
202 return cls(
203 id=data.get("id", ""),
204 type=data.get("type", ""),
205 name=data.get("name", ""),
206 poster=data.get("poster"),
207 posterShape=data.get("posterShape"),
208 background=data.get("background"),
209 logo=data.get("logo"),
210 description=data.get("description"),
211 releaseInfo=data.get("releaseInfo"),
212 director=data.get("director", []),
213 cast=data.get("cast", []),
214 imdbRating=data.get("imdbRating"),
215 released=data.get("released"),
216 trailers=data.get("trailers", []),
217 links=[MetaLink.from_dict(l) for l in data.get("links", [])],
218 videos=[Video.from_dict(v) for v in data.get("videos", [])],
219 runtime=data.get("runtime"),
220 language=data.get("language"),
221 country=data.get("country"),
222 awards=data.get("awards"),
223 website=data.get("website"),
224 behaviorHints=MetaBehaviorHints.from_dict(hints) if hints else None,
225 moviedb_id=data.get("moviedb_id"),
226 imdb_id=data.get("imdb_id"),
227 genres=data.get("genres", [])
228 )
229
230
231@dataclass
232class MetaPreview:
233 id: str
234 type: str
235 name: str
236 poster: str
237 posterShape: Optional[str] = None
238 background: Optional[str] = None
239 genres: List[str] = field(default_factory=list)
240 imdbRating: Optional[str] = None
241 releaseInfo: Optional[str] = None
242 director: List[str] = field(default_factory=list)
243 cast: List[str] = field(default_factory=list)
244 links: List[MetaLink] = field(default_factory=list)
245 description: Optional[str] = None
246 trailers: List[Dict[str, str]] = field(default_factory=list)
247 moviedb_id: Optional[str] = None
248 imdb_id: Optional[str] = None
249 streams: List[Stream] = field(default_factory=list)
250
251 @classmethod
252 def from_dict(cls, data: Dict[str, Any]) -> 'MetaPreview':
253 return cls(
254 id=data.get("id", ""),
255 type=data.get("type", ""),
256 name=data.get("name", ""),
257 poster=data.get("poster", ""),
258 posterShape=data.get("posterShape"),
259 background=data.get("background"),
260 genres=data.get("genres", []),
261 imdbRating=data.get("imdbRating"),
262 releaseInfo=data.get("releaseInfo"),
263 director=data.get("director", []),
264 cast=data.get("cast", []),
265 links=[MetaLink.from_dict(l) for l in data.get("links", [])],
266 description=data.get("description"),
267 trailers=data.get("trailers", []),
268 moviedb_id=data.get("moviedb_id"),
269 imdb_id=data.get("imdb_id"),
270 streams=[Stream.from_dict(s) for s in data.get("streams", [])]
271 )
plugin.video.jacktook/lib/api/tmdbv3api/__init__.py +25 -0
……@@ -0,0 +1,25 @@
1from .objs.account import Account
2from .objs.auth import Authentication
3from .objs.certification import Certification
4from .objs.change import Change
5from .objs.collection import Collection
6from .objs.company import Company
7from .objs.configuration import Configuration
8from .objs.credit import Credit
9from .objs.discover import Discover
10from .objs.episode import Episode
11from .objs.find import Find
12from .objs.genre import Genre
13from .objs.group import Group
14from .objs.keyword import Keyword
15from .objs.list import List
16from .objs.movie import Movie
17from .objs.network import Network
18from .objs.person import Person
19from .objs.provider import Provider
20from .objs.review import Review
21from .objs.search import Search
22from .objs.season import Season
23from .objs.trending import Trending
24from .objs.tv import TV
25from .tmdb import TMDb
plugin.video.jacktook/lib/api/tmdbv3api/as_obj.py +132 -0
……@@ -0,0 +1,132 @@
1# encoding: utf-8
2import sys
3from .exceptions import TMDbException
4
5
6class AsObj:
7 def __init__(self, json=None, key=None, dict_key=False, dict_key_name=None):
8 self._json = json if json else {}
9 self._key = key
10 self._dict_key = dict_key
11 self._dict_key_name = dict_key_name
12 self._obj_list = []
13 self._list_only = False
14 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 ]
18 self._list_only = True
19 elif dict_key:
20 self._obj_list = [
21 (
22 AsObj({k: v}, key=k, dict_key_name=dict_key_name)
23 if isinstance(v, (dict, list))
24 else v
25 )
26 for k, v in self._json.items()
27 ]
28 self._list_only = True
29 else:
30 for key, value in self._json.items():
31 if isinstance(value, (dict, list)):
32 if self._key and key == self._key:
33 final = AsObj(
34 value, dict_key=isinstance(value, dict), dict_key_name=key
35 )
36 self._obj_list = final
37 else:
38 final = AsObj(value)
39 else:
40 final = value
41 if dict_key_name:
42 setattr(self, dict_key_name, key)
43 setattr(self, key, final)
44
45 def _dict(self):
46 return {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
47
48 def __delitem__(self, key):
49 return delattr(self, key)
50
51 def __getitem__(self, key):
52 if isinstance(key, (int, slice)):
53 return self._obj_list[key]
54 else:
55 return getattr(self, key)
56
57 def __iter__(self):
58 return (o for o in self._obj_list) if self._obj_list else iter(self._dict())
59
60 def __len__(self):
61 return len(self._obj_list) if self._obj_list else len(self._dict())
62
63 def __repr__(self):
64 return str(self._obj_list) if self._list_only else str(self._dict())
65
66 def __setitem__(self, key, value):
67 return setattr(self, key, value)
68
69 def __str__(self):
70 return str(self._obj_list) if self._list_only else str(self._dict())
71
72 if sys.version_info >= (3, 8):
73
74 def __reversed__(self):
75 return reversed(self._dict())
76
77 if sys.version_info >= (3, 9):
78
79 def __class_getitem__(self, key):
80 return self.__dict__.__class_getitem__(key)
81
82 def __ior__(self, value):
83 return self._dict().__ior__(value)
84
85 def __or__(self, value):
86 return self._dict().__or__(value)
87
88 def copy(self):
89 return AsObj(
90 self._json.copy(),
91 key=self._key,
92 dict_key=self._dict_key,
93 dict_key_name=self._dict_key_name,
94 )
95
96 def get(self, key, value=None):
97 return self._dict().get(key, value)
98
99 def items(self):
100 return self._dict().items()
101
102 def keys(self):
103 return self._dict().keys()
104
105 def pop(self, key, value=None):
106 return self.__dict__.pop(key, value)
107
108 def popitem(self):
109 return self.__dict__.popitem()
110
111 def setdefault(self, key, value=None):
112 return self.__dict__.setdefault(key, value)
113
114 def update(self, entries):
115 return self.__dict__.update(entries)
116
117 def values(self):
118 return self._dict().values()
119
120 def to_dict(self):
121 if self._list_only:
122 return [o.to_dict() if isinstance(o, AsObj) else o for o in self._obj_list]
123 else:
124 result = {}
125 for k, v in self._dict().items():
126 if isinstance(v, AsObj):
127 result[k] = v.to_dict()
128 elif isinstance(v, list):
129 result[k] = [i.to_dict() if isinstance(i, AsObj) else i for i in v]
130 else:
131 result[k] = v
132 return result
plugin.video.jacktook/lib/api/tmdbv3api/exceptions.py +2 -0
……@@ -0,0 +1,2 @@
1class TMDbException(Exception):
2 pass
plugin.video.jacktook/lib/api/tmdbv3api/objs/__init__.py +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/lib/api/tmdbv3api/objs/account.py +177 -0
……@@ -0,0 +1,177 @@
1import os
2from lib.api.tmdbv3api.exceptions import TMDbException
3from lib.api.tmdbv3api.tmdb import TMDb
4
5
6
7class Account(TMDb):
8 _urls = {
9 "details": "/account",
10 "created_lists": "/account/%s/lists",
11 "favorite_movies": "/account/%s/favorite/movies",
12 "favorite_tv": "/account/%s/favorite/tv",
13 "favorite": "/account/%s/favorite",
14 "rated_movies": "/account/%s/rated/movies",
15 "rated_tv": "/account/%s/rated/tv",
16 "rated_episodes": "/account/%s/rated/tv/episodes",
17 "movie_watchlist": "/account/%s/watchlist/movies",
18 "tv_watchlist": "/account/%s/watchlist/tv",
19 "watchlist": "/account/%s/watchlist",
20 }
21
22 @property
23 def account_id(self):
24 if not os.environ.get("TMDB_ACCOUNT_ID"):
25 os.environ["TMDB_ACCOUNT_ID"] = str(self.details()["id"])
26 return os.environ.get("TMDB_ACCOUNT_ID")
27
28 def details(self):
29 """
30 Get your account details.
31 :return:
32 """
33 return self._request_obj(
34 self._urls["details"],
35 params="session_id=%s" % self.session_id
36 )
37
38 def created_lists(self, page=1):
39 """
40 Get all of the lists created by an account. Will include private lists if you are the owner.
41 :param page: int
42 :return:
43 """
44 return self._request_obj(
45 self._urls["created_lists"] % self.account_id,
46 params="session_id=%s&page=%s" % (self.session_id, page),
47 key="results"
48 )
49
50 def _get_list(self, url, asc_sort=True, page=1):
51 params = "session_id=%s&page=%s" % (self.session_id, page)
52 if asc_sort is False:
53 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 )
59
60 def favorite_movies(self, asc_sort=True, page=1):
61 """
62 Get the list of your favorite movies.
63 :param asc_sort: bool
64 :param page: int
65 :return:
66 """
67 return self._get_list("favorite_movies", asc_sort=asc_sort, page=page)
68
69 def favorite_tv_shows(self, asc_sort=True, page=1):
70 """
71 Get the list of your favorite TV shows.
72 :param asc_sort: bool
73 :param page: int
74 :return:
75 """
76 return self._get_list("favorite_tv", asc_sort=asc_sort, page=page)
77
78 def mark_as_favorite(self, media_id, media_type, favorite=True):
79 """
80 This method allows you to mark a movie or TV show as a favorite item.
81 :param media_id: int
82 :param media_type: str
83 :param favorite:bool
84 """
85 if media_type not in ["tv", "movie"]:
86 raise TMDbException("Media Type should be tv or movie.")
87 self._request_obj(
88 self._urls["favorite"] % self.account_id,
89 params="session_id=%s" % self.session_id,
90 method="POST",
91 json={
92 "media_type": media_type,
93 "media_id": media_id,
94 "favorite": favorite,
95 }
96 )
97
98 def unmark_as_favorite(self, media_id, media_type):
99 """
100 This method allows you to unmark a movie or TV show as a favorite item.
101 :param media_id: int
102 :param media_type: str
103 """
104 self.mark_as_favorite(media_id, media_type, favorite=False)
105
106 def rated_movies(self, asc_sort=True, page=1):
107 """
108 Get a list of all the movies you have rated.
109 :param asc_sort: bool
110 :param page: int
111 :return:
112 """
113 return self._get_list("rated_movies", asc_sort=asc_sort, page=page)
114
115 def rated_tv_shows(self, asc_sort=True, page=1):
116 """
117 Get a list of all the TV shows you have rated.
118 :param asc_sort: bool
119 :param page: int
120 :return:
121 """
122 return self._get_list("rated_tv", asc_sort=asc_sort, page=page)
123
124 def rated_episodes(self, asc_sort=True, page=1):
125 """
126 Get a list of all the TV episodes you have rated.
127 :param asc_sort: bool
128 :param page: int
129 :return:
130 """
131 return self._get_list("rated_episodes", asc_sort=asc_sort, page=page)
132
133 def movie_watchlist(self, asc_sort=True, page=1):
134 """
135 Get a list of all the movies you have added to your watchlist.
136 :param asc_sort: bool
137 :param page: int
138 :return:
139 """
140 return self._get_list("movie_watchlist", asc_sort=asc_sort, page=page)
141
142 def tv_show_watchlist(self, asc_sort=True, page=1):
143 """
144 Get a list of all the TV shows you have added to your watchlist.
145 :param asc_sort: bool
146 :param page: int
147 :return:
148 """
149 return self._get_list("tv_watchlist", asc_sort=asc_sort, page=page)
150
151 def add_to_watchlist(self, media_id, media_type, watchlist=True):
152 """
153 Add a movie or TV show to your watchlist.
154 :param media_id: int
155 :param media_type: str
156 :param watchlist: bool
157 """
158 if media_type not in ["tv", "movie"]:
159 raise TMDbException("Media Type should be tv or movie.")
160 self._request_obj(
161 self._urls["watchlist"] % self.account_id,
162 "session_id=%s" % self.session_id,
163 method="POST",
164 json={
165 "media_type": media_type,
166 "media_id": media_id,
167 "watchlist": watchlist,
168 }
169 )
170
171 def remove_from_watchlist(self, media_id, media_type):
172 """
173 Remove a movie or TV show from your watchlist.
174 :param media_id: int
175 :param media_type: str
176 """
177 self.add_to_watchlist(media_id, media_type, watchlist=False)
plugin.video.jacktook/lib/api/tmdbv3api/objs/anime.py +126 -0
……@@ -0,0 +1,126 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2from lib.api.tmdbv3api.utils import get_current_date, get_dates, years_tvshows
3
4
5class TmdbAnime(TMDb):
6 _urls = {
7 "discover_tv": "/discover/tv",
8 "discover_movie": "/discover/movie",
9 "search_tv": "/search/tv",
10 "search_movie": "/search/movie",
11 "tv_keywords": "/tv/%s/keywords",
12 "movie_keywords": "/movie/%s/keywords",
13 }
14
15 def cartoons_popular(self, mode, page_no):
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,
23 )
24
25 def animation_popular(self, mode, page_no):
26 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,
33 )
34
35 def anime_popular(self, mode, page_no):
36 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,
43 )
44
45 def anime_popular_recent(self, mode, page_no):
46 return self._request_obj(
47 (
48 self._urls["discover_tv"]
49 if mode == "tv"
50 else self._urls["discover_movie"]
51 ),
52 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)
55 ),
56 )
57
58 def anime_year(self, params):
59 year_param = (
60 "first_air_date_year" if params["mode"] == "tv" else "primary_release_year"
61 )
62 return self._request_obj(
63 (
64 self._urls["discover_tv"]
65 if params["mode"] == "tv"
66 else self._urls["discover_movie"]
67 ),
68 params="with_keywords=210024&include_null_first_air_dates=false&%s=%s&page=%s"
69 % (year_param, params["year"], params["page"]),
70 )
71
72 def anime_genres(self, params):
73 return self._request_obj(
74 (
75 self._urls["discover_tv"]
76 if params["mode"] == "tv"
77 else self._urls["discover_movie"]
78 ),
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 )
82
83 def anime_on_the_air(self, mode, page_no):
84 current_date, future_date = get_dates(7, reverse=False)
85 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),
93 )
94
95 def anime_top_rated(self, mode, page_no):
96 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,
104 )
105
106 def anime_search(self, query, mode, page_no, adult=False):
107 params = "query=%s&page=%s" % (query, page_no)
108
109 if adult is not None:
110 params += "&include_adult=%s" % "true" if adult else "false"
111
112 return self._request_obj(
113 self._urls["search_tv"] if mode == "tv" else self._urls["search_movie"],
114 params=params,
115 key="results",
116 )
117
118 def tmdb_keywords(self, mode, tmdb_id):
119 return self._request_obj(
120 (
121 self._urls["tv_keywords"]
122 if mode == "tv"
123 else self._urls["movie_keywords"]
124 )
125 % tmdb_id,
126 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/auth.py +63 -0
……@@ -0,0 +1,63 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3class Authentication(TMDb):
4 _urls = {
5 "create_request_token": "/authentication/token/new",
6 "validate_with_login": "/authentication/token/validate_with_login",
7 "create_session": "/authentication/session/new",
8 "delete_session": "/authentication/session",
9 }
10
11 def __init__(self, username, password):
12 super().__init__()
13 self.username = username
14 self.password = password
15 self.expires_at = None
16 self.request_token = self._create_request_token()
17 self._authorise_request_token_with_login()
18 self._create_session()
19
20 def _create_request_token(self):
21 """
22 Create a temporary request token that can be used to validate a TMDb user login.
23 """
24 response = self._request_obj(self._urls["create_request_token"])
25 self.expires_at = response.expires_at
26 return response.request_token
27
28 def _create_session(self):
29 """
30 You can use this method to create a fully valid session ID once a user has validated the request token.
31 """
32 response = self._request_obj(
33 self._urls["create_session"],
34 method="POST",
35 json={"request_token": self.request_token}
36 )
37 self.session_id = response.session_id
38
39 def _authorise_request_token_with_login(self):
40 """
41 This method allows an application to validate a request token by entering a username and password.
42 """
43 self._request_obj(
44 self._urls["validate_with_login"],
45 method="POST",
46 json={
47 "username": self.username,
48 "password": self.password,
49 "request_token": self.request_token,
50 }
51 )
52
53 def delete_session(self):
54 """
55 If you would like to delete (or "logout") from a session, call this method with a valid session ID.
56 """
57 if self.has_session:
58 self._request_obj(
59 self._urls["delete_session"],
60 method="DELETE",
61 json={"session_id": self.session_id}
62 )
63 self.session_id = ""
plugin.video.jacktook/lib/api/tmdbv3api/objs/certification.py +21 -0
……@@ -0,0 +1,21 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3class Certification(TMDb):
4 _urls = {
5 "movie_list": "/certification/movie/list",
6 "tv_list": "/certification/tv/list",
7 }
8
9 def movie_list(self):
10 """
11 Get an up to date list of the officially supported movie certifications on TMDB.
12 :return:
13 """
14 return self._request_obj(self._urls["movie_list"], key="certifications")
15
16 def tv_list(self):
17 """
18 Get an up to date list of the officially supported TV show certifications on TMDB.
19 :return:
20 """
21 return self._request_obj(self._urls["tv_list"], key="certifications")
plugin.video.jacktook/lib/api/tmdbv3api/objs/change.py +54 -0
……@@ -0,0 +1,54 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3
4class Change(TMDb):
5 _urls = {
6 "movie": "/movie/changes",
7 "tv": "/tv/changes",
8 "person": "/person/changes"
9 }
10
11 def _change_list(self, change_type, start_date="", end_date="", page=1):
12 params = "page=%s" % page
13 if start_date:
14 params += "&start_date=%s" % start_date
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 )
22
23 def movie_change_list(self, start_date="", end_date="", page=1):
24 """
25 Get the changes for a movie. By default only the last 24 hours are returned.
26 You can query up to 14 days in a single query by using the start_date and end_date query parameters.
27 :param start_date: str
28 :param end_date: str
29 :param page: int
30 :return:
31 """
32 return self._change_list("movie", start_date=start_date, end_date=end_date, page=page)
33
34 def tv_change_list(self, start_date="", end_date="", page=1):
35 """
36 Get a list of all of the TV show ids that have been changed in the past 24 hours.
37 You can query up to 14 days in a single query by using the start_date and end_date query parameters.
38 :param start_date: str
39 :param end_date: str
40 :param page: int
41 :return:
42 """
43 return self._change_list("tv", start_date=start_date, end_date=end_date, page=page)
44
45 def person_change_list(self, start_date="", end_date="", page=1):
46 """
47 Get a list of all of the person ids that have been changed in the past 24 hours.
48 You can query up to 14 days in a single query by using the start_date and end_date query parameters.
49 :param start_date: str
50 :param end_date: str
51 :param page: int
52 :return:
53 """
54 return self._change_list("person", start_date=start_date, end_date=end_date, page=page)
plugin.video.jacktook/lib/api/tmdbv3api/objs/collection.py +33 -0
……@@ -0,0 +1,33 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3
4class Collection(TMDb):
5 _urls = {
6 "details": "/collection/%s",
7 "images": "/collection/%s/images",
8 "translations": "/collection/%s/translations"
9 }
10
11 def details(self, collection_id):
12 """
13 Get collection details by id.
14 :param collection_id: int
15 :return:
16 """
17 return self._request_obj(self._urls["details"] % collection_id, key="parts")
18
19 def images(self, collection_id):
20 """
21 Get the images for a collection by id.
22 :param collection_id: int
23 :return:
24 """
25 return self._request_obj(self._urls["images"] % collection_id, add_lang=False)
26
27 def translations(self, collection_id):
28 """
29 Get the list translations for a collection by id.
30 :param collection_id: int
31 :return:
32 """
33 return self._request_obj(self._urls["translations"] % collection_id, key="translations")
plugin.video.jacktook/lib/api/tmdbv3api/objs/company.py +47 -0
……@@ -0,0 +1,47 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3
4class Company(TMDb):
5 _urls = {
6 "details": "/company/%s",
7 "alternative_names": "/company/%s/alternative_names",
8 "images": "/company/%s/images",
9 "movies": "/company/%s/movies"
10 }
11
12 def details(self, company_id):
13 """
14 Get a companies details by id.
15 :param company_id: int
16 :return:
17 """
18 return self._request_obj(self._urls["details"] % company_id)
19
20 def alternative_names(self, company_id):
21 """
22 Get the alternative names of a company.
23 :param company_id: int
24 :return:
25 """
26 return self._request_obj(self._urls["alternative_names"] % company_id, key="results")
27
28 def images(self, company_id):
29 """
30 Get the alternative names of a company.
31 :param company_id: int
32 :return:
33 """
34 return self._request_obj(self._urls["images"] % company_id, key="logos")
35
36 def movies(self, company_id, page=1):
37 """
38 Get the movies of a company by id.
39 :param company_id: int
40 :param page: int
41 :return:
42 """
43 return self._request_obj(
44 self._urls["movies"] % company_id,
45 params="page=%s" % page,
46 key="results"
47 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/configuration.py +54 -0
……@@ -0,0 +1,54 @@
1import warnings
2from lib.api.tmdbv3api.tmdb import TMDb
3
4
5class Configuration(TMDb):
6 _urls = {
7 "api_configuration": "/configuration",
8 "countries": "/configuration/countries",
9 "jobs": "/configuration/jobs",
10 "languages": "/configuration/languages",
11 "primary_translations": "/configuration/primary_translations",
12 "timezones": "/configuration/timezones"
13 }
14
15 def info(self):
16 warnings.warn("info method is deprecated use tmdbv3api.Configuration().api_configuration()",
17 DeprecationWarning)
18 return self.api_configuration()
19
20 def api_configuration(self):
21 """
22 Get the system wide configuration info.
23 """
24 return self._request_obj(self._urls["api_configuration"])
25
26 def countries(self):
27 """
28 Get the list of countries (ISO 3166-1 tags) used throughout TMDb.
29 """
30 return self._request_obj(self._urls["countries"])
31
32 def jobs(self):
33 """
34 Get a list of the jobs and departments we use on TMDb.
35 """
36 return self._request_obj(self._urls["jobs"])
37
38 def languages(self):
39 """
40 Get the list of languages (ISO 639-1 tags) used throughout TMDb.
41 """
42 return self._request_obj(self._urls["languages"])
43
44 def primary_translations(self):
45 """
46 Get a list of the officially supported translations on TMDb.
47 """
48 return self._request_obj(self._urls["primary_translations"])
49
50 def timezones(self):
51 """
52 Get the list of timezones used throughout TMDb.
53 """
54 return self._request_obj(self._urls["timezones"])
plugin.video.jacktook/lib/api/tmdbv3api/objs/credit.py +14 -0
……@@ -0,0 +1,14 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3class Credit(TMDb):
4 _urls = {
5 "details": "/credit/%s"
6 }
7
8 def details(self, credit_id):
9 """
10 Get a movie or TV credit details by id.
11 :param credit_id: int
12 :return:
13 """
14 return self._request_obj(self._urls["details"] % credit_id)
plugin.video.jacktook/lib/api/tmdbv3api/objs/discover.py +45 -0
……@@ -0,0 +1,45 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2from lib.api.tmdbv3api.utils import get_dates
3
4try:
5 from urllib import urlencode
6except ImportError:
7 from urllib.parse import urlencode
8
9
10class Discover(TMDb):
11 _urls = {"movies": "/discover/movie", "tv": "/discover/tv"}
12
13 def discover_movies(self, params):
14 """
15 Discover movies by different types of data like average rating, number of votes, genres and certifications.
16 :param params: dict
17 :return:
18 """
19 return self._request_obj(self._urls["movies"], urlencode(params), key="results")
20
21 def discover_tv_shows(self, params):
22 """
23 Discover TV shows by different types of data like average rating, number of votes, genres,
24 the network they aired on and air dates.
25 :param params: dict
26 :return:
27 """
28 return self._request_obj(self._urls["tv"], urlencode(params), key="results")
29
30 def discover_tv_calendar(self, page):
31 # TMDb network IDs for major streaming platforms
32 # Netflix: 213, Amazon: 1024, Disney+: 2739, Hulu: 453, Apple TV+: 2552, HBO Max: 3186
33 network_ids = "213,1024,2739,453,2552,3186"
34 current_date, future_date = get_dates(7, reverse=False)
35 params = (
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)
41 )
42 return self._request_obj(
43 self._urls["tv"],
44 params=params,
45 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/episode.py +157 -0
……@@ -0,0 +1,157 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3
4class Episode(TMDb):
5 _urls = {
6 "details": "/tv/%s/season/%s/episode/%s",
7 "account_states": "/tv/%s/season/%s/episode/%s/account_states",
8 "changes": "/tv/episode/%s/changes",
9 "credits": "/tv/%s/season/%s/episode/%s/credits",
10 "external_ids": "/tv/%s/season/%s/episode/%s/external_ids",
11 "images": "/tv/%s/season/%s/episode/%s/images",
12 "translations": "/tv/%s/season/%s/episode/%s/translations",
13 "rate_tv_episode": "/tv/%s/season/%s/episode/%s/rating",
14 "delete_rating": "/tv/%s/season/%s/episode/%s/rating",
15 "videos": "/tv/%s/season/%s/episode/%s/videos",
16 }
17
18 def details(self, tv_id, season_num, episode_num, append_to_response="trailers,images,casts,translations"):
19 """
20 Get the TV episode details by id.
21 :param tv_id: int
22 :param season_num: int
23 :param episode_num: int
24 :param append_to_response: str
25 :return:
26 """
27 return self._request_obj(
28 self._urls["details"] % (tv_id, season_num, episode_num),
29 params="append_to_response=%s" % append_to_response
30 )
31
32 def account_states(self, tv_id, season_num, episode_num):
33 """
34 Get your rating for a episode.
35 :param tv_id: int
36 :param season_num: int
37 :param episode_num: int
38 :return:
39 """
40 return self._request_obj(
41 self._urls["account_states"] % (tv_id, season_num, episode_num),
42 params="session_id=%s" % self.session_id
43 )
44
45 def changes(self, episode_id, start_date=None, end_date=None, page=1):
46 """
47 Get the changes for a TV episode. By default only the last 24 hours are returned.
48 You can query up to 14 days in a single query by using the start_date and end_date query parameters.
49 :param episode_id: int
50 :param start_date: str
51 :param end_date: str
52 :param page: int
53 :return:
54 """
55 params = "page=%s" % page
56 if start_date:
57 params += "&start_date=%s" % start_date
58 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
66 def credits(self, tv_id, season_num, episode_num):
67 """
68 Get the credits for TV season.
69 :param tv_id: int
70 :param season_num: int
71 :param episode_num: int
72 :return:
73 """
74 return self._request_obj(self._urls["credits"] % (tv_id, season_num, episode_num))
75
76 def external_ids(self, tv_id, season_num, episode_num):
77 """
78 Get the external ids for a TV episode.
79 :param tv_id: int
80 :param season_num: int
81 :param episode_num: int
82 :return:
83 """
84 return self._request_obj(self._urls["external_ids"] % (tv_id, season_num, episode_num))
85
86 def images(self, tv_id, season_num, episode_num, include_image_language=None):
87 """
88 Get the images that belong to a TV episode.
89 :param tv_id: int
90 :param season_num: int
91 :param episode_num: int
92 :param include_image_language: str
93 :return:
94 """
95 return self._request_obj(
96 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 )
100
101 def translations(self, tv_id, season_num, episode_num):
102 """
103 Get the translation data for an episode.
104 :param tv_id: int
105 :param season_num: int
106 :param episode_num: int
107 :return:
108 """
109 return self._request_obj(
110 self._urls["translations"] % (tv_id, season_num, episode_num),
111 key="translations"
112 )
113
114 def rate_tv_episode(self, tv_id, season_num, episode_num, rating):
115 """
116 Rate a TV episode.
117 :param tv_id: int
118 :param season_num: int
119 :param episode_num: int
120 :param rating: float
121 """
122 self._request_obj(
123 self._urls["rate_tv_episode"] % (tv_id, season_num, episode_num),
124 params="session_id=%s" % self.session_id,
125 method="POST",
126 json={"value": rating}
127 )
128
129 def delete_rating(self, tv_id, season_num, episode_num):
130 """
131 Remove your rating for a TV episode.
132 :param tv_id: int
133 :param season_num: int
134 :param episode_num: int
135 """
136 self._request_obj(
137 self._urls["delete_rating"] % (tv_id, season_num, episode_num),
138 params="session_id=%s" % self.session_id,
139 method="DELETE"
140 )
141
142 def videos(self, tv_id, season_num, episode_num, include_video_language=None):
143 """
144 Get the videos that have been added to a TV episode.
145 :param tv_id: int
146 :param season_num: int
147 :param episode_num: int
148 :param include_video_language: str
149 :return:
150 """
151 params = ""
152 if include_video_language:
153 params += "&include_video_language=%s" % include_video_language
154 return self._request_obj(
155 self._urls["videos"] % (tv_id, season_num, episode_num),
156 params=params
157 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/find.py +83 -0
……@@ -0,0 +1,83 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3
4class Find(TMDb):
5 _urls = {
6 "find": "/find/%s"
7 }
8
9 def find(self, external_id, external_source):
10 """
11 The find method makes it easy to search for objects in our database by an external id. For example, an IMDB ID.
12 :param external_id: str
13 :param external_source str
14 :return:
15 """
16 return self._request_obj(
17 self._urls["find"] % external_id.replace("/", "%2F"),
18 params="external_source=" + external_source
19 )
20
21 def find_by_imdb_id(self, imdb_id):
22 """
23 The find method makes it easy to search for objects in our database by an IMDB ID.
24 :param imdb_id: str
25 :return:
26 """
27 return self.find(imdb_id, "imdb_id")
28
29 def find_by_tvdb_id(self, tvdb_id):
30 """
31 The find method makes it easy to search for objects in our database by a TVDB ID.
32 :param tvdb_id: int
33 :return:
34 """
35 return self.find(tvdb_id, "tvdb_id")
36
37 def find_by_freebase_mid(self, freebase_mid):
38 """
39 The find method makes it easy to search for objects in our database by a Freebase MID.
40 :param freebase_mid: str
41 :return:
42 """
43 return self.find(freebase_mid, "freebase_mid")
44
45 def find_by_freebase_id(self, freebase_id):
46 """
47 The find method makes it easy to search for objects in our database by a Freebase ID.
48 :param freebase_id: str
49 :return:
50 """
51 return self.find(freebase_id, "freebase_id")
52
53 def find_by_tvrage_id(self, tvrage_id):
54 """
55 The find method makes it easy to search for objects in our database by a TVRage ID.
56 :param tvrage_id: str
57 :return:
58 """
59 return self.find(tvrage_id, "tvrage_id")
60
61 def find_by_facebook_id(self, facebook_id):
62 """
63 The find method makes it easy to search for objects in our database by a Facebook ID.
64 :param facebook_id: str
65 :return:
66 """
67 return self.find(facebook_id, "facebook_id")
68
69 def find_by_instagram_id(self, instagram_id):
70 """
71 The find method makes it easy to search for objects in our database by a Instagram ID.
72 :param instagram_id: str
73 :return:
74 """
75 return self.find(instagram_id, "instagram_id")
76
77 def find_by_twitter_id(self, twitter_id):
78 """
79 The find method makes it easy to search for objects in our database by a Twitter ID.
80 :param twitter_id: str
81 :return:
82 """
83 return self.find(twitter_id, "twitter_id")
plugin.video.jacktook/lib/api/tmdbv3api/objs/genre.py +22 -0
……@@ -0,0 +1,22 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3
4class Genre(TMDb):
5 _urls = {
6 "movie_list": "/genre/movie/list",
7 "tv_list": "/genre/tv/list"
8 }
9
10 def movie_list(self):
11 """
12 Get the list of official genres for movies.
13 :return:
14 """
15 return self._request_obj(self._urls["movie_list"], key="genres")
16
17 def tv_list(self):
18 """
19 Get the list of official genres for TV shows.
20 :return:
21 """
22 return self._request_obj(self._urls["tv_list"], key="genres")
plugin.video.jacktook/lib/api/tmdbv3api/objs/group.py +15 -0
……@@ -0,0 +1,15 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3
4class Group(TMDb):
5 _urls = {
6 "details": "/tv/episode_group/%s"
7 }
8
9 def details(self, group_id):
10 """
11 Get the details of a TV episode group.
12 :param group_id: int
13 :return:
14 """
15 return self._request_obj(self._urls["details"] % group_id, key="groups")
plugin.video.jacktook/lib/api/tmdbv3api/objs/keyword.py +23 -0
……@@ -0,0 +1,23 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3class Keyword(TMDb):
4 _urls = {
5 "details": "/keyword/%s",
6 "movies": "/keyword/%s/movies"
7 }
8
9 def details(self, keyword_id):
10 """
11 Get a keywords details by id.
12 :param keyword_id: int
13 :return:
14 """
15 return self._request_obj(self._urls["details"] % keyword_id)
16
17 def movies(self, keyword_id):
18 """
19 Get the movies of a keyword by id.
20 :param keyword_id: int
21 :return:
22 """
23 return self._request_obj(self._urls["movies"] % keyword_id, key="results")
plugin.video.jacktook/lib/api/tmdbv3api/objs/list.py +95 -0
……@@ -0,0 +1,95 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3class List(TMDb):
4 _urls = {
5 "details": "/list/%s",
6 "check_status": "/list/%s/item_status",
7 "create": "/list",
8 "add_movie": "/list/%s/add_item",
9 "remove_movie": "/list/%s/remove_item",
10 "clear_list": "/list/%s/clear",
11 "delete_list": "/list/%s",
12 }
13
14 def details(self, list_id):
15 """
16 Get list details by id.
17 :param list_id: int
18 :return:
19 """
20 return self._request_obj(self._urls["details"] % list_id, key="items")
21
22 def check_item_status(self, list_id, movie_id):
23 """
24 You can use this method to check if a movie has already been added to the list.
25 :param list_id: int
26 :param movie_id: int
27 :return:
28 """
29 return self._request_obj(self._urls["check_status"] % list_id, params="movie_id=%s" % movie_id)["item_present"]
30
31 def create_list(self, name, description):
32 """
33 You can use this method to check if a movie has already been added to the list.
34 :param name: str
35 :param description: str
36 :return:
37 """
38 return self._request_obj(
39 self._urls["create"],
40 params="session_id=%s" % self.session_id,
41 method="POST",
42 json={
43 "name": name,
44 "description": description,
45 "language": self.language
46 }
47 ).list_id
48
49 def add_movie(self, list_id, movie_id):
50 """
51 Add a movie to a list.
52 :param list_id: int
53 :param movie_id: int
54 """
55 self._request_obj(
56 self._urls["add_movie"] % list_id,
57 params="session_id=%s" % self.session_id,
58 method="POST",
59 json={"media_id": movie_id}
60 )
61
62 def remove_movie(self, list_id, movie_id):
63 """
64 Remove a movie from a list.
65 :param list_id: int
66 :param movie_id: int
67 """
68 self._request_obj(
69 self._urls["remove_movie"] % list_id,
70 params="session_id=%s" % self.session_id,
71 method="POST",
72 json={"media_id": movie_id}
73 )
74
75 def clear_list(self, list_id):
76 """
77 Clear all of the items from a list.
78 :param list_id: int
79 """
80 self._request_obj(
81 self._urls["clear_list"] % list_id,
82 params="session_id=%s&confirm=true" % self.session_id,
83 method="POST"
84 )
85
86 def delete_list(self, list_id):
87 """
88 Delete a list.
89 :param list_id: int
90 """
91 self._request_obj(
92 self._urls["delete_list"] % list_id,
93 params="session_id=%s" % self.session_id,
94 method="DELETE"
95 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/movie.py +348 -0
……@@ -0,0 +1,348 @@
1import warnings
2from lib.api.tmdbv3api.tmdb import TMDb
3from .find import Find
4from .search import Search
5
6
7class Movie(TMDb):
8 _urls = {
9 "details": "/movie/%s",
10 "account_states": "/movie/%s/account_states",
11 "alternative_titles": "/movie/%s/alternative_titles",
12 "changes": "/movie/%s/changes",
13 "credits": "/movie/%s/credits",
14 "external_ids": "/movie/%s/external_ids",
15 "images": "/movie/%s/images",
16 "keywords": "/movie/%s/keywords",
17 "lists": "/movie/%s/lists",
18 "recommendations": "/movie/%s/recommendations",
19 "release_dates": "/movie/%s/release_dates",
20 "reviews": "/movie/%s/reviews",
21 "similar": "/movie/%s/similar",
22 "translations": "/movie/%s/translations",
23 "videos": "/movie/%s/videos",
24 "watch_providers": "/movie/%s/watch/providers",
25 "rate_movie": "/movie/%s/rating",
26 "delete_rating": "/movie/%s/rating",
27 "latest": "/movie/latest",
28 "now_playing": "/movie/now_playing",
29 "popular": "/movie/popular",
30 "top_rated": "/movie/top_rated",
31 "upcoming": "/movie/upcoming",
32 }
33
34 def details(self, movie_id, append_to_response="videos,trailers,images,casts,translations,keywords,release_dates,runtime,external_ids"):
35 """
36 Get the primary information about a movie.
37 :param movie_id: int
38 :param append_to_response: str
39 :return:
40 """
41 return self._request_obj(
42 self._urls["details"] % movie_id,
43 params="append_to_response=%s" % append_to_response
44 )
45
46 def account_states(self, movie_id):
47 """
48 Grab the following account states for a session:
49 Movie rating, If it belongs to your watchlist, or If it belongs to your favourite list.
50 :param movie_id: int
51 :return:
52 """
53 return self._request_obj(
54 self._urls["account_states"] % movie_id,
55 params="session_id=%s" % self.session_id
56 )
57
58 def alternative_titles(self, movie_id, country=None):
59 """
60 Get all of the alternative titles for a movie.
61 :param movie_id: int
62 :param country: str
63 :return:
64 """
65 return self._request_obj(
66 self._urls["alternative_titles"] % movie_id,
67 params="country=%s" % country if country else "",
68 key="titles"
69 )
70
71 def changes(self, movie_id, start_date=None, end_date=None, page=1):
72 """
73 Get the changes for a movie. By default only the last 24 hours are returned.
74 You can query up to 14 days in a single query by using the start_date and end_date query parameters.
75 :param movie_id: int
76 :param start_date: str
77 :param end_date: str
78 :param page: int
79 :return:
80 """
81 params = "page=%s" % page
82 if start_date:
83 params += "&start_date=%s" % start_date
84 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
92 def credits(self, movie_id):
93 """
94 Get the cast and crew for a movie.
95 :param movie_id: int
96 :return:
97 """
98 return self._request_obj(self._urls["credits"] % movie_id)
99
100 def external_ids(self, movie_id):
101 """
102 Get the external ids for a movie.
103 :param movie_id: int
104 :return:
105 """
106 return self._request_obj(self._urls["external_ids"] % movie_id)
107
108 def images(self, movie_id, include_image_language=None):
109 """
110 Get the images that belong to a movie.
111 Querying images with a language parameter will filter the results.
112 If you want to include a fallback language (especially useful for backdrops)
113 you can use the include_image_language parameter.
114 This should be a comma separated value like so: include_image_language=en,null.
115 :param movie_id: int
116 :param include_image_language: str
117 :return:
118 """
119 return self._request_obj(
120 self._urls["images"] % movie_id,
121 params="include_image_language=%s" % include_image_language if include_image_language else ""
122 )
123
124 def keywords(self, movie_id):
125 """
126 Get the keywords associated to a movie.
127 :param movie_id: int
128 :return:
129 """
130 return self._request_obj(
131 self._urls["keywords"] % movie_id,
132 key="keywords"
133 )
134
135 def lists(self, movie_id, page=1):
136 """
137 Get a list of lists that this movie belongs to.
138 :param movie_id: int
139 :param page: int
140 :return:
141 """
142 return self._request_obj(
143 self._urls["lists"] % movie_id,
144 params="page=%s" % page,
145 key="results"
146 )
147
148 def recommendations(self, movie_id, page=1):
149 """
150 Get a list of recommended movies for a movie.
151 :param movie_id: int
152 :param page: int
153 :return:
154 """
155 return self._request_obj(
156 self._urls["recommendations"] % movie_id,
157 params="page=%s" % page,
158 key="results"
159 )
160
161 def release_dates(self, movie_id):
162 """
163 Get the release date along with the certification for a movie.
164 :param movie_id: int
165 :return:
166 """
167 return self._request_obj(
168 self._urls["release_dates"] % movie_id,
169 key="results"
170 )
171
172 def reviews(self, movie_id, page=1):
173 """
174 Get the user reviews for a movie.
175 :param movie_id: int
176 :param page: int
177 :return:
178 """
179 return self._request_obj(
180 self._urls["reviews"] % movie_id,
181 params="page=%s" % page,
182 key="results"
183 )
184
185 def similar(self, movie_id, page=1):
186 """
187 Get a list of similar movies.
188 :param movie_id: int
189 :param page: int
190 :return:
191 """
192 return self._request_obj(
193 self._urls["similar"] % movie_id,
194 params="page=%s" % page,
195 key="results"
196 )
197
198 def translations(self, movie_id):
199 """
200 Get a list of translations that have been created for a movie.
201 :param movie_id: int
202 :return:
203 """
204 return self._request_obj(
205 self._urls["translations"] % movie_id,
206 key="translations"
207 )
208
209 def videos(self, movie_id, page=1):
210 """
211 Get the videos that have been added to a movie.
212 :param movie_id: int
213 :param page: int
214 :return:
215 """
216 return self._request_obj(
217 self._urls["videos"] % movie_id,
218 params="page=%s" % page,
219 key="results"
220 )
221
222 def watch_providers(self, movie_id):
223 """
224 You can query this method to get a list of the availabilities per country by provider.
225 :param movie_id: int
226 :return:
227 """
228 return self._request_obj(
229 self._urls["watch_providers"] % movie_id,
230 key="results"
231 )
232
233 def rate_movie(self, movie_id, rating):
234 """
235 Rate a movie.
236 :param movie_id: int
237 :param rating: float
238 """
239 self._request_obj(
240 self._urls["rate_movie"] % movie_id,
241 params="session_id=%s" % self.session_id,
242 method="POST",
243 json={"value": rating}
244 )
245
246 def delete_rating(self, movie_id):
247 """
248 Remove your rating for a movie.
249 :param movie_id: int
250 """
251 self._request_obj(
252 self._urls["delete_rating"] % movie_id,
253 params="session_id=%s" % self.session_id,
254 method="DELETE"
255 )
256
257 def latest(self):
258 """
259 Get the most newly created movie. This is a live response and will continuously change.
260 :return:
261 """
262 return self._request_obj(self._urls["latest"])
263
264 def now_playing(self, region=None, page=1):
265 """
266 Get a list of movies in theatres.
267 :param region: str
268 :param page: int
269 :return:
270 """
271 params = "page=%s" % page
272 if region:
273 params += "&region=%s" % region
274 return self._request_obj(
275 self._urls["now_playing"],
276 params=params,
277 key="results"
278 )
279
280 def popular(self, region=None, page=1):
281 """
282 Get a list of the current popular movies on TMDb. This list updates daily.
283 :param region: str
284 :param page: int
285 :return:
286 """
287 params = "page=%s" % page
288 if region:
289 params += "&region=%s" % region
290 return self._request_obj(
291 self._urls["popular"],
292 params=params,
293 key="results"
294 )
295
296 def top_rated(self, region=None, page=1):
297 """
298 Get the top rated movies on TMDb.
299 :param region: str
300 :param page: int
301 :return:
302 """
303 params = "page=%s" % page
304 if region:
305 params += "&region=%s" % region
306 return self._request_obj(
307 self._urls["top_rated"],
308 params=params,
309 key="results"
310 )
311
312 def upcoming(self, region=None, page=1):
313 """
314 Get a list of upcoming movies in theatres.
315 :param region: str
316 :param page: int
317 :return:
318 """
319 params = "page=%s" % page
320 if region:
321 params += "&region=%s" % region
322 return self._request_obj(
323 self._urls["upcoming"],
324 params=params,
325 key="results"
326 )
327
328 def search(self, term, page=1):
329 """
330 Search for movies.
331 :param term: str
332 :param page: int
333 :return:
334 """
335 warnings.warn("search method is deprecated use tmdbv3api.Search().movies(term)",
336 DeprecationWarning)
337 return Search().movies(term, page=page)
338
339 def external(self, external_id, external_source):
340 """
341 The find method makes it easy to search for objects in our database by an external id. For example, an IMDB ID.
342 :param external_id: str
343 :param external_source str
344 :return:
345 """
346 warnings.warn("external method is deprecated use tmdbv3api.Find().find(external_id, external_source)",
347 DeprecationWarning)
348 return Find().find(external_id, external_source)
plugin.video.jacktook/lib/api/tmdbv3api/objs/network.py +39 -0
……@@ -0,0 +1,39 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3
4class Network(TMDb):
5 _urls = {
6 "details": "/network/%s",
7 "alternative_names": "/network/%s/alternative_names",
8 "images": "/network/%s/images"
9 }
10
11 def details(self, network_id):
12 """
13 Get a networks details by id.
14 :param network_id: int
15 :return:
16 """
17 return self._request_obj(self._urls["details"] % network_id)
18
19 def alternative_names(self, network_id):
20 """
21 Get the alternative names of a network.
22 :param network_id: int
23 :return:
24 """
25 return self._request_obj(
26 self._urls["alternative_names"] % network_id,
27 key="results"
28 )
29
30 def images(self, network_id):
31 """
32 Get the TV network logos by id.
33 :param network_id: int
34 :return:
35 """
36 return self._request_obj(
37 self._urls["images"] % network_id,
38 key="logos"
39 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/person.py +151 -0
……@@ -0,0 +1,151 @@
1import warnings
2from lib.api.tmdbv3api.tmdb import TMDb
3from .search import Search
4
5
6class Person(TMDb):
7 _urls = {
8 "details": "/person/%s",
9 "changes": "/person/%s/changes",
10 "movie_credits": "/person/%s/movie_credits",
11 "tv_credits": "/person/%s/tv_credits",
12 "combined_credits": "/person/%s/combined_credits",
13 "external_ids": "/person/%s/external_ids",
14 "images": "/person/%s/images",
15 "tagged_images": "/person/%s/tagged_images",
16 "translations": "/person/%s/translations",
17 "latest": "/person/latest",
18 "popular": "/person/popular",
19 "search_people": "/search/person",
20 }
21
22 def details(self, person_id, append_to_response="videos,images"):
23 """
24 Get the primary person details by id.
25 :param append_to_response: str
26 :param person_id: int
27 :return:
28 """
29 return self._request_obj(
30 self._urls["details"] % person_id,
31 params="append_to_response=%s" % append_to_response
32 )
33
34 def changes(self, person_id, start_date=None, end_date=None, page=1):
35 """
36 Get the changes for a person. By default only the last 24 hours are returned.
37 You can query up to 14 days in a single query by using the start_date and end_date query parameters.
38 :param person_id: int
39 :param start_date: str
40 :param end_date: str
41 :param page: int
42 :return:
43 """
44 params = "page=%s" % page
45 if start_date:
46 params += "&start_date=%s" % start_date
47 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 )
54
55 def movie_credits(self, person_id):
56 """
57 Get the movie credits for a person.
58 :param person_id: int
59 :return:
60 """
61 return self._request_obj(self._urls["movie_credits"] % person_id)
62
63 def tv_credits(self, person_id):
64 """
65 Get the TV show credits for a person.
66 :param person_id: int
67 :return:
68 """
69 return self._request_obj(self._urls["tv_credits"] % person_id)
70
71 def combined_credits(self, person_id):
72 """
73 Get the movie and TV credits together in a single response.
74 :param person_id: int
75 :return:
76 """
77 return self._request_obj(self._urls["combined_credits"] % person_id)
78
79 def external_ids(self, person_id):
80 """
81 Get the external ids for a person. We currently support the following external sources.
82 IMDB ID, Facebook, Freebase MID, Freebase ID, Instagram, TVRage ID, and Twitter
83 :param person_id: int
84 :return:
85 """
86 return self._request_obj(self._urls["external_ids"] % person_id)
87
88 def images(self, person_id):
89 """
90 Get the images for a person.
91 :param person_id: int
92 :return:
93 """
94 return self._request_obj(
95 self._urls["images"] % person_id,
96 key="profiles"
97 )
98
99 def tagged_images(self, person_id, page=1):
100 """
101 Get the images that this person has been tagged in.
102 :param person_id: int
103 :param page: int
104 :return:
105 """
106 return self._request_obj(
107 self._urls["tagged_images"] % person_id,
108 params="page=%s" % page,
109 key="results"
110 )
111
112 def translations(self, person_id):
113 """
114 Get a list of translations that have been created for a person.
115 :param person_id: int
116 :return:
117 """
118 return self._request_obj(
119 self._urls["translations"] % person_id,
120 key="translations"
121 )
122
123 def latest(self):
124 """
125 Get the most newly created person. This is a live response and will continuously change.
126 :return:
127 """
128 return self._request_obj(self._urls["latest"])
129
130 def popular(self, page=1):
131 """
132 Get the list of popular people on TMDb. This list updates daily.
133 :param page: int
134 :return:
135 """
136 return self._request_obj(
137 self._urls["popular"],
138 params="page=%s" % page,
139 key="results"
140 )
141
142 def search(self, term, page=1):
143 """
144 Search for people.
145 :param term: string
146 :param page: int
147 :return:
148 """
149 warnings.warn("search method is deprecated use tmdbv3api.Search().people(params)",
150 DeprecationWarning)
151 return Search().people(term, page=page)
plugin.video.jacktook/lib/api/tmdbv3api/objs/provider.py +41 -0
……@@ -0,0 +1,41 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3
4class Provider(TMDb):
5 _urls = {
6 "regions": "/watch/providers/regions", # TODO:
7 "movie": "/watch/providers/movie", # TODO:
8 "tv": "/watch/providers/tv", # TODO:
9 }
10
11 def available_regions(self):
12 """
13 Returns a list of all of the countries we have watch provider (OTT/streaming) data for.
14 :return:
15 """
16 return self._request_obj(
17 self._urls["regions"],
18 key="results"
19 )
20
21 def movie_providers(self, region=None):
22 """
23 Returns a list of the watch provider (OTT/streaming) data we have available for movies.
24 :return:
25 """
26 return self._request_obj(
27 self._urls["movie"],
28 params="watch_region=%s" % region if region else "",
29 key="results"
30 )
31
32 def tv_providers(self, region=None):
33 """
34 Returns a list of the watch provider (OTT/streaming) data we have available for TV series.
35 :return:
36 """
37 return self._request_obj(
38 self._urls["tv"],
39 params="watch_region=%s" % region if region else "",
40 key="results"
41 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/review.py +15 -0
……@@ -0,0 +1,15 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3
4class Review(TMDb):
5 _urls = {
6 "details": "/review/%s",
7 }
8
9 def details(self, review_id):
10 """
11 Get the primary person details by id.
12 :param review_id: int
13 :return:
14 """
15 return self._request_obj(self._urls["details"] % review_id)
plugin.video.jacktook/lib/api/tmdbv3api/objs/search.py +146 -0
……@@ -0,0 +1,146 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3try:
4 from urllib import quote
5except ImportError:
6 from urllib.parse import quote
7
8
9class Search(TMDb):
10 _urls = {
11 "companies": "/search/company",
12 "collections": "/search/collection",
13 "keywords": "/search/keyword",
14 "movies": "/search/movie",
15 "multi": "/search/multi",
16 "people": "/search/person",
17 "tv_shows": "/search/tv",
18 }
19
20 def companies(self, term, page=1):
21 """
22 Search for companies.
23 :param term: str
24 :param page: int
25 :return:
26 """
27 return self._request_obj(
28 self._urls["companies"],
29 params="query=%s&page=%s" % (quote(term), page),
30 key="results"
31 )
32
33 def collections(self, term, page=1):
34 """
35 Search for collections.
36 :param term: str
37 :param page: int
38 :return:
39 """
40 return self._request_obj(
41 self._urls["collections"],
42 params="query=%s&page=%s" % (quote(term), page),
43 key="results"
44 )
45
46 def keywords(self, term, page=1):
47 """
48 Search for keywords.
49 :param term: str
50 :param page: int
51 :return:
52 """
53 return self._request_obj(
54 self._urls["keywords"],
55 params="query=%s&page=%s" % (quote(term), page),
56 key="results"
57 )
58
59 def movies(self, term, adult=None, region=None, year=None, release_year=None, page=1, append_to_response="external_ids"):
60 """
61 Search for movies.
62 :param term: str
63 :param adult: bool
64 :param region: str
65 :param year: int
66 :param release_year: int
67 :param page: int
68 :return:
69 """
70 params = "query=%s&page=%s" % (quote(term), page)
71 if adult is not None:
72 params += "&include_adult=%s" % "true" if adult else "false"
73 if region is not None:
74 params += "&region=%s" % quote(region)
75 if year is not None:
76 params += "&year=%s" % year
77 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 )
85
86 def multi(self, term, adult=None, region=None, page=1):
87 """
88 Search multiple models in a single request.
89 Multi search currently supports searching for movies, tv shows and people in a single request.
90 :param term: str
91 :param adult: bool
92 :param region: str
93 :param page: int
94 :return:
95 """
96 params = "query=%s&page=%s" % (quote(term), page)
97 if adult is not None:
98 params += "&include_adult=%s" % "true" if adult else "false"
99 if region is not None:
100 params += "&region=%s" % quote(region)
101 return self._request_obj(
102 self._urls["multi"],
103 params=params,
104 key="results"
105 )
106
107 def people(self, term, adult=None, region=None, page=1):
108 """
109 Search for people.
110 :param term: str
111 :param adult: bool
112 :param region: str
113 :param page: int
114 :return:
115 """
116 params = "query=%s&page=%s" % (quote(term), page)
117 if adult is not None:
118 params += "&include_adult=%s" % "true" if adult else "false"
119 if region is not None:
120 params += "&region=%s" % quote(region)
121 return self._request_obj(
122 self._urls["people"],
123 params=params,
124 key="results"
125 )
126
127 def tv_shows(self, term, adult=None, release_year=None, page=1, append_to_response="external_ids"):
128 """
129 Search for a TV show.
130 :param term: str
131 :param adult: bool
132 :param release_year: int
133 :param page: int
134 :return:
135 """
136 params = "query=%s&page=%s" % (quote(term), page)
137 if adult is not None:
138 params += "&include_adult=%s" % "true" if adult else "false"
139 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 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/season.py +133 -0
……@@ -0,0 +1,133 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3
4class Season(TMDb):
5 _urls = {
6 "details": "/tv/%s/season/%s",
7 "account_states": "/tv/%s/season/%s/account_states",
8 "aggregate_credits": "/tv/%s/season/%s/aggregate_credits",
9 "changes": "/tv/season/%s/changes",
10 "credits": "/tv/%s/season/%s/credits",
11 "external_ids": "/tv/%s/season/%s/external_ids",
12 "images": "/tv/%s/season/%s/images",
13 "translations": "/tv/%s/season/%s/translations",
14 "videos": "/tv/%s/season/%s/videos",
15 }
16
17 def details(self, tv_id, season_num, append_to_response="videos,trailers,images,credits,translations,runtime"):
18 """
19 Get the TV season details by id.
20 :param tv_id: int
21 :param season_num: int
22 :param append_to_response: str
23 :return:
24 """
25 return self._request_obj(
26 self._urls["details"] % (tv_id, season_num),
27 params="append_to_response=%s" % append_to_response
28 )
29
30 def account_states(self, tv_id, season_num):
31 """
32 Get all of the user ratings for the season's episodes.
33 :param tv_id: int
34 :param season_num: int
35 :return:
36 """
37 return self._request_obj(
38 self._urls["account_states"] % (tv_id, season_num),
39 params="session_id=%s" % self.session_id,
40 key="results"
41 )
42
43 def aggregate_credits(self, tv_id, season_num):
44 """
45 Get the aggregate credits for TV season.
46 This call differs from the main credits call in that it does not only return the season credits,
47 but rather is a view of all the cast & crew for all of the episodes belonging to a season.
48 :param tv_id: int
49 :param season_num: int
50 :return:
51 """
52 return self._request_obj(self._urls["aggregate_credits"] % (tv_id, season_num))
53
54 def changes(self, season_id, start_date=None, end_date=None, page=1):
55 """
56 Get the changes for a TV season. By default only the last 24 hours are returned.
57 You can query up to 14 days in a single query by using the start_date and end_date query parameters.
58 :param season_id: int
59 :param start_date: str
60 :param end_date: str
61 :param page: int
62 :return:
63 """
64 params = "page=%s" % page
65 if start_date:
66 params += "&start_date=%s" % start_date
67 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 )
74
75 def credits(self, tv_id, season_num):
76 """
77 Get the credits for TV season.
78 :param tv_id: int
79 :param season_num: int
80 :return:
81 """
82 return self._request_obj(self._urls["credits"] % (tv_id, season_num))
83
84 def external_ids(self, tv_id, season_num):
85 """
86 Get the external ids for a TV season.
87 :param tv_id: int
88 :param season_num: int
89 :return:
90 """
91 return self._request_obj(self._urls["external_ids"] % (tv_id, season_num))
92
93 def images(self, tv_id, season_num, include_image_language=None):
94 """
95 Get the images that belong to a TV season.
96 :param tv_id: int
97 :param season_num: int
98 :param include_image_language: str
99 :return:
100 """
101 return self._request_obj(
102 self._urls["images"] % (tv_id, season_num),
103 params="include_image_language=%s" % include_image_language if include_image_language else "",
104 key="posters"
105 )
106
107 def translations(self, tv_id, season_num):
108 """
109 Get a list of the translations that exist for a TV show.
110 :param tv_id: int
111 :param season_num: int
112 """
113 return self._request_obj(
114 self._urls["translations"] % (tv_id, season_num),
115 key="translations"
116 )
117
118 def videos(self, tv_id, season_num, include_video_language=None, page=1):
119 """
120 Get the videos that have been added to a TV show.
121 :param tv_id: int
122 :param season_num: int
123 :param include_video_language: str
124 :param page: int
125 :return:
126 """
127 params = "page=%s" % page
128 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 )
plugin.video.jacktook/lib/api/tmdbv3api/objs/trending.py +75 -0
……@@ -0,0 +1,75 @@
1from lib.api.tmdbv3api.tmdb import TMDb
2
3
4class Trending(TMDb):
5 _urls = {"trending": "/trending/%s/%s"}
6
7 def _trending(self, media_type="all", time_window="day", page=1):
8 return self._request_obj(
9 self._urls["trending"] % (media_type, time_window),
10 params="page=%s" % page
11 )
12
13 def all_day(self, page=1):
14 """
15 Get all daily trending
16 :param page: int
17 :return:
18 """
19 return self._trending(media_type="all", time_window="day", page=page)
20
21 def all_week(self, page=1):
22 """
23 Get all weekly trending
24 :param page: int
25 :return:
26 """
27 return self._trending(media_type="all", time_window="week", page=page)
28
29 def movie_day(self, page=1):
30 """
31 Get movie daily trending
32 :param page: int
33 :return:
34 """
35 return self._trending(media_type="movie", time_window="day", page=page)
36
37 def movie_week(self, page=1):
38 """
39 Get movie weekly trending
40 :param page: int
41 :return:
42 """
43 return self._trending(media_type="movie", time_window="week", page=page)
44
45 def tv_day(self, page=1):
46 """
47 Get tv daily trending
48 :param page: int
49 :return:
50 """
51 return self._trending(media_type="tv", time_window="day", page=page)
52
53 def tv_week(self, page=1):
54 """
55 Get tv weekly trending
56 :param page: int
57 :return:
58 """
59 return self._trending(media_type="tv", time_window="week", page=page)
60
61 def person_day(self, page=1):
62 """
63 Get person daily trending
64 :param page: int
65 :return:
66 """
67 return self._trending(media_type="person", time_window="day", page=page)
68
69 def person_week(self, page=1):
70 """
71 Get person weekly trending
72 :param page: int
73 :return:
74 """
75 return self._trending(media_type="person", time_window="week", page=page)
plugin.video.jacktook/lib/api/tmdbv3api/objs/tv.py +347 -0
……@@ -0,0 +1,347 @@
1import warnings
2from lib.api.tmdbv3api.tmdb import TMDb
3from .search import Search
4
5try:
6 from urllib import quote
7except ImportError:
8 from urllib.parse import quote
9
10
11class TV(TMDb):
12 _urls = {
13 "details": "/tv/%s",
14 "account_states": "/tv/%s/account_states",
15 "aggregate_credits": "/tv/%s/aggregate_credits",
16 "alternative_titles": "/tv/%s/alternative_titles",
17 "changes": "/tv/%s/changes",
18 "content_ratings": "/tv/%s/content_ratings",
19 "credits": "/tv/%s/credits",
20 "episode_groups": "/tv/%s/episode_groups",
21 "external_ids": "/tv/%s/external_ids",
22 "images": "/tv/%s/images",
23 "keywords": "/tv/%s/keywords",
24 "recommendations": "/tv/%s/recommendations",
25 "reviews": "/tv/%s/reviews",
26 "screened_theatrically": "/tv/%s/screened_theatrically",
27 "similar": "/tv/%s/similar",
28 "translations": "/tv/%s/translations",
29 "videos": "/tv/%s/videos",
30 "watch_providers": "/tv/%s/watch/providers",
31 "rate_tv_show": "/tv/%s/rating",
32 "delete_rating": "/tv/%s/rating",
33 "latest": "/tv/latest",
34 "airing_today": "/tv/airing_today",
35 "on_the_air": "/tv/on_the_air",
36 "popular": "/tv/popular",
37 "top_rated": "/tv/top_rated",
38 }
39
40 def details(self, tv_id, append_to_response="videos,trailers,images,credits,translations,external_ids"):
41 """
42 Get the primary TV show details by id.
43 :param tv_id: int
44 :param append_to_response: str
45 :return:
46 """
47 return self._request_obj(
48 self._urls["details"] % tv_id,
49 params="append_to_response=%s" % append_to_response,
50 )
51
52 def account_states(self, tv_id):
53 """
54 Grab the following account states for a session:
55 TV show rating, If it belongs to your watchlist, or If it belongs to your favourite list.
56 :param tv_id: int
57 :return:
58 """
59 return self._request_obj(
60 self._urls["account_states"] % tv_id,
61 params="session_id=%s" % self.session_id
62 )
63
64 def aggregate_credits(self, tv_id):
65 """
66 Get the aggregate credits (cast and crew) that have been added to a TV show.
67 This call differs from the main credits call in that it does not return the newest season but rather,
68 is a view of all the entire cast & crew for all episodes belonging to a TV show.
69 :param tv_id: int
70 :return:
71 """
72 return self._request_obj(self._urls["aggregate_credits"] % tv_id)
73
74 def alternative_titles(self, tv_id):
75 """
76 Returns all of the alternative titles for a TV show.
77 :param tv_id: int
78 :return:
79 """
80 return self._request_obj(
81 self._urls["alternative_titles"] % tv_id,
82 key="results"
83 )
84
85 def changes(self, tv_id, start_date=None, end_date=None, page=1):
86 """
87 Get the changes for a TV show. By default only the last 24 hours are returned.
88 You can query up to 14 days in a single query by using the start_date and end_date query parameters.
89 :param tv_id: int
90 :param start_date: str
91 :param end_date: str
92 :param page: int
93 """
94 params = "page=%s" % page
95 if start_date:
96 params += "&start_date=%s" % start_date
97 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 )
104
105 def content_ratings(self, tv_id):
106 """
107 Get the list of content ratings (certifications) that have been added to a TV show.
108 :param tv_id: int
109 :return:
110 """
111 return self._request_obj(
112 self._urls["content_ratings"] % tv_id,
113 key="results"
114 )
115
116 def credits(self, tv_id):
117 """
118 Get the credits (cast and crew) that have been added to a TV show.
119 :param tv_id: int
120 :return:
121 """
122 return self._request_obj(self._urls["credits"] % tv_id)
123
124 def episode_groups(self, tv_id):
125 """
126 Get all of the episode groups that have been created for a TV show.
127 :param tv_id: int
128 :return:
129 """
130 return self._request_obj(
131 self._urls["episode_groups"] % tv_id,
132 key="results"
133 )
134
135 def external_ids(self, tv_id):
136 """
137 Get the external ids for a TV show.
138 :param tv_id: int
139 :return:
140 """
141 return self._request_obj(self._urls["external_ids"] % tv_id)
142
143 def images(self, tv_id, include_image_language=None):
144 """
145 Get the images that belong to a TV show.
146 Querying images with a language parameter will filter the results.
147 If you want to include a fallback language (especially useful for backdrops)
148 you can use the include_image_language parameter.
149 This should be a comma separated value like so: include_image_language=en,null.
150 :param tv_id: int
151 :param include_image_language: str
152 :return:
153 """
154 return self._request_obj(
155 self._urls["images"] % tv_id,
156 params="include_image_language=%s" % include_image_language if include_image_language else ""
157 )
158
159 def keywords(self, tv_id):
160 """
161 Get the keywords that have been added to a TV show.
162 :param tv_id: int
163 :return:
164 """
165 return self._request_obj(
166 self._urls["keywords"] % tv_id,
167 key="results"
168 )
169
170 def recommendations(self, tv_id, page=1):
171 """
172 Get the list of TV show recommendations for this item.
173 :param tv_id: int
174 :param page: int
175 :return:
176 """
177 return self._request_obj(
178 self._urls["recommendations"] % tv_id,
179 params="page=%s" % page,
180 key="results"
181 )
182
183 def reviews(self, tv_id, page=1):
184 """
185 Get the reviews for a TV show.
186 :param tv_id: int
187 :param page: int
188 :return:
189 """
190 return self._request_obj(
191 self._urls["reviews"] % tv_id,
192 params="page=%s" % page,
193 key="results"
194 )
195
196 def screened_theatrically(self, tv_id):
197 """
198 Get a list of seasons or episodes that have been screened in a film festival or theatre.
199 :param tv_id: int
200 :return:
201 """
202 return self._request_obj(
203 self._urls["screened_theatrically"] % tv_id,
204 key="results"
205 )
206
207 def similar(self, tv_id, page=1):
208 """
209 Get the primary TV show details by id.
210 :param tv_id: int
211 :param page: int
212 :return:
213 """
214 return self._request_obj(
215 self._urls["similar"] % tv_id,
216 params="page=%s" % page,
217 key="results"
218 )
219
220 def translations(self, tv_id):
221 """
222 Get a list of the translations that exist for a TV show.
223 :param tv_id: int
224 :return:
225 """
226 return self._request_obj(
227 self._urls["translations"] % tv_id,
228 key="translations"
229 )
230
231 def videos(self, tv_id, include_video_language=None, page=1):
232 """
233 Get the videos that have been added to a TV show.
234 :param tv_id: int
235 :param include_video_language: str
236 :param page: int
237 :return:
238 """
239 params = "page=%s" % page
240 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 )
246
247 def watch_providers(self, tv_id):
248 """
249 You can query this method to get a list of the availabilities per country by provider.
250 :param tv_id: int
251 :return:
252 """
253 return self._request_obj(
254 self._urls["watch_providers"] % tv_id,
255 key="results"
256 )
257
258 def rate_tv_show(self, tv_id, rating):
259 """
260 Rate a TV show.
261 :param tv_id: int
262 :param rating: float
263 """
264 self._request_obj(
265 self._urls["rate_tv_show"] % tv_id,
266 params="session_id=%s" % self.session_id,
267 method="POST",
268 json={"value": rating}
269 )
270
271 def delete_rating(self, tv_id):
272 """
273 Remove your rating for a TV show.
274 :param tv_id: int
275 """
276 self._request_obj(
277 self._urls["delete_rating"] % tv_id,
278 params="session_id=%s" % self.session_id,
279 method="DELETE"
280 )
281
282 def latest(self):
283 """
284 Get the most newly created TV show. This is a live response and will continuously change.
285 :return:
286 """
287 return self._request_obj(self._urls["latest"])
288
289 def airing_today(self, page=1):
290 """
291 Get a list of TV shows that are airing today.
292 This query is purely day based as we do not currently support airing times.
293 :param page: int
294 :return:
295 """
296 return self._request_obj(
297 self._urls["airing_today"],
298 params="page=%s" % page,
299 key="results"
300 )
301
302 def on_the_air(self, page=1):
303 """
304 Get a list of shows that are currently on the air.
305 :param page:
306 :return:
307 """
308 return self._request_obj(
309 self._urls["on_the_air"],
310 params="page=%s" % page,
311 key="results"
312 )
313
314 def popular(self, page=1):
315 """
316 Get a list of the current popular TV shows on TMDb. This list updates daily.
317 :param page:
318 :return:
319 """
320 return self._request_obj(
321 self._urls["popular"],
322 params="page=%s" % page,
323 key="results"
324 )
325
326 def top_rated(self, page=1):
327 """
328 Get a list of the top rated TV shows on TMDb.
329 :param page:
330 :return:
331 """
332 return self._request_obj(
333 self._urls["top_rated"],
334 params="page=%s" % page,
335 key="results"
336 )
337
338 def search(self, term, page=1):
339 """
340 Search for a TV show.
341 :param term:
342 :param page:
343 :return:
344 """
345 warnings.warn("search method is deprecated use tmdbv3api.Search().tv_shows(term)",
346 DeprecationWarning)
347 return Search().tv_shows(term, page=page)
plugin.video.jacktook/lib/api/tmdbv3api/tmdb.py +210 -0
……@@ -0,0 +1,210 @@
1# -*- coding: utf-8 -*-
2
3import logging
4import os
5import time
6
7import requests
8
9from lib.utils.kodi.utils import kodilog
10
11from .as_obj import AsObj
12from .exceptions import TMDbException
13
14try:
15 from functools import lru_cache
16except ImportError:
17 from backports.functools_lru_cache import lru_cache
18
19logger = logging.getLogger(__name__)
20
21
22class TMDb(object):
23 _session = None
24 TMDB_API_KEY = "TMDB_API_KEY"
25 TMDB_LANGUAGE = "TMDB_LANGUAGE"
26 TMDB_SESSION_ID = "TMDB_SESSION_ID"
27 TMDB_WAIT_ON_RATE_LIMIT = "TMDB_WAIT_ON_RATE_LIMIT"
28 TMDB_DEBUG_ENABLED = "TMDB_DEBUG_ENABLED"
29 TMDB_CACHE_ENABLED = "TMDB_CACHE_ENABLED"
30 TMDB_PROXIES = "TMDB_PROXIES"
31 REQUEST_CACHE_MAXSIZE = None
32
33 def __init__(self, obj_cached=True, session=None):
34 if self.__class__._session is None or session is not None:
35 self.__class__._session = requests.Session() if session is None else session
36 self._base = "https://api.themoviedb.org/3"
37 self._remaining = 40
38 self._reset = None
39 self.obj_cached = obj_cached
40 if os.environ.get(self.TMDB_LANGUAGE) is None:
41 os.environ[self.TMDB_LANGUAGE] = "en-US"
42
43 @property
44 def page(self):
45 return os.environ["page"]
46
47 @property
48 def total_results(self):
49 return os.environ["total_results"]
50
51 @property
52 def total_pages(self):
53 return os.environ["total_pages"]
54
55 @property
56 def api_key(self):
57 return os.environ.get(self.TMDB_API_KEY)
58
59 @property
60 def proxies(self):
61 proxy = os.environ.get(self.TMDB_PROXIES)
62 if proxy is not None:
63 proxy = eval(proxy)
64 return proxy
65
66 @proxies.setter
67 def proxies(self, proxies):
68 if proxies is not None:
69 os.environ[self.TMDB_PROXIES] = str(proxies)
70
71 @api_key.setter
72 def api_key(self, api_key):
73 os.environ[self.TMDB_API_KEY] = str(api_key)
74
75 @property
76 def language(self):
77 return os.environ.get(self.TMDB_LANGUAGE)
78
79 @language.setter
80 def language(self, language):
81 os.environ[self.TMDB_LANGUAGE] = language
82
83 @property
84 def has_session(self):
85 return True if os.environ.get(self.TMDB_SESSION_ID) else False
86
87 @property
88 def session_id(self):
89 if not os.environ.get(self.TMDB_SESSION_ID):
90 raise TMDbException(
91 "Must Authenticate to create a session run Authentication(username, password)"
92 )
93 return os.environ.get(self.TMDB_SESSION_ID)
94
95 @session_id.setter
96 def session_id(self, session_id):
97 os.environ[self.TMDB_SESSION_ID] = session_id
98
99 @property
100 def wait_on_rate_limit(self):
101 if os.environ.get(self.TMDB_WAIT_ON_RATE_LIMIT) == "False":
102 return False
103 else:
104 return True
105
106 @wait_on_rate_limit.setter
107 def wait_on_rate_limit(self, wait_on_rate_limit):
108 os.environ[self.TMDB_WAIT_ON_RATE_LIMIT] = str(wait_on_rate_limit)
109
110 @property
111 def debug(self):
112 if os.environ.get(self.TMDB_DEBUG_ENABLED) == "True":
113 return True
114 else:
115 return False
116
117 @debug.setter
118 def debug(self, debug):
119 os.environ[self.TMDB_DEBUG_ENABLED] = str(debug)
120
121 @property
122 def cache(self):
123 if os.environ.get(self.TMDB_CACHE_ENABLED) == "False":
124 return False
125 else:
126 return True
127
128 @cache.setter
129 def cache(self, cache):
130 os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
131
132 @staticmethod
133 @lru_cache(maxsize=REQUEST_CACHE_MAXSIZE)
134 def cached_request(method, url, data, json, proxies):
135 return requests.request(method, url, data=data, json=json, proxies=proxies)
136
137 def cache_clear(self):
138 return self.cached_request.cache_clear()
139
140 def _request_obj(
141 self,
142 action,
143 params="",
144 call_cached=True,
145 method="GET",
146 data=None,
147 json=None,
148 key=None,
149 add_lang=True,
150 ):
151 if self.api_key is None or self.api_key == "":
152 raise TMDbException("No API key found.")
153
154 if add_lang:
155 url = f"{self._base}{action}?api_key={self.api_key}&{params}&language={self.language}"
156 else:
157 url = f"{self._base}{action}?api_key={self.api_key}&{params}"
158
159 if self.cache and self.obj_cached and call_cached and method != "POST":
160 req = self.cached_request(method, url, data, json, self.proxies)
161 else:
162 req = self.__class__._session.request(
163 method, url, data=data, json=json, proxies=self.proxies
164 )
165
166 headers = req.headers
167
168 if "X-RateLimit-Remaining" in headers:
169 self._remaining = int(headers["X-RateLimit-Remaining"])
170
171 if "X-RateLimit-Reset" in headers:
172 self._reset = int(headers["X-RateLimit-Reset"])
173
174 if self._remaining < 1:
175 current_time = int(time.time())
176 sleep_time = self._reset - current_time
177
178 if self.wait_on_rate_limit:
179 logger.warning("Rate limit reached. Sleeping for: %d" % sleep_time)
180 time.sleep(abs(sleep_time))
181 return self._request_obj(
182 action, params, call_cached, method, data, json, key
183 )
184 else:
185 raise TMDbException(
186 "Rate limit reached. Try again in %d seconds." % sleep_time
187 )
188
189 json = req.json()
190
191 if "page" in json:
192 os.environ["page"] = str(json["page"])
193
194 if "total_results" in json:
195 os.environ["total_results"] = str(json["total_results"])
196
197 if "total_pages" in json:
198 os.environ["total_pages"] = str(json["total_pages"])
199
200 if self.debug:
201 logger.info(json)
202 logger.info(self.cached_request.cache_info())
203
204 if "errors" in json:
205 raise TMDbException(json["errors"])
206
207 if "success" in json and json["success"] is False:
208 raise TMDbException(json["status_message"])
209
210 return AsObj(json, key=key)
plugin.video.jacktook/lib/api/tmdbv3api/utils.py +15 -0
……@@ -0,0 +1,15 @@
1import datetime
2
3years_tvshows = [{'name': '2024', 'id': 2024}]
4
5def get_dates(days, reverse=True):
6 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')
11 return str(current_date), new_date
12
13def get_current_date(return_str=True):
14 if return_str: return str(datetime.date.today())
15 else: return datetime.date.today()
plugin.video.jacktook/lib/api/trakt/base_cache.py +209 -0
……@@ -0,0 +1,209 @@
1# -*- coding: utf-8 -*-
2import ast
3import os
4import time
5import sqlite3 as database
6from lib.utils.kodi.utils import (
7 delete_file,
8 dialog_ok,
9 get_property,
10 notification,
11 set_property,
12 clear_property,
13 translation,
14)
15
16import xbmcaddon
17from xbmcvfs import translatePath
18
19
20userdata_path = translatePath(xbmcaddon.Addon().getAddonInfo("profile"))
21databases_path = os.path.join(userdata_path, "databases/")
22database_path_raw = os.path.join(userdata_path, "databases")
23trakt_db = translatePath(os.path.join(database_path_raw, "traktcache.db"))
24lists_db = translatePath(os.path.join(database_path_raw, "lists.db"))
25maincache_db = translatePath(os.path.join(database_path_raw, "maincache.db"))
26paginator_db = translatePath(os.path.join(database_path_raw, "paginator.db"))
27database_timeout = 20
28
29
30database_locations = {
31 "trakt_db": trakt_db,
32 "lists_db": lists_db,
33 "maincache_db": maincache_db,
34 "paginator_db": paginator_db,
35}
36
37integrity_check = {
38 "trakt_db": ("trakt_data", "watched_status", "progress"),
39 "maincache_db": ("maincache",),
40 "lists_db": ("lists",),
41}
42
43table_creators = {
44 "trakt_db": (
45 "CREATE TABLE IF NOT EXISTS trakt_data (id text unique, data text)",
46 "CREATE TABLE IF NOT EXISTS watched \
47(db_type text not null, media_id text not null, season integer, episode integer, last_played text, title text, unique (db_type, media_id, season, episode))",
48 "CREATE TABLE IF NOT EXISTS progress \
49(db_type text not null, media_id text not null, season integer, episode integer, resume_point text, curr_time text, \
50last_played text, resume_id integer, title text, unique (db_type, media_id, season, episode))",
51 "CREATE TABLE IF NOT EXISTS watched_status (db_type text not null, media_id text not null, status text, unique (db_type, media_id))",
52 ),
53 "lists_db": (
54 "CREATE TABLE IF NOT EXISTS lists (id text unique, data text, expires integer)",
55 ),
56 "maincache_db": (
57 "CREATE TABLE IF NOT EXISTS maincache (id text unique, data text, expires integer)",
58 ),
59 "paginator_db": (
60 "CREATE TABLE IF NOT EXISTS paginated_data (id text unique, page_number integer, data text, total_pages integer)",
61 ),
62}
63
64media_prop = "jacktook.%s"
65BASE_GET = "SELECT expires, data FROM %s WHERE id = ?"
66BASE_SET = "INSERT OR REPLACE INTO %s(id, data, expires) VALUES (?, ?, ?)"
67BASE_DELETE = "DELETE FROM %s WHERE id = ?"
68
69
70def make_database(database_name):
71 dbcon = database.connect(database_locations[database_name])
72 for command in table_creators[database_name]:
73 dbcon.execute(command)
74 dbcon.close()
75
76
77def setup_databases():
78 if not os.path.exists(databases_path):
79 os.makedirs(databases_path)
80 for database_name, database_location in database_locations.items():
81 dbcon = database.connect(database_location)
82 for command in table_creators[database_name]:
83 dbcon.execute(command)
84
85
86def connect_database(database_name):
87 dbcon = database.connect(
88 database_locations[database_name],
89 timeout=database_timeout,
90 isolation_level=None,
91 check_same_thread=False,
92 )
93 dbcon.execute("PRAGMA synchronous = OFF")
94 dbcon.execute("PRAGMA journal_mode = OFF")
95 return dbcon
96
97
98def get_timestamp(offset=0):
99 # Offset is in HOURS multiply by 3600 to get seconds
100 return int(time.time()) + (offset * 3600)
101
102
103def check_databases_integrity():
104 def _process(database_name, tables):
105 database_location = database_locations[database_name]
106 dbcon = None
107 try:
108 dbcon = database.connect(database_location)
109 for db_table in tables:
110 dbcon.execute(command_base % db_table)
111 except:
112 database_errors.append(database_name)
113 if os.path.exists(database_location):
114 try:
115 dbcon.close()
116 except:
117 pass
118 delete_file(database_location)
119
120 command_base = "SELECT * FROM %s LIMIT 1"
121 database_errors = []
122 for database_name, tables in integrity_check.items():
123 _process(database_name, tables)
124 setup_databases()
125 if database_errors:
126 dialog_ok(
127 heading=translation(90432),
128 line1=translation(90433)
129 % ", ".join(database_errors),
130 )
131 else:
132 notification(translation(90426), time=3000)
133
134
135class BaseCache(object):
136 def __init__(self, dbfile, table):
137 self.table = table
138 self.dbfile = dbfile
139
140 def get(self, string):
141 result = None
142 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)
147 cache_data = dbcon.execute(BASE_GET % self.table, (string,)).fetchone()
148 if cache_data:
149 if cache_data[0] > current_time:
150 result = self._deserialize(cache_data[1])
151 self.set_memory_cache(result, string, cache_data[0])
152 else:
153 self.delete(string)
154 except:
155 pass
156 return result
157
158 def set(self, string, data, expiration=720):
159 try:
160 dbcon = connect_database(self.dbfile)
161 expires = get_timestamp(expiration)
162 dbcon.execute(
163 BASE_SET % self.table, (string, self._serialize(data), int(expires))
164 )
165 self.set_memory_cache(data, string, int(expires))
166 except:
167 return None
168
169 def get_memory_cache(self, string, current_time):
170 result = None
171 try:
172 cachedata = get_property(media_prop % string)
173 if cachedata:
174 cachedata = self._deserialize(cachedata)
175 if cachedata[0] > current_time:
176 result = cachedata[1]
177 except:
178 pass
179 return result
180
181 def set_memory_cache(self, data, string, expires):
182 try:
183 cachedata = (expires, data)
184 cachedata_repr = self._serialize(cachedata)
185 set_property(media_prop % string, cachedata_repr)
186 except:
187 pass
188
189 @staticmethod
190 def _serialize(data):
191 return repr(data)
192
193 @staticmethod
194 def _deserialize(data):
195 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)
plugin.video.jacktook/lib/api/trakt/lists_cache.py +64 -0
……@@ -0,0 +1,64 @@
1from lib.api.trakt.base_cache import BaseCache, get_timestamp
2
3
4GET_ALL = "SELECT id FROM lists"
5DELETE_ALL = "DELETE FROM lists"
6DELETE_PREFIX = 'DELETE FROM lists WHERE id LIKE "%s%%"'
7CLEAN = "DELETE from lists WHERE CAST(expires AS INT) <= ?"
8
9
10class ListsCache(BaseCache):
11 def __init__(self):
12 BaseCache.__init__(self, "lists_db", "lists")
13
14 def delete_all_lists(self):
15 try:
16 dbcon = self.manual_connect("lists_db")
17 for i in dbcon.execute(GET_ALL):
18 self.delete_memory_cache(str(i[0]))
19 dbcon.execute(DELETE_ALL)
20 dbcon.execute("VACUUM")
21 return True
22 except:
23 return False
24
25 def delete_prefix(self, prefix):
26 try:
27 dbcon = self.manual_connect("lists_db")
28 for item in dbcon.execute(GET_ALL):
29 cache_key = str(item[0])
30 if cache_key.startswith(prefix):
31 self.delete_memory_cache(cache_key)
32 dbcon.execute(DELETE_PREFIX % prefix)
33 dbcon.execute("VACUUM")
34 return True
35 except:
36 return False
37
38 def clean_database(self):
39 try:
40 dbcon = self.manual_connect("lists_db")
41 dbcon.execute(CLEAN, (get_timestamp(),))
42 dbcon.execute("VACUUM")
43 return True
44 except:
45 return False
46
47
48lists_cache = ListsCache()
49
50
51def lists_cache_object(function, string, args, json=False, expiration=48):
52 cache = lists_cache.get(string)
53 if cache is not None:
54 return cache
55 if isinstance(args, list):
56 args = tuple(args)
57 else:
58 args = (args,)
59 if json:
60 result = function(*args).json()
61 else:
62 result = function(*args)
63 lists_cache.set(string, result, expiration=expiration)
64 return result
plugin.video.jacktook/lib/api/trakt/main_cache.py +69 -0
……@@ -0,0 +1,69 @@
1from lib.api.trakt.base_cache import BaseCache, get_timestamp
2
3
4GET_ALL = "SELECT id FROM maincache"
5DELETE_ALL = "DELETE FROM maincache"
6LIKE_SELECT = "SELECT id from maincache where id LIKE %s"
7LIKE_DELETE = "DELETE FROM maincache WHERE id LIKE %s"
8CLEAN = "DELETE from maincache WHERE CAST(expires AS INT) <= ?"
9
10
11class MainCache(BaseCache):
12 def __init__(self):
13 BaseCache.__init__(self, "maincache_db", "maincache")
14
15 def delete_all(self):
16 try:
17 dbcon = self.manual_connect("maincache_db")
18 for i in dbcon.execute(GET_ALL):
19 self.delete_memory_cache(str(i[0]))
20 dbcon.execute(DELETE_ALL)
21 dbcon.execute("VACUUM")
22 return True
23 except:
24 return False
25
26 def delete_all_folderscrapers(self):
27 dbcon = self.manual_connect("maincache_db")
28 remove_list = [
29 str(i[0])
30 for i in dbcon.execute(LIKE_SELECT % "'FOLDERSCRAPER_%'").fetchall()
31 ]
32 if not remove_list:
33 return True
34 try:
35 dbcon.execute(LIKE_DELETE % "'FOLDERSCRAPER_%'")
36 dbcon.execute("VACUUM")
37 for item in remove_list:
38 self.delete_memory_cache(str(item))
39 return True
40 except:
41 return False
42
43 def clean_database(self):
44 try:
45 dbcon = self.manual_connect("maincache_db")
46 dbcon.execute(CLEAN, (get_timestamp(),))
47 dbcon.execute("VACUUM")
48 return True
49 except:
50 return False
51
52
53main_cache = MainCache()
54
55
56def cache_object(function, string, args, json=True, expiration=24):
57 cache = main_cache.get(string)
58 if cache is not None:
59 return cache
60 if isinstance(args, list):
61 args = tuple(args)
62 else:
63 args = (args,)
64 if json:
65 result = function(*args).json()
66 else:
67 result = function(*args)
68 main_cache.set(string, result, expiration=expiration)
69 return result
plugin.video.jacktook/lib/api/trakt/trakt.py +1694 -0
……@@ -0,0 +1,1694 @@
1import json
2import datetime
3import random
4import time
5from typing import Any, Dict
6import requests
7from lib.utils.general.utils import set_pluging_category
8import xbmc
9
10from lib.api.trakt.lists_cache import lists_cache
11from lib.api.trakt.base_cache import BASE_DELETE, connect_database
12from lib.api.trakt.lists_cache import lists_cache_object
13from lib.api.trakt.main_cache import cache_object
14from lib.api.trakt.main_cache import cache_object
15from lib.api.trakt.trakt_cache import (
16 cache_trakt_object,
17 clear_trakt_collection_watchlist_data,
18 clear_trakt_favorites,
19 clear_trakt_list_contents_data,
20 clear_trakt_list_data,
21 trakt_watched_cache,
22)
23from lib.api.trakt.trakt_utils import clean_ids, sort_for_article, sort_list
24from lib.gui.qr_progress_dialog import QRProgressDialog
25from lib.jacktook.utils import ADDON_PATH
26from lib.utils.debrid.qrcode_utils import make_qrcode
27from lib.utils.kodi.utils import (
28 copy2clip,
29 get_datetime,
30 get_property,
31 kodilog,
32 notification,
33 set_property,
34 set_setting,
35 sleep,
36 progressDialog,
37 translation,
38)
39from lib.utils.kodi.settings import (
40 EMPTY_USER,
41 trakt_client,
42 trakt_lists_sort_order,
43 trakt_secret,
44)
45
46
47class TraktBase:
48 def __init__(self):
49 self.api_endpoint = "https://api.trakt.tv/%s"
50 self.timeout = 20
51 self.empty_setting_check = (None, "empty_setting", "")
52 self.standby_date = "2050-01-01T01:00:00.000Z"
53 self.trakt_user = EMPTY_USER
54
55 def ensure_token_valid(self):
56 expires = get_property("trakt_expires")
57 refresh_token = get_property("trakt_refresh")
58 if expires and refresh_token:
59 try:
60 expires = float(expires)
61 # Refresh if less than 1 hour left
62 if expires - time.time() < 3600:
63 self.trakt_refresh = refresh_token
64 self.trakt_refresh_token()
65 except Exception as e:
66 kodilog(f"Error checking token expiry: {e}")
67
68 def no_client_key(self):
69 notification(translation(90382))
70 return None
71
72 def no_secret_key(self):
73 notification(translation(90383))
74 return None
75
76 def _handle_unauthorized(self):
77 kodilog("Trakt unauthorized - Revoking authentication")
78 set_property("trakt_token", "")
79 set_property("trakt_refresh", "")
80 set_property("trakt_expires", "")
81 set_setting("trakt_user", EMPTY_USER)
82 set_setting("is_trakt_auth", "false")
83 notification(translation(90408), time=5000)
84 try:
85 TraktCache().clear_all_trakt_cache_data()
86 except:
87 pass
88
89 def call_trakt(
90 self,
91 path,
92 params=None,
93 data=None,
94 is_delete=False,
95 with_auth=True,
96 method=None,
97 pagination=False,
98 page_no=1,
99 ):
100 params = params or {}
101 headers = {
102 "Content-Type": "application/json",
103 "trakt-api-version": "2",
104 "trakt-api-key": trakt_client(),
105 }
106
107 if headers["trakt-api-key"] in self.empty_setting_check:
108 return self.no_client_key()
109
110 if with_auth:
111 self.ensure_token_valid()
112 token = get_property("trakt_token")
113
114 if token:
115 headers["Authorization"] = f"Bearer {token}"
116
117 if pagination:
118 params["page"] = page_no
119
120 response = self._send_request(path, params, data, headers, is_delete, method)
121
122 # Rate Limiting Handling
123 if response.status_code == 429:
124 retry_after = int(response.headers.get("Retry-After", 1))
125 kodilog(f"Trakt Rate Limit Exceeded. Retrying in {retry_after} seconds.")
126 sleep(retry_after * 1000)
127 response = self._send_request(
128 path, params, data, headers, is_delete, method
129 )
130
131 kodilog("Trakt response status code: %s" % response.status_code)
132 try:
133 response.raise_for_status()
134 except requests.HTTPError as error:
135 status_code = response.status_code
136 if status_code == 401:
137 self._handle_unauthorized()
138 return []
139
140 status_code = response.status_code
141 error_messages = {
142 400: "Bad Request",
143 401: "Unauthorized",
144 403: "Forbidden",
145 404: "Not Found",
146 422: "Unprocessable Entity",
147 429: "Rate Limit Exceeded",
148 500: "Internal Server Error",
149 503: "Service Unavailable",
150 504: "Gateway Timeout",
151 }
152 error_message = error_messages.get(
153 status_code, f"HTTP Error: {status_code}"
154 )
155 kodilog(f"Trakt API error: {response.text}")
156 raise ProviderException(f"Trakt API error: {error_message}")
157 except requests.RequestException as error:
158 error_message = f"Trakt API error: {error}"
159 raise ProviderException(error_message)
160
161 return self._process_response(response, method, pagination)
162
163 def _send_request(self, path, params, data, headers, is_delete, method):
164 url = self.api_endpoint % path
165 kodilog("Trakt URL: %s" % url)
166 if method == "post":
167 return requests.post(url, json=data, headers=headers, timeout=self.timeout)
168 elif method == "delete":
169 return requests.delete(url, json=data, headers=headers, timeout=self.timeout)
170 elif method == "sort_by_headers":
171 return requests.get(
172 url, params=params, headers=headers, timeout=self.timeout
173 )
174 elif data is not None:
175 return requests.post(url, json=data, headers=headers, timeout=self.timeout)
176 elif is_delete:
177 return requests.delete(url, headers=headers, timeout=self.timeout)
178 else:
179 return requests.get(
180 url, params=params, headers=headers, timeout=self.timeout
181 )
182
183 def _process_response(self, response, method, pagination):
184 response.encoding = "utf-8"
185 try:
186 result = response.json()
187 except ValueError:
188 return None
189
190 if method == "sort_by_headers":
191 headers = response.headers
192 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
199
200 if pagination:
201 return result, response.headers.get("X-Pagination-Page-Count")
202 return result
203
204 def get_trakt(self, params):
205 try:
206 path_insert = params.get("path_insert")
207 if path_insert is not None:
208 if not isinstance(path_insert, (tuple)):
209 path_insert = (path_insert,)
210 formatted_path = params["path"] % path_insert
211 else:
212 formatted_path = params["path"]
213
214 result = self.call_trakt(
215 formatted_path,
216 params=params.get("params", {}),
217 data=params.get("data"),
218 is_delete=params.get("is_delete", False),
219 with_auth=params.get("with_auth", False),
220 method=params.get("method"),
221 pagination=params.get("pagination", True),
222 page_no=params.get("page_no"),
223 )
224 if result is not None:
225 if params.get("pagination", True):
226 if isinstance(result, (list, tuple)) and len(result) > 0:
227 return result[0]
228 return []
229 return result
230 except KeyError as e:
231 kodilog(f"KeyError in get_trakt: {e}")
232 raise
233 except TypeError as e:
234 kodilog(f"TypeError in get_trakt: {e}")
235 raise
236
237 def trakt_refresh_token(self):
238 CLIENT_ID = trakt_client()
239 if CLIENT_ID in self.empty_setting_check:
240 return self.no_client_key()
241 CLIENT_SECRET = trakt_secret()
242 if CLIENT_SECRET in self.empty_setting_check:
243 return self.no_secret_key()
244 data = {
245 "client_id": CLIENT_ID,
246 "client_secret": CLIENT_SECRET,
247 "redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
248 "grant_type": "refresh_token",
249 "refresh_token": self.trakt_refresh,
250 }
251 response = self.call_trakt("oauth/token", data=data, with_auth=False)
252 if response and isinstance(response, dict):
253 set_property("trakt_token", response["access_token"])
254 set_property("trakt_refresh", response["refresh_token"])
255 set_property("trakt_expires", str(time.time() + 82800)) # 23 hours
256
257 def get_trakt_id_by_tmdb(self, tmdb_id, media_type="movie"):
258 trakt_object = self.get_trakt_object_by_tmdb(tmdb_id, media_type)
259 if trakt_object:
260 try:
261 return trakt_object["ids"]["trakt"]
262 except (KeyError, TypeError):
263 return None
264 return None
265
266 def get_trakt_object_by_tmdb(self, tmdb_id, media_type="movie"):
267 params = {
268 "path": "search/tmdb/%s",
269 "path_insert": tmdb_id,
270 "params": {"type": media_type},
271 "with_auth": False,
272 "pagination": False,
273 }
274 results = self.get_trakt(params)
275 if results and isinstance(results, list) and len(results) > 0:
276 try:
277 return results[0][media_type]
278 except (KeyError, IndexError, TypeError):
279 return None
280 return None
281
282
283class TraktAuthentication(TraktBase):
284 def __init__(self):
285 super().__init__()
286
287 def get_user_settings(self):
288 params = {
289 "path": "users/settings",
290 "path_insert": (),
291 "with_auth": True,
292 "pagination": False,
293 }
294 return self.get_trakt(params)
295
296 def get_account_stats(self):
297 params = {
298 "path": "users/me/stats",
299 "with_auth": True,
300 "pagination": False,
301 }
302 return self.get_trakt(params)
303
304 def trakt_get_device_code(self):
305 CLIENT_ID = trakt_client()
306 if CLIENT_ID in self.empty_setting_check:
307 return self.no_client_key()
308 data = {"client_id": CLIENT_ID}
309 return self.call_trakt("oauth/device/code", data=data, with_auth=False)
310
311 def trakt_get_device_token(self, device_codes):
312 CLIENT_ID = trakt_client()
313 if CLIENT_ID in self.empty_setting_check:
314 return self.no_client_key()
315 CLIENT_SECRET = trakt_secret()
316 if CLIENT_SECRET in self.empty_setting_check:
317 return self.no_secret_key()
318 result = None
319 headers = {
320 "Content-Type": "application/json",
321 "trakt-api-version": "2",
322 "trakt-api-key": CLIENT_ID,
323 }
324 data = {
325 "code": device_codes["device_code"],
326 "client_id": CLIENT_ID,
327 "client_secret": CLIENT_SECRET,
328 }
329 start = time.time()
330 expires_in = device_codes["expires_in"]
331 sleep_interval = device_codes["interval"]
332 user_code = str(device_codes["user_code"])
333 verification_url = str(device_codes["verification_url"])
334
335 # Generate QR code for the verification URL
336 qr_code = make_qrcode(verification_url)
337 try:
338 copy2clip(user_code)
339 except:
340 pass
341
342 # Create and display QR code dialog
343 progressDialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH)
344 progressDialog.setup(
345 translation(90563),
346 qr_code,
347 verification_url,
348 user_code,
349 "",
350 is_debrid=False,
351 )
352 progressDialog.show_dialog()
353
354 try:
355 time_passed = 0
356 while not progressDialog.iscanceled and time_passed < expires_in:
357 sleep(max(sleep_interval, 1) * 1000)
358 response = requests.post(
359 self.api_endpoint % "oauth/device/token",
360 data=json.dumps(data),
361 headers=headers,
362 timeout=self.timeout,
363 )
364 status_code = response.status_code
365 if status_code == 200:
366 result = response.json()
367 progressDialog.update_progress(100, translation(90545))
368 break
369 elif status_code == 400:
370 time_passed = time.time() - start
371 progress = int(100 * time_passed / expires_in)
372 progressDialog.update_progress(progress)
373 else:
374 break
375 except:
376 pass
377 try:
378 progressDialog.close_dialog()
379 except:
380 pass
381 return result
382
383 def trakt_authenticate(self):
384 code = self.trakt_get_device_code()
385 token = self.trakt_get_device_token(code)
386 if not token:
387 kodilog("Trakt authentication failed, no token received")
388 notification(translation(90384), time=3000)
389 return False
390 set_property("trakt_token", str(token["access_token"]))
391 set_property("trakt_refresh", str(token["refresh_token"]))
392 set_property("trakt_expires", str(time.time() + 82800)) # 23 hours
393 try:
394 user = self.call_trakt("users/me")
395 if user and isinstance(user, dict):
396 set_setting("trakt_user", str(user["username"]))
397 set_setting("is_trakt_auth", "true")
398 notification(translation(90385), time=3000)
399 return True
400 except:
401 kodilog("Trakt user not found, setting to empty user")
402 set_setting("is_trakt_auth", "false")
403 notification(translation(90384), time=3000)
404 return False
405
406 def trakt_revoke_authentication(self):
407 kodilog("Revoking Trakt authentication")
408 TraktCache().clear_all_trakt_cache_data()
409 CLIENT_ID = trakt_client()
410 if CLIENT_ID in self.empty_setting_check:
411 return self.no_client_key()
412 CLIENT_SECRET = trakt_secret()
413 if CLIENT_SECRET in self.empty_setting_check:
414 return self.no_secret_key()
415 data = {
416 "token": get_property("trakt_token"),
417 "client_id": CLIENT_ID,
418 "client_secret": CLIENT_SECRET,
419 }
420 set_property("trakt_token", "")
421 set_property("trakt_refresh", "")
422 set_property("trakt_expires", "")
423 set_setting("trakt_user", EMPTY_USER)
424 set_setting("is_trakt_auth", "false")
425 self.call_trakt("oauth/revoke", data=data, with_auth=False)
426 notification(translation(90386), time=3000)
427
428
429class TraktMovies(TraktBase):
430 def trakt_movies_trending(self, page_no):
431 set_pluging_category(translation(90028))
432 string = "trakt_movies_trending_%s" % page_no
433 params = {
434 "path": "movies/trending",
435 "params": {"limit": 20},
436 "page_no": page_no,
437 }
438 return lists_cache_object(self.get_trakt, string, params)
439
440 def trakt_movies_trending_recent(self, page_no):
441 set_pluging_category(translation(90024))
442 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
445 params = {
446 "path": "movies/trending",
447 "params": {"limit": 20, "years": years},
448 "page_no": page_no,
449 }
450 return lists_cache_object(self.get_trakt, string, params)
451
452 def trakt_movies_top10_boxoffice(self):
453 set_pluging_category(translation(90036))
454 string = "trakt_movies_top10_boxoffice"
455 params = {"path": "movies/boxoffice", "pagination": False}
456 return lists_cache_object(self.get_trakt, string, params)
457
458 def trakt_movies_most_watched(self, page_no):
459 set_pluging_category(translation(90029))
460 string = "trakt_movies_most_watched_%s" % page_no
461 params = {
462 "path": "movies/watched/daily",
463 "params": {"limit": 20},
464 "page_no": page_no,
465 }
466 return lists_cache_object(self.get_trakt, string, params)
467
468 def trakt_movies_most_favorited(self, page_no):
469 set_pluging_category(translation(90030))
470 string = "trakt_movies_most_favorited%s" % page_no
471 params = {
472 "path": "movies/favorited/daily",
473 "params": {"limit": 20},
474 "page_no": page_no,
475 }
476 return lists_cache_object(self.get_trakt, string, params)
477
478 def trakt_recommendations(self, media_type):
479 set_pluging_category(translation(90033))
480 string = "trakt_recommendations_%s" % (media_type)
481 params = {
482 "path": "/recommendations/%s",
483 "path_insert": media_type,
484 "with_auth": True,
485 "params": {
486 "limit": 50,
487 "ignore_collected": "true",
488 "ignore_watchlisted": "true",
489 },
490 "pagination": False,
491 }
492 return lists_cache_object(self.get_trakt, string, params)
493
494 def get_watched_movies(self):
495 params = {
496 "path": "sync/watched/movies",
497 "with_auth": True,
498 "pagination": False,
499 "params": {"extended": "full"},
500 }
501 return self.get_trakt(params)
502
503 def trakt_movies_progress(self):
504 set_pluging_category(translation(90200))
505 try:
506 return TraktScrobble().trakt_get_playback_progress("movies")
507 except Exception:
508 return []
509
510 def trakt_collection(self, page_no):
511 set_pluging_category(translation(90294))
512 params = {
513 "path": "sync/collection/movies",
514 "params": {"limit": 20, "extended": "full"},
515 "page_no": page_no,
516 "with_auth": True,
517 }
518 return self.get_trakt(params)
519
520
521class TraktTV(TraktBase):
522 def trakt_tv_trending(self, page_no):
523 set_pluging_category(translation(90028))
524 string = "trakt_tv_trending_%s" % page_no
525 params = {
526 "path": "shows/trending",
527 "params": {"limit": 20},
528 "page_no": page_no,
529 }
530 return lists_cache_object(self.get_trakt, string, params)
531
532 def trakt_tv_trending_recent(self, page_no):
533 set_pluging_category(translation(90024))
534 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
537 params = {
538 "path": "shows/trending",
539 "params": {"limit": 20, "years": years},
540 "page_no": page_no,
541 }
542 return lists_cache_object(self.get_trakt, string, params)
543
544 def trakt_tv_most_watched(self, page_no):
545 set_pluging_category(translation(90029))
546 string = "trakt_tv_most_watched_%s" % page_no
547 params = {
548 "path": "shows/watched/daily",
549 "params": {"limit": 20},
550 "page_no": page_no,
551 }
552 return lists_cache_object(self.get_trakt, string, params)
553
554 def trakt_tv_most_favorited(self, page_no):
555 set_pluging_category(translation(90030))
556 string = "trakt_tv_most_favorited_%s" % page_no
557 params = {
558 "path": "shows/favorited/daily",
559 "params": {"limit": 20},
560 "page_no": page_no,
561 }
562 return lists_cache_object(self.get_trakt, string, params)
563
564 def trakt_recommendations(self, media_type):
565 set_pluging_category(translation(90033))
566 string = "trakt_recommendations_%s" % (media_type)
567 params = {
568 "path": "/recommendations/%s",
569 "path_insert": media_type,
570 "with_auth": True,
571 "params": {
572 "limit": 50,
573 "ignore_collected": "true",
574 "ignore_watchlisted": "true",
575 },
576 "pagination": False,
577 }
578 return lists_cache_object(self.get_trakt, string, params)
579
580 def get_watched_shows(self):
581 params = {
582 "path": "sync/watched/shows",
583 "with_auth": True,
584 "pagination": False,
585 "params": {"extended": "full"},
586 }
587 return self.get_trakt(params)
588
589 @staticmethod
590 def _parse_iso_date(value):
591 if not value:
592 return None
593 try:
594 return datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
595 except Exception:
596 return None
597
598 @staticmethod
599 def _is_episode_aired(air_date, now_dt):
600 if not air_date:
601 return True
602 now_date = now_dt.date() if hasattr(now_dt, "date") else now_dt
603 try:
604 return datetime.datetime.strptime(air_date, "%Y-%m-%d").date() <= now_date
605 except ValueError:
606 return True
607
608 @staticmethod
609 def _last_watched_episode(show_item):
610 last_episode = None
611 last_timestamp = None
612
613 for season in show_item.get("seasons", []):
614 season_number = season.get("number")
615 if not season_number or season_number == 0:
616 continue
617
618 for episode in season.get("episodes", []):
619 episode_number = episode.get("number")
620 if not episode_number:
621 continue
622
623 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 )
627 watched_dt = TraktTV._parse_iso_date(watched_at)
628
629 if watched_dt:
630 if last_timestamp is None or watched_dt > last_timestamp:
631 last_timestamp = watched_dt
632 last_episode = episode_key
633 elif last_episode is None or episode_key > last_episode:
634 last_episode = episode_key
635
636 return last_episode, last_timestamp
637
638 @staticmethod
639 def _find_next_episode(fetcher, tmdb_id, starting_point, now_dt):
640 if not tmdb_id or not starting_point:
641 return None
642
643 show_details = fetcher("tv_details", tmdb_id)
644 total_seasons = getattr(show_details, "number_of_seasons", 0) or 0
645 start_season, start_episode = starting_point
646
647 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 )
651 episodes = getattr(season_details, "episodes", []) or []
652 for episode in episodes:
653 episode_number = getattr(episode, "episode_number", 0) or 0
654 if season_number == int(start_season) and episode_number <= int(
655 start_episode
656 ):
657 continue
658
659 air_date = getattr(episode, "air_date", None)
660 if not TraktTV._is_episode_aired(air_date, now_dt):
661 continue
662
663 return {
664 "season": season_number,
665 "number": episode_number,
666 "title": getattr(episode, "name", ""),
667 "first_aired": air_date,
668 }
669
670 return None
671
672 @classmethod
673 def _build_up_next_entries(cls, watched_shows, progress_items, fetcher=None, now_dt=None, hidden_items=None):
674 if fetcher is None:
675 from lib.clients.tmdb.utils.utils import tmdb_get as fetcher
676
677 now_dt = now_dt or get_datetime(string=False)
678 entries = []
679 progress_by_show = {}
680 hidden_items = hidden_items or []
681
682 for item in progress_items or []:
683 show = item.get("show", {})
684 ids = show.get("ids", {})
685 tmdb_id = ids.get("tmdb")
686 episode = item.get("episode", {})
687 if not tmdb_id or int(tmdb_id) in hidden_items or not episode:
688 continue
689
690 season_number = episode.get("season")
691 episode_number = episode.get("number")
692 if not season_number or int(season_number) == 0 or not episode_number:
693 continue
694 if not cls._is_episode_aired(episode.get("first_aired"), now_dt):
695 continue
696
697 progress_entry = {
698 "type": "resume",
699 "show": show,
700 "episode": {
701 "season": season_number,
702 "number": episode_number,
703 "title": episode.get("title", ""),
704 "first_aired": episode.get("first_aired"),
705 },
706 "progress": item.get("progress", 0),
707 "activity_at": item.get("paused_at") or "",
708 }
709
710 existing_entry = progress_by_show.get(int(tmdb_id))
711 if existing_entry:
712 existing_activity = existing_entry.get("activity_at", "")
713 new_activity = progress_entry.get("activity_at", "")
714 if (new_activity, progress_entry.get("progress", 0)) <= (
715 existing_activity,
716 existing_entry.get("progress", 0),
717 ):
718 continue
719
720 progress_by_show[int(tmdb_id)] = progress_entry
721
722 entries.extend(progress_by_show.values())
723
724 for show_item in watched_shows or []:
725 show = show_item.get("show", {})
726 ids = show.get("ids", {})
727 tmdb_id = ids.get("tmdb")
728 if not tmdb_id:
729 continue
730
731 tmdb_id = int(tmdb_id)
732 if tmdb_id in progress_by_show or tmdb_id in hidden_items:
733 continue
734
735 last_episode, last_timestamp = cls._last_watched_episode(show_item)
736 if not last_episode:
737 continue
738
739 next_episode = cls._find_next_episode(fetcher, tmdb_id, last_episode, now_dt)
740 if not next_episode:
741 continue
742
743 entries.append(
744 {
745 "type": "next",
746 "show": show,
747 "episode": next_episode,
748 "progress": 0,
749 "activity_at": last_timestamp.isoformat() if last_timestamp else "",
750 }
751 )
752
753 return sorted(
754 entries,
755 key=lambda item: (
756 1 if item.get("type") == "resume" else 0,
757 item.get("activity_at", ""),
758 item.get("progress", 0),
759 ),
760 reverse=True,
761 )
762
763 def trakt_get_hidden_items(self, section="progress_watched"):
764 def _process(params):
765 result = self.get_trakt(params)
766 hidden = []
767 if result:
768 for item in result:
769 if item.get("type") == "show":
770 tmdb_id = item.get("show", {}).get("ids", {}).get("tmdb")
771 if tmdb_id:
772 hidden.append(int(tmdb_id))
773 return hidden
774
775 string = "trakt_hidden_items_%s" % section
776 params = {
777 "path": "users/hidden/%s" % section,
778 "params": {"limit": 1000},
779 "with_auth": True,
780 "pagination": False,
781 }
782 return cache_trakt_object(_process, string, params)
783
784 def trakt_up_next(self):
785 watched_shows = self.get_watched_shows()
786 progress_items = TraktScrobble().trakt_get_playback_progress("episodes")
787 hidden_items = self.trakt_get_hidden_items("progress_watched")
788 return self._build_up_next_entries(watched_shows, progress_items, hidden_items=hidden_items)
789
790 def trakt_collection(self, page_no):
791 set_pluging_category(translation(90294))
792 params = {
793 "path": "sync/collection/shows",
794 "params": {"limit": 20, "extended": "full"},
795 "page_no": page_no,
796 "with_auth": True,
797 }
798 return self.get_trakt(params)
799
800
801class TraktAnime(TraktBase):
802 def _get_path_prefix(self, mode):
803 return "movies" if mode == "movies" else "shows"
804
805 def trakt_anime_trending(self, page_no, mode="tv"):
806 path_prefix = self._get_path_prefix(mode)
807 string = "trakt_anime_trending_%s_%s" % (mode, page_no)
808 params = {
809 "path": "%s/trending" % path_prefix,
810 "params": {"genres": "anime", "limit": 20},
811 "page_no": page_no,
812 }
813 return lists_cache_object(self.get_trakt, string, params)
814
815 def trakt_anime_trending_recent(self, page_no, mode="tv"):
816 path_prefix = self._get_path_prefix(mode)
817 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)
820 params = {
821 "path": "%s/trending" % path_prefix,
822 "params": {"genres": "anime", "limit": 20, "years": years},
823 "page_no": page_no,
824 }
825 return lists_cache_object(self.get_trakt, string, params)
826
827 def trakt_anime_most_watched(self, page_no, mode="tv"):
828 path_prefix = self._get_path_prefix(mode)
829 string = "trakt_anime_most_watched_%s_%s" % (mode, page_no)
830 params = {
831 "path": "%s/watched/daily" % path_prefix,
832 "params": {"genres": "anime", "limit": 20},
833 "page_no": page_no,
834 }
835 return lists_cache_object(self.get_trakt, string, params)
836
837 def trakt_anime_most_favorited(self, page_no, mode="tv"):
838 path_prefix = self._get_path_prefix(mode)
839 string = "trakt_anime_most_favorited_%s_%s" % (mode, page_no)
840 params = {
841 "path": "%s/favorited/daily" % path_prefix,
842 "params": {"genres": "anime", "limit": 20},
843 "page_no": page_no,
844 }
845 return lists_cache_object(self.get_trakt, string, params)
846
847
848class TraktLists(TraktBase):
849 @staticmethod
850 def _history_payload(media_type, season, episode, ids):
851 if media_type in ("movie", "movies"):
852 media_type_key = "movies"
853 payload = {media_type_key: [{"ids": {"tmdb": int(ids["tmdb"])}}]}
854 else:
855 media_type_key = "shows"
856
857 show_item: Dict[str, Any] = {"ids": {"tmdb": int(ids["tmdb"])}}
858
859 if season:
860 show_item["seasons"] = [{"number": int(season)}]
861
862 if season and episode:
863 show_item["seasons"] = [
864 {"number": int(season), "episodes": [{"number": int(episode)}]}
865 ]
866
867 payload = {media_type_key: [show_item]}
868
869 return payload
870
871 @staticmethod
872 def _ids_match(candidate_ids, target_ids):
873 for key in ("trakt", "tmdb", "tvdb", "imdb"):
874 candidate = candidate_ids.get(key)
875 target = target_ids.get(key) or target_ids.get(f"{key}_id")
876 if candidate and target and str(candidate) == str(target):
877 return True
878 return False
879
880 def get_collection_items(self, media_type):
881 if media_type in ("movie", "movies"):
882 path = "sync/collection/movies"
883 else:
884 path = "sync/collection/shows"
885 return self.get_trakt(
886 {
887 "path": path,
888 "with_auth": True,
889 "pagination": False,
890 "params": {"extended": "full"},
891 }
892 )
893
894 def build_collection_remove_payload(self, media_type, ids):
895 if media_type in ("movie", "movies"):
896 return self._list_item_payload(media_type, ids)
897
898 items = self.get_collection_items(media_type)
899 if not items:
900 return None
901
902 for item in items:
903 show = item.get("show") or item
904 show_ids = show.get("ids", {})
905 if not self._ids_match(show_ids, ids):
906 continue
907
908 payload = {"shows": [{"ids": clean_ids(show_ids)}]}
909 if show.get("title"):
910 payload["shows"][0]["title"] = show.get("title")
911 if show.get("year"):
912 payload["shows"][0]["year"] = show.get("year")
913
914 seasons = item.get("seasons", []) or []
915 if seasons:
916 payload["shows"][0]["seasons"] = []
917 for season in seasons:
918 season_number = season.get("number")
919 if not season_number:
920 continue
921 season_payload = {"number": int(season_number)}
922 episodes = season.get("episodes", []) or []
923 if episodes:
924 season_payload["episodes"] = []
925 for episode in episodes:
926 episode_number = episode.get("number")
927 if episode_number:
928 season_payload["episodes"].append(
929 {"number": int(episode_number)}
930 )
931 payload["shows"][0]["seasons"].append(season_payload)
932 return payload
933
934 return None
935
936 def trakt_watchlist(self, media_type):
937 kodilog("Fetching trakt watchlist")
938 result = self.trakt_fetch_sorted_list("watchlist", media_type)
939 kodilog("Watchlist result: %s" % result, level=xbmc.LOGDEBUG)
940 return result
941
942 def add_to_watchlist(self, media_type, ids):
943 original_media_type = media_type
944 if media_type in ("movie", "movies"):
945 media_type = "movies"
946 else:
947 media_type = "shows"
948
949 payload = self._list_item_payload(media_type, ids)
950
951 result = self.call_trakt(
952 "sync/watchlist",
953 data=payload,
954 with_auth=True,
955 pagination=False,
956 )
957 lists_cache.delete_prefix("trakt_%s_watchlist_" % ("movies" if media_type == "movies" else "tv"))
958 clear_trakt_collection_watchlist_data("watchlist", original_media_type)
959 return result
960
961 def remove_from_watchlist(self, media_type, ids):
962 original_media_type = media_type
963 if media_type in ("movie", "movies"):
964 media_type = "movies"
965 else:
966 media_type = "shows"
967
968 payload = self._list_item_payload(media_type, ids)
969 result = self.call_trakt(
970 "sync/watchlist/remove",
971 data=payload,
972 with_auth=True,
973 pagination=False,
974 )
975 lists_cache.delete_prefix("trakt_%s_watchlist_" % ("movies" if media_type == "movies" else "tv"))
976 clear_trakt_collection_watchlist_data("watchlist", original_media_type)
977 return result
978
979 def trakt_comments(self, media_type, tmdb_id, sort_type="likes", page_no=1):
980 if media_type in ("movie", "movies"):
981 media_type = "movies"
982 else:
983 media_type = "shows"
984
985 trakt_id = self.get_trakt_id_by_tmdb(tmdb_id, media_type=media_type[:-1])
986 if not trakt_id:
987 return []
988
989 params = {
990 "path": f"{media_type}/%s/comments/%s",
991 "path_insert": (trakt_id, sort_type),
992 "with_auth": False,
993 "params": {"limit": 20},
994 "pagination": True,
995 "page_no": page_no,
996 }
997 return self.get_trakt(params)
998
999 def add_to_collection(self, media_type, ids, payload=None):
1000 original_media_type = media_type
1001 if media_type in ("movie", "movies"):
1002 media_type = "movies"
1003 else:
1004 media_type = "shows"
1005
1006 payload = payload or self._list_item_payload(media_type, ids)
1007
1008 result = self.call_trakt(
1009 "sync/collection",
1010 data=payload,
1011 with_auth=True,
1012 pagination=False,
1013 )
1014 lists_cache.delete_prefix("trakt_%s_collection_" % ("movies" if media_type == "movies" else "tv"))
1015 clear_trakt_collection_watchlist_data("collection", original_media_type)
1016 return result
1017
1018 def remove_from_collection(self, media_type, ids, payload=None):
1019 original_media_type = media_type
1020 if media_type in ("movie", "movies"):
1021 media_type = "movies"
1022 else:
1023 media_type = "shows"
1024
1025 payload = payload or self._list_item_payload(media_type, ids)
1026 result = self.call_trakt(
1027 "sync/collection/remove",
1028 data=payload,
1029 with_auth=True,
1030 pagination=False,
1031 )
1032 lists_cache.delete_prefix("trakt_%s_collection_" % ("movies" if media_type == "movies" else "tv"))
1033 clear_trakt_collection_watchlist_data("collection", original_media_type)
1034 return result
1035
1036 def trakt_watched_history(self, media_type, page_no, sort_type="recent"):
1037 def _process(params, media_type):
1038 response = self.get_trakt(params)
1039 history = []
1040 if not response or not isinstance(response, (list, tuple)):
1041 return history
1042 if media_type == "movies":
1043 for item in response:
1044 if item["type"] == "movie":
1045 history.append(
1046 {
1047 "media_ids": item["movie"]["ids"],
1048 "title": item["movie"]["title"],
1049 "type": "movie",
1050 "watched_at": item.get("watched_at"),
1051 }
1052 )
1053 elif media_type == "shows":
1054 for item in response:
1055 if item["type"] == "episode":
1056 history.append(
1057 {
1058 "media_ids": item["show"]["ids"],
1059 "title": f"{item['show']['title']} - S{item['episode']['season']}E{item['episode']['number']} - {item['episode']['title']}",
1060 "type": "show",
1061 "watched_at": item.get("watched_at"),
1062 "show_title": item["show"]["title"],
1063 "ep_title": item["episode"]["title"],
1064 "season": item["episode"]["season"],
1065 "episode": item["episode"]["number"],
1066 }
1067 )
1068 # --- Sorting logic similar to trakt_fetch_sorted_list ---
1069 if sort_type == "recent":
1070 history.sort(key=lambda k: k.get("watched_at") or "", reverse=True)
1071 elif sort_type == "random":
1072 random.shuffle(history)
1073 elif sort_type is None:
1074 # Default: sort by watched_at descending
1075 history.sort(key=lambda k: k.get("watched_at") or "", reverse=True)
1076 return history
1077
1078 if media_type in ("movie", "movies"):
1079 media_type = "movies"
1080 else:
1081 media_type = "shows"
1082
1083 params = {
1084 "path": "sync/history/%s",
1085 "path_insert": media_type,
1086 "page_no": page_no,
1087 "with_auth": True,
1088 }
1089 return _process(params, media_type)
1090
1091 def mark_as_watched(self, media_type, season, episode, ids):
1092 payload = self._history_payload(media_type, season, episode, ids)
1093
1094 kodilog("Payload: %s" % payload)
1095
1096 response = self.call_trakt(
1097 "sync/history",
1098 data=payload,
1099 with_auth=True,
1100 pagination=False,
1101 )
1102
1103 if (
1104 response
1105 and isinstance(response, dict)
1106 and "added" in response
1107 and response["added"].get("movies", 0) == 0
1108 and response["added"].get("episodes", 0) == 0
1109 ):
1110 notification(translation(90387), time=3000)
1111 else:
1112 notification(translation(90388), time=3000)
1113
1114 return response
1115
1116 def mark_as_unwatched(self, media_type, season, episode, ids):
1117 payload = self._history_payload(media_type, season, episode, ids)
1118 return self.call_trakt(
1119 "sync/history/remove",
1120 data=payload,
1121 with_auth=True,
1122 pagination=False,
1123 )
1124
1125 def trakt_fetch_sorted_list(self, list_type, media_type, sort_type=None, limit=None):
1126 data = self.trakt_fetch_watchlist(list_type, media_type)
1127
1128 if sort_type == "recent":
1129 data.sort(key=lambda k: k["collected_at"], reverse=True)
1130 elif sort_type == "random":
1131 random.shuffle(data)
1132 elif sort_type is None and list_type == "watchlist":
1133 sort_order = trakt_lists_sort_order("watchlist")
1134 if sort_order == 0:
1135 data = sort_for_article(data, "title")
1136 elif sort_order == 1:
1137 data.sort(key=lambda k: k["collected_at"], reverse=True)
1138 else:
1139 data.sort(key=lambda k: k["released"], reverse=True)
1140
1141 return data[:limit] if limit else data
1142
1143 def trakt_fetch_watchlist(self, list_type, media_type):
1144 def _process(params):
1145 raw_data = self.get_trakt(params)
1146 if not raw_data:
1147 return []
1148 return [
1149 {
1150 "media_ids": {
1151 "tmdb": item[media_key]["ids"].get("tmdb", ""),
1152 "imdb": item[media_key]["ids"].get("imdb", ""),
1153 "tvdb": item[media_key]["ids"].get("tvdb", ""),
1154 },
1155 "title": item[media_key]["title"],
1156 "collected_at": item.get(collected_at_key),
1157 "released": item[media_key].get(
1158 release_date_key, default_release_date
1159 ),
1160 }
1161 for item in raw_data
1162 ]
1163
1164 if media_type in ("movie", "movies"):
1165 media_key = "movie"
1166 release_date_key = "released"
1167 collected_at_key = "listed_at"
1168 default_release_date = "2050-01-01"
1169 else:
1170 media_key = "show"
1171 release_date_key = "first_aired"
1172 collected_at_key = "listed_at"
1173 default_release_date = self.standby_date
1174
1175 api_path = "sync/%s/%s?extended=full"
1176
1177 params = {
1178 "path": api_path,
1179 "path_insert": (list_type, media_key),
1180 "with_auth": True,
1181 "pagination": False,
1182 }
1183
1184 return _process(params)
1185
1186 def trakt_search_lists(self, search_title, page_no):
1187 def _process(dummy_arg):
1188 result = self.call_trakt(
1189 "search",
1190 params={
1191 "type": "list",
1192 "fields": "name, description",
1193 "query": search_title,
1194 "limit": 50,
1195 },
1196 pagination=True,
1197 page_no=page_no,
1198 )
1199 if isinstance(result, tuple):
1200 result = result[0]
1201 if not result:
1202 return []
1203
1204 normalized = []
1205 for item in result:
1206 list_data = item.get("list", {})
1207 user_data = list_data.get("user", {}) or item.get("user", {}) or {}
1208 ids = list_data.get("ids", {})
1209 user_ids = user_data.get("ids", {})
1210 user_slug = (
1211 user_ids.get("slug")
1212 or user_data.get("username")
1213 or list_data.get("username")
1214 or ""
1215 )
1216 normalized.append(
1217 {
1218 "list_type": "search_lists",
1219 "name": list_data.get("name", ""),
1220 "description": list_data.get("description", ""),
1221 "slug": ids.get("slug", ""),
1222 "trakt_id": ids.get("trakt"),
1223 "user_slug": user_slug,
1224 "username": user_data.get("username", ""),
1225 "item_count": list_data.get("item_count", 0),
1226 "privacy": list_data.get("privacy", "public"),
1227 "with_auth": False,
1228 "can_like": True,
1229 }
1230 )
1231
1232 return normalized
1233
1234 string = "trakt_search_lists_%s_%s" % (search_title, page_no)
1235 return cache_object(_process, string, "dummy_arg", False, 4)
1236
1237 def trakt_favorites(self, media_type):
1238 def _process(params):
1239 result = self.get_trakt(params)
1240 if not result:
1241 return []
1242 return [
1243 {
1244 "media_ids": {
1245 "tmdb": i[i["type"]]["ids"].get("tmdb", ""),
1246 "imdb": i[i["type"]]["ids"].get("imdb", ""),
1247 "tvdb": i[i["type"]]["ids"].get("tvdb", ""),
1248 },
1249 "title": i[i["type"]].get("title", ""),
1250 "type": i["type"],
1251 }
1252 for i in result
1253 ]
1254
1255 media_type = "movies" if media_type in ("movie", "movies") else "shows"
1256 string = "trakt_favorites_%s" % media_type
1257 params = {
1258 "path": "users/me/favorites/%s/%s",
1259 "path_insert": (media_type, "title"),
1260 "with_auth": True,
1261 "pagination": False,
1262 }
1263 return cache_trakt_object(_process, string, params)
1264
1265 def get_trakt_list_contents(self, list_type, user, slug, with_auth, trakt_id=None):
1266 def _process(params):
1267 result = self.get_trakt(params)
1268 if not result:
1269 return []
1270 return [
1271 {
1272 "media_ids": i[i["type"]]["ids"],
1273 "title": i[i["type"]]["title"],
1274 "type": i["type"],
1275 "order": c,
1276 }
1277 for c, i in enumerate(result)
1278 if i["type"] in ("movie", "show")
1279 ]
1280
1281 string = "trakt_list_contents_%s_%s_%s_%s" % (list_type, user, slug, trakt_id)
1282 if list_type == "my_lists" and trakt_id:
1283 params = {
1284 "path": "users/me/lists/%s/items",
1285 "path_insert": trakt_id,
1286 "params": {"extended": "full"},
1287 "with_auth": True,
1288 "method": "sort_by_headers",
1289 }
1290 elif trakt_id:
1291 params = {
1292 "path": "lists/%s/items",
1293 "path_insert": trakt_id,
1294 "params": {"extended": "full"},
1295 "with_auth": with_auth,
1296 "method": "sort_by_headers",
1297 }
1298 else:
1299 params = {
1300 "path": "users/%s/lists/%s/items",
1301 "path_insert": (user, slug),
1302 "params": {"extended": "full"},
1303 "with_auth": with_auth,
1304 "method": "sort_by_headers",
1305 }
1306 return cache_trakt_object(_process, string, params)
1307
1308 def trakt_trending_popular_lists(self, list_type, page_no):
1309 set_pluging_category(translation(90072))
1310 string = "trakt_%s_user_lists_%s" % (list_type, page_no)
1311 params = {
1312 "path": "lists/%s",
1313 "path_insert": list_type,
1314 "params": {"limit": 50},
1315 "page_no": page_no,
1316 }
1317 return cache_object(self.get_trakt, string, params, False)
1318
1319 def trakt_get_lists(self, list_type):
1320 def _process(params):
1321 result = self.get_trakt(params)
1322 if not result:
1323 return []
1324
1325 normalized = []
1326 for item in result:
1327 list_data = item.get("list", item)
1328 user_data = list_data.get("user", {})
1329 ids = list_data.get("ids", {})
1330 user_ids = user_data.get("ids", {})
1331 user_slug = user_ids.get("slug") or user_data.get("username") or ""
1332 normalized.append(
1333 {
1334 "list_type": list_type,
1335 "name": list_data.get("name", ""),
1336 "description": list_data.get("description", ""),
1337 "slug": ids.get("slug", ""),
1338 "trakt_id": ids.get("trakt"),
1339 "user_slug": user_slug,
1340 "username": user_data.get("username", ""),
1341 "item_count": list_data.get("item_count", 0),
1342 "privacy": list_data.get("privacy", "public"),
1343 "with_auth": True,
1344 "can_delete": list_type == "my_lists",
1345 "can_unlike": list_type == "liked_lists",
1346 }
1347 )
1348
1349 return normalized
1350
1351 if list_type == "my_lists":
1352 string = "trakt_my_lists"
1353 path = "users/me/lists"
1354 elif list_type == "liked_lists":
1355 string = "trakt_liked_lists"
1356 path = "users/likes/lists"
1357 else:
1358 return []
1359
1360 params = {
1361 "path": path,
1362 "params": {"limit": 1000},
1363 "pagination": False,
1364 "with_auth": True,
1365 }
1366 return cache_trakt_object(_process, string, params)
1367
1368 def add_to_favorites(self, media_type, ids):
1369 response = self.call_trakt(
1370 "sync/favorites",
1371 data=self._list_item_payload(media_type, ids),
1372 with_auth=True,
1373 pagination=False,
1374 )
1375 clear_trakt_favorites()
1376 return response
1377
1378 def remove_from_favorites(self, media_type, ids):
1379 response = self.call_trakt(
1380 "sync/favorites/remove",
1381 data=self._list_item_payload(media_type, ids),
1382 with_auth=True,
1383 pagination=False,
1384 )
1385 clear_trakt_favorites()
1386 return response
1387
1388 def create_list(self, name, description="", privacy="private"):
1389 response = self.call_trakt(
1390 "users/me/lists",
1391 data={
1392 "name": name,
1393 "description": description,
1394 "privacy": privacy,
1395 "display_numbers": True,
1396 "allow_comments": True,
1397 },
1398 with_auth=True,
1399 pagination=False,
1400 )
1401 clear_trakt_list_data("my_lists")
1402 clear_trakt_list_contents_data("my_lists")
1403 return response
1404
1405 def delete_list(self, trakt_id):
1406 response = self.call_trakt(
1407 "users/me/lists/%s" % trakt_id,
1408 method="delete",
1409 with_auth=True,
1410 pagination=False,
1411 )
1412 clear_trakt_list_data("my_lists")
1413 clear_trakt_list_contents_data("my_lists")
1414 return response
1415
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 )
1435 clear_trakt_list_data("liked_lists")
1436 clear_trakt_list_contents_data("liked_lists")
1437 return response
1438
1439 def _list_item_payload(self, media_type, ids):
1440 media_key = "movies" if media_type in ("movie", "movies") else "shows"
1441 normalized_ids = clean_ids(
1442 {
1443 "tmdb": ids.get("tmdb") or ids.get("tmdb_id"),
1444 "tvdb": ids.get("tvdb") or ids.get("tvdb_id"),
1445 "imdb": ids.get("imdb") or ids.get("imdb_id"),
1446 }
1447 )
1448 for key in ("tmdb", "tvdb"):
1449 if key in normalized_ids:
1450 try:
1451 normalized_ids[key] = int(normalized_ids[key])
1452 except (TypeError, ValueError):
1453 pass
1454
1455 payload = {
1456 media_key: [
1457 {
1458 "ids": normalized_ids
1459 }
1460 ]
1461 }
1462 return payload
1463
1464 def add_item_to_list(self, trakt_id, media_type, ids):
1465 response = self.call_trakt(
1466 "users/me/lists/%s/items" % trakt_id,
1467 data=self._list_item_payload(media_type, ids),
1468 with_auth=True,
1469 pagination=False,
1470 )
1471 clear_trakt_list_data("my_lists")
1472 clear_trakt_list_contents_data("my_lists")
1473 return response
1474
1475 def remove_item_from_list(self, trakt_id, media_type, ids):
1476 response = self.call_trakt(
1477 "users/me/lists/%s/items/remove" % trakt_id,
1478 data=self._list_item_payload(media_type, ids),
1479 with_auth=True,
1480 pagination=False,
1481 )
1482 clear_trakt_list_data("my_lists")
1483 clear_trakt_list_contents_data("my_lists")
1484 return response
1485
1486 def make_trakt_slug(self, name):
1487 import re
1488
1489 name = name.strip()
1490 name = name.lower()
1491 name = re.sub("[^a-z0-9_]", "-", name)
1492 name = re.sub("--+", "-", name)
1493 return name
1494
1495
1496class TraktScrobble(TraktBase):
1497 @staticmethod
1498 def _scrobble_payload(data):
1499 from lib.utils.kodi.utils import ADDON_VERSION
1500 from datetime import datetime
1501 progress = data.get("progress", 0)
1502 if isinstance(progress, str):
1503 try:
1504 progress = float(progress)
1505 except ValueError:
1506 progress = 0.0
1507
1508 payload: Dict[str, Any] = {
1509 "progress": progress,
1510 "app_version": ADDON_VERSION,
1511 "app_date": datetime.now().strftime("%Y-%m-%d")
1512 }
1513
1514 if data["mode"] == "movies":
1515 payload["movie"] = {"ids": {"tmdb": data["ids"]["tmdb_id"]}}
1516 elif data["mode"] == "tv":
1517 if data.get("tv_data") is None:
1518 return None
1519 payload["show"] = {"ids": {"tmdb": data["ids"]["tmdb_id"]}}
1520 payload["episode"] = {
1521 "season": data.get("tv_data").get("season"),
1522 "number": data.get("tv_data").get("episode"),
1523 }
1524
1525 return payload
1526
1527 def trakt_start_scrobble(self, data):
1528 payload = self._scrobble_payload(data)
1529 if payload is None:
1530 return
1531
1532 self.call_trakt("scrobble/start", data=payload, with_auth=True)
1533
1534 def trakt_pause_scrobble(self, data):
1535 payload = self._scrobble_payload(data)
1536 if payload is None or payload.get("progress", 0) < 1.0:
1537 return
1538
1539 self.call_trakt("scrobble/pause", data=payload, with_auth=True)
1540
1541 def trakt_stop_scrobble(self, data):
1542 kodilog("Stopping scrobble")
1543
1544 payload = self._scrobble_payload(data)
1545 if payload is None or payload.get("progress", 0) < 1.0:
1546 return
1547
1548 self.call_trakt("scrobble/stop", data=payload, with_auth=True)
1549
1550 def trakt_get_last_tracked_position(self, data):
1551 try:
1552 media_type = "movies" if data["mode"] == "movies" else "episodes"
1553 season = data.get("tv_data", {}).get("season")
1554 episode = data.get("tv_data", {}).get("episode")
1555 tmdb_id = data.get("ids", {}).get("tmdb_id")
1556
1557 # Check local cache first
1558 if data["mode"] == "movies":
1559 cached_progress = trakt_watched_cache.get_progress(
1560 "movie", str(tmdb_id)
1561 )
1562 else:
1563 cached_progress = trakt_watched_cache.get_progress(
1564 "episode", str(tmdb_id), season, episode
1565 )
1566
1567 if cached_progress > 0:
1568 kodilog(f"Trakt: Found cached resume point: {cached_progress}%")
1569 return cached_progress
1570
1571 path = f"sync/playback/{media_type}"
1572 response = self.call_trakt(path, with_auth=True)
1573 if response:
1574 for item in response:
1575 try:
1576 if item["type"] == "movie":
1577 trakt_tmdb = int(item["movie"]["ids"]["tmdb"])
1578 local_tmdb = int(tmdb_id)
1579 if trakt_tmdb == local_tmdb:
1580 progress = item.get("progress", 0)
1581 kodilog(
1582 f"Trakt: Found cloud resume point for movie: {progress}%"
1583 )
1584 return progress
1585 elif item["type"] == "episode":
1586 trakt_tmdb = int(item["show"]["ids"]["tmdb"])
1587 local_tmdb = int(tmdb_id)
1588 # TV Show comparison logic
1589 if (
1590 trakt_tmdb == local_tmdb
1591 and item["episode"]["season"] == int(season)
1592 and item["episode"]["number"] == int(episode)
1593 ):
1594 progress = item.get("progress", 0)
1595 kodilog(
1596 f"Trakt: Found cloud resume point for episode: {progress}%"
1597 )
1598 return progress
1599 except Exception:
1600 continue
1601
1602 except Exception as e:
1603 kodilog(f"Trakt: Error fetching last tracked position: {e}")
1604 return 0.0
1605
1606 return 0.0
1607
1608 def trakt_get_playback_progress(self, media_type):
1609 # media_type: "movies" or "episodes"
1610 try:
1611 path = f"sync/playback/{media_type}"
1612 params = {
1613 "path": path,
1614 "params": {"limit": 1000}, # Safety limit
1615 "with_auth": True,
1616 "pagination": False,
1617 }
1618 return self.get_trakt(params)
1619 except Exception as e:
1620 kodilog(f"Error fetching playback progress: {e}")
1621 return []
1622 return 0
1623
1624
1625class TraktCache(TraktBase):
1626 def clear_all_trakt_cache_data(self):
1627 try:
1628 dbcon = connect_database("trakt_db")
1629 for table in ("trakt_data", "progress", "watched", "watched_status"):
1630 dbcon.execute(BASE_DELETE % table)
1631 dbcon.execute("VACUUM")
1632 return True
1633 except:
1634 return False
1635
1636 def clear_cache(self, cache_type):
1637 success = True
1638 if cache_type == "trakt":
1639 success = self.clear_all_trakt_cache_data()
1640 elif cache_type == "list":
1641 success = lists_cache.delete_all_lists()
1642 if success:
1643 return success
1644
1645
1646class TraktCalendar(TraktBase):
1647 def trakt_my_calendar(self, start_date, days):
1648 params = {
1649 "path": "calendars/my/shows/%s/%s",
1650 "path_insert": (start_date, days),
1651 "with_auth": True,
1652 "pagination": False,
1653 "params": {"extended": "full"},
1654 }
1655 return self.get_trakt(params)
1656
1657 def trakt_all_shows_calendar(self, start_date, days):
1658 params = {
1659 "path": "calendars/all/shows/%s/%s",
1660 "path_insert": (start_date, days),
1661 "with_auth": True,
1662 "pagination": False,
1663 "params": {"extended": "full"},
1664 }
1665 return self.get_trakt(params)
1666
1667
1668class TraktSync(TraktBase):
1669 def get_last_activities(self):
1670 return self.call_trakt(
1671 "sync/last_activities",
1672 with_auth=True,
1673 pagination=False,
1674 )
1675
1676
1677class TraktAPI:
1678 def __init__(self):
1679 self.auth = TraktAuthentication()
1680 self.movies = TraktMovies()
1681 self.tv = TraktTV()
1682 self.anime = TraktAnime()
1683 self.lists = TraktLists()
1684 self.scrobble = TraktScrobble()
1685 self.sync = TraktSync()
1686 self.cache = TraktCache()
1687 self.calendar = TraktCalendar()
1688
1689
1690class ProviderException(Exception):
1691 def __init__(self, message):
1692 self.message = message
1693 super().__init__(self.message)
1694 notification(self.message)
plugin.video.jacktook/lib/api/trakt/trakt_cache.py +301 -0
……@@ -0,0 +1,301 @@
1from lib.api.trakt.base_cache import connect_database
2
3
4SELECT = "SELECT id FROM trakt_data"
5DELETE = "DELETE FROM trakt_data WHERE id=?"
6DELETE_LIKE = 'DELETE FROM trakt_data WHERE id LIKE "%s"'
7WATCHED_INSERT = "INSERT OR IGNORE INTO watched VALUES (?, ?, ?, ?, ?, ?)"
8WATCHED_DELETE = "DELETE FROM watched WHERE db_type = ?"
9PROGRESS_INSERT = "INSERT OR IGNORE INTO progress VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
10PROGRESS_DELETE = "DELETE FROM progress WHERE db_type = ?"
11STATUS_INSERT = "INSERT INTO watched_status VALUES (?, ?, ?)"
12STATUS_DELETE = "DELETE FROM watched_status"
13BASE_DELETE = "DELETE FROM %s"
14TC_BASE_GET = "SELECT data FROM trakt_data WHERE id = ?"
15TC_BASE_SET = "INSERT OR REPLACE INTO trakt_data (id, data) VALUES (?, ?)"
16TC_BASE_DELETE = "DELETE FROM trakt_data WHERE id = ?"
17
18
19class TraktCache:
20 def get(self, string):
21 result = None
22 try:
23 dbcon = connect_database("trakt_db")
24 cache_data = dbcon.execute(TC_BASE_GET, (string,)).fetchone()
25 if cache_data:
26 result = eval(cache_data[0])
27 except:
28 pass
29 return result
30
31 def set(self, string, data):
32 try:
33 dbcon = connect_database("trakt_db")
34 dbcon.execute(TC_BASE_SET, (string, repr(data)))
35 except:
36 return None
37
38 def delete(self, string):
39 try:
40 dbcon = connect_database("trakt_db")
41 dbcon.execute(TC_BASE_DELETE, (string,))
42 except:
43 pass
44
45 def clear_all(self):
46 try:
47 dbcon = connect_database("trakt_db")
48 dbcon.execute("DELETE FROM trakt_data")
49 dbcon.execute("VACUUM")
50 except:
51 pass
52
53
54trakt_cache = TraktCache()
55
56
57class TraktWatched:
58 def set_bulk_tvshow_status(self, insert_list):
59 self._delete(STATUS_DELETE, ())
60 self._executemany(STATUS_INSERT, insert_list)
61
62 def set_tvshow_status(self, insert_dict):
63 dbcon = connect_database("trakt_db")
64 dbcon.execute(
65 "INSERT OR REPLACE INTO trakt_data (id, data) VALUES (?, ?)",
66 (
67 "trakt_tvshow_status",
68 repr(insert_dict),
69 ),
70 )
71
72 def set_bulk_movie_watched(self, insert_list):
73 self._delete(WATCHED_DELETE, ("movie",))
74 self._executemany(WATCHED_INSERT, insert_list)
75
76 def set_bulk_tvshow_watched(self, insert_list):
77 self._delete(WATCHED_DELETE, ("episode",))
78 self._executemany(WATCHED_INSERT, insert_list)
79
80 def set_bulk_movie_progress(self, insert_list):
81 self._delete(PROGRESS_DELETE, ("movie",))
82 self._executemany(PROGRESS_INSERT, insert_list)
83
84 def set_bulk_tvshow_progress(self, insert_list):
85 self._delete(PROGRESS_DELETE, ("episode",))
86 self._executemany(PROGRESS_INSERT, insert_list)
87
88 def clear_all(self):
89 try:
90 self._delete("DELETE FROM watched", ())
91 self._delete("DELETE FROM progress", ())
92 self._delete("DELETE FROM watched_status", ())
93 except:
94 pass
95
96 def _executemany(self, command, insert_list):
97 dbcon = connect_database("trakt_db")
98 dbcon.executemany(command, insert_list)
99
100 def _delete(self, command, args):
101 dbcon = connect_database("trakt_db")
102 dbcon.execute(command, args)
103 dbcon.execute("VACUUM")
104
105 def get_watched_status(self, db_type, media_id, season=None, episode=None):
106 try:
107 dbcon = connect_database("trakt_db")
108 if db_type == "movie":
109 command = "SELECT 1 FROM watched WHERE db_type=? AND media_id=?"
110 args = (db_type, media_id)
111 else:
112 command = "SELECT 1 FROM watched WHERE db_type=? AND media_id=? AND season=? AND episode=?"
113 args = (db_type, media_id, season, episode)
114
115 result = dbcon.execute(command, args).fetchone()
116 return result is not None
117 except:
118 return False
119
120 def get_progress(self, db_type, media_id, season=None, episode=None):
121 try:
122 dbcon = connect_database("trakt_db")
123 if db_type == "movie":
124 command = (
125 "SELECT resume_point FROM progress WHERE db_type=? AND media_id=?"
126 )
127 args = (db_type, media_id)
128 else:
129 command = "SELECT resume_point FROM progress WHERE db_type=? AND media_id=? AND season=? AND episode=?"
130 args = (db_type, media_id, season, episode)
131
132 result = dbcon.execute(command, args).fetchone()
133 if result:
134 return float(result[0])
135 return 0.0
136 except:
137 return 0.0
138
139
140trakt_watched_cache = TraktWatched()
141
142
143def cache_trakt_object(function, string, url):
144 cache = trakt_cache.get(string)
145 if cache:
146 return cache
147 result = function(url)
148 trakt_cache.set(string, result)
149 return result
150
151
152def reset_activity(latest_activities):
153 string = "trakt_get_activity"
154 try:
155 dbcon = connect_database("trakt_db")
156 data = dbcon.execute(TC_BASE_GET, (string,)).fetchone()
157 if data:
158 cached_data = eval(data[0])
159 else:
160 cached_data = default_activities()
161 dbcon.execute(DELETE, (string,))
162 trakt_cache.set(string, latest_activities)
163 except:
164 cached_data = default_activities()
165 return cached_data
166
167
168def clear_trakt_hidden_data(list_type):
169 string = "trakt_hidden_items_%s" % list_type
170 try:
171 dbcon = connect_database("trakt_db")
172 dbcon.execute(DELETE, (string,))
173 except:
174 pass
175
176
177def clear_trakt_collection_watchlist_data(list_type, media_type):
178 if media_type == "movies":
179 media_type = "movie"
180 if media_type in ("tvshows", "shows"):
181 media_type = "tvshow"
182 string = "trakt_%s_%s" % (list_type, media_type)
183 try:
184 dbcon = connect_database("trakt_db")
185 dbcon.execute(DELETE, (string,))
186 except:
187 pass
188
189
190def clear_trakt_list_contents_data(list_type):
191 string = "trakt_list_contents_" + list_type + "_%"
192 try:
193 dbcon = connect_database("trakt_db")
194 dbcon.execute(DELETE_LIKE % string)
195 except:
196 pass
197
198
199def clear_trakt_list_data(list_type):
200 string = "trakt_%s" % list_type
201 try:
202 dbcon = connect_database("trakt_db")
203 dbcon.execute(DELETE, (string,))
204 except:
205 pass
206
207
208def clear_trakt_calendar():
209 try:
210 dbcon = connect_database("trakt_db")
211 dbcon.execute(DELETE_LIKE % "trakt_get_my_calendar_%")
212 except:
213 return
214
215
216def clear_trakt_recommendations():
217 try:
218 dbcon = connect_database("trakt_db")
219 dbcon.execute(DELETE_LIKE % "trakt_recommendations_%")
220 except:
221 return
222
223
224def clear_trakt_favorites():
225 try:
226 dbcon = connect_database("trakt_db")
227 dbcon.execute(DELETE_LIKE % "trakt_favorites_%")
228 except:
229 return
230
231
232def clear_trakt_watchlist():
233 """
234 Clear the cached Trakt.tv watchlist.
235 """
236 try:
237 dbcon = connect_database("trakt_db")
238 dbcon.execute(DELETE_LIKE % "trakt_watchlist_%")
239 except:
240 pass
241
242
243def default_activities():
244 return {
245 "all": "2024-01-22T00:22:21.000Z",
246 "movies": {
247 "watched_at": "2020-01-01T00:00:01.000Z",
248 "collected_at": "2020-01-01T00:00:01.000Z",
249 "rated_at": "2020-01-01T00:00:01.000Z",
250 "watchlisted_at": "2020-01-01T00:00:01.000Z",
251 "favorited_at": "2020-01-01T00:00:01.000Z",
252 "recommendations_at": "2020-01-01T00:00:01.000Z",
253 "commented_at": "2020-01-01T00:00:01.000Z",
254 "paused_at": "2020-01-01T00:00:01.000Z",
255 "hidden_at": "2020-01-01T00:00:01.000Z",
256 },
257 "episodes": {
258 "watched_at": "2020-01-01T00:00:01.000Z",
259 "collected_at": "2020-01-01T00:00:01.000Z",
260 "rated_at": "2020-01-01T00:00:01.000Z",
261 "watchlisted_at": "2020-01-01T00:00:01.000Z",
262 "commented_at": "2020-01-01T00:00:01.000Z",
263 "paused_at": "2020-01-01T00:00:01.000Z",
264 },
265 "shows": {
266 "rated_at": "2020-01-01T00:00:01.000Z",
267 "watchlisted_at": "2020-01-01T00:00:01.000Z",
268 "favorited_at": "2020-01-01T00:00:01.000Z",
269 "recommendations_at": "2020-01-01T00:00:01.000Z",
270 "commented_at": "2020-01-01T00:00:01.000Z",
271 "hidden_at": "2020-01-01T00:00:01.000Z",
272 },
273 "seasons": {
274 "rated_at": "2020-01-01T00:00:01.000Z",
275 "watchlisted_at": "2020-01-01T00:00:01.000Z",
276 "commented_at": "2020-01-01T00:00:01.000Z",
277 "hidden_at": "2020-01-01T00:00:01.000Z",
278 },
279 "comments": {
280 "liked_at": "2020-01-01T00:00:01.000Z",
281 "blocked_at": "2020-01-01T00:00:01.000Z",
282 },
283 "lists": {
284 "liked_at": "2020-01-01T00:00:01.000Z",
285 "updated_at": "2020-01-01T00:00:01.000Z",
286 "commented_at": "2020-01-01T00:00:01.000Z",
287 },
288 "watchlist": {"updated_at": "2020-01-01T00:00:01.000Z"},
289 "favorites": {"updated_at": "2020-01-01T00:00:01.000Z"},
290 "recommendations": {"updated_at": "2020-01-01T00:00:01.000Z"},
291 "collaborations": {"updated_at": "2020-01-01T00:00:01.000Z"},
292 "account": {
293 "settings_at": "2020-01-01T00:00:01.000Z",
294 "followed_at": "2020-01-01T00:00:01.000Z",
295 "following_at": "2020-01-01T00:00:01.000Z",
296 "pending_at": "2020-01-01T00:00:01.000Z",
297 "requested_at": "2020-01-01T00:00:01.000Z",
298 },
299 "saved_filters": {"updated_at": "2020-01-01T00:00:01.000Z"},
300 "notes": {"updated_at": "2020-01-01T00:00:01.000Z"},
301 }
plugin.video.jacktook/lib/api/trakt/trakt_utils.py +273 -0
……@@ -0,0 +1,273 @@
1from datetime import datetime
2import random
3import re
4import time
5import json
6import xbmc
7from lib.utils.kodi.utils import action_url_run, kodilog
8from lib.utils.kodi.settings import get_setting
9
10
11def is_trakt_auth():
12 if not get_setting("is_trakt_auth"):
13 kodilog("Trakt is not authenticated", level=xbmc.LOGDEBUG)
14 return False
15 kodilog("Trakt is authenticated", level=xbmc.LOGDEBUG)
16 return True
17
18
19def _filter_trakt_actions(actions, include_add=True, include_remove=True):
20 filtered = []
21 for label, command, action_type in actions:
22 if action_type == "add" and not include_add:
23 continue
24 if action_type == "remove" and not include_remove:
25 continue
26 filtered.append((label, command))
27 return filtered
28
29
30def add_trakt_watchlist_context_menu(media_type, ids, include_add=True, include_remove=True):
31 filtered_ids = clean_ids(
32 {
33 "tmdb": ids.get("tmdb_id") or ids.get("tmdb"),
34 "tvdb": ids.get("tvdb_id") or ids.get("tvdb"),
35 "imdb": ids.get("imdb_id") or ids.get("imdb"),
36 }
37 )
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),
45 ),
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),
54 ),
55 "remove",
56 ),
57 ], include_add, include_remove)
58
59
60def add_trakt_watched_context_menu(
61 media_type, season=None, episode=None, ids={}, include_add=True, include_remove=True
62):
63 filtered_ids = clean_ids(
64 {
65 "tmdb": ids.get("tmdb_id") or ids.get("tmdb"),
66 "tvdb": ids.get("tvdb_id") or ids.get("tvdb"),
67 "imdb": ids.get("imdb_id") or ids.get("imdb"),
68 }
69 )
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),
79 ),
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),
90 ),
91 "remove",
92 ),
93 ], include_add, include_remove)
94
95
96def add_trakt_collection_context_menu(media_type, ids, include_add=True, include_remove=True):
97 filtered_ids = clean_ids(
98 {
99 "tmdb": ids.get("tmdb_id") or ids.get("tmdb"),
100 "tvdb": ids.get("tvdb_id") or ids.get("tvdb"),
101 "imdb": ids.get("imdb_id") or ids.get("imdb"),
102 }
103 )
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),
111 ),
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),
120 ),
121 "remove",
122 ),
123 ], include_add, include_remove)
124
125
126def add_trakt_favorites_context_menu(media_type, ids, include_add=True, include_remove=True):
127 filtered_ids = clean_ids(
128 {
129 "tmdb": ids.get("tmdb_id") or ids.get("tmdb"),
130 "tvdb": ids.get("tvdb_id") or ids.get("tvdb"),
131 "imdb": ids.get("imdb_id") or ids.get("imdb"),
132 }
133 )
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),
141 ),
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),
150 ),
151 "remove",
152 ),
153 ], include_add, include_remove)
154
155
156def add_trakt_custom_list_context_menu(media_type, ids, include_add=True, include_remove=True):
157 filtered_ids = clean_ids(
158 {
159 "tmdb": ids.get("tmdb_id") or ids.get("tmdb"),
160 "tvdb": ids.get("tvdb_id") or ids.get("tvdb"),
161 "imdb": ids.get("imdb_id") or ids.get("imdb"),
162 }
163 )
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),
171 ),
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),
180 ),
181 "remove",
182 ),
183 ], include_add, include_remove)
184
185
186def clean_ids(ids_dict):
187 return {k: v for k, v in ids_dict.items() if v not in (None, "", "null")}
188
189
190def jsondate_to_datetime(jsondate_object, resformat, remove_time=False):
191 if not jsondate_object:
192 return None
193 if remove_time:
194 datetime_object = datetime_workaround(jsondate_object, resformat).date()
195 else:
196 datetime_object = datetime_workaround(jsondate_object, resformat)
197 return datetime_object
198
199
200def datetime_workaround(data, str_format):
201 try:
202 datetime_object = datetime.strptime(data, str_format)
203 except:
204 datetime_object = datetime(*(time.strptime(data, str_format)[0:6]))
205 return datetime_object
206
207
208def sort_list(sort_key, sort_direction, list_data):
209 try:
210 reverse = sort_direction != "asc"
211 if sort_key == "rank":
212 return sorted(list_data, key=lambda x: x["rank"], reverse=reverse)
213 if sort_key == "added":
214 return sorted(list_data, key=lambda x: x["listed_at"], reverse=reverse)
215 if sort_key == "title":
216 return sorted(
217 list_data,
218 key=lambda x: title_key(x[x["type"]].get("title")),
219 reverse=reverse,
220 )
221 if sort_key == "released":
222 return sorted(
223 list_data, key=lambda x: released_key(x[x["type"]]), reverse=reverse
224 )
225 if sort_key == "runtime":
226 return sorted(
227 list_data, key=lambda x: x[x["type"]].get("runtime", 0), reverse=reverse
228 )
229 if sort_key == "popularity":
230 return sorted(
231 list_data, key=lambda x: x[x["type"]].get("votes", 0), reverse=reverse
232 )
233 if sort_key == "percentage":
234 return sorted(
235 list_data, key=lambda x: x[x["type"]].get("rating", 0), reverse=reverse
236 )
237 if sort_key == "votes":
238 return sorted(
239 list_data, key=lambda x: x[x["type"]].get("votes", 0), reverse=reverse
240 )
241 if sort_key == "random":
242 return sorted(list_data, key=lambda k: random.random())
243 return list_data
244 except:
245 return list_data
246
247
248def released_key(item):
249 if "released" in item:
250 return item["released"] or "2050-01-01"
251 if "first_aired" in item:
252 return item["first_aired"] or "2050-01-01"
253 return "2050-01-01"
254
255
256def title_key(title):
257 try:
258 if title is None:
259 title = ""
260 articles = ["the", "a", "an"]
261 match = re.match(r"^((\w+)\s+)", title.lower())
262 if match and match.group(2) in articles:
263 offset = len(match.group(1))
264 else:
265 offset = 0
266 return title[offset:]
267 except:
268 return title
269
270
271def sort_for_article(_list, _key):
272 _list.sort(key=lambda k: re.sub(r"(^the |^a |^an )", "", k[_key].lower()))
273 return _list
plugin.video.jacktook/lib/api/tvdbapi/__init__.py +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/lib/api/tvdbapi/tvdbapi.py +59 -0
……@@ -0,0 +1,59 @@
1import requests
2from datetime import timedelta
3from lib.db.cached import cache
4from lib.utils.kodi.settings import is_cache_enabled, get_cache_expiration
5
6class TVDBAPI:
7 def __init__(self):
8 self.headers = {"User-Agent": "TheTVDB v.4 TV Scraper for Kodi"}
9 self.apiKey = {"apikey": "edae60dc-1b44-4bac-8db7-65c0aaf5258b"}
10 self.baseUrl = "https://api4.thetvdb.com/v4/"
11 self.art = {}
12 self.request_response = None
13 self.threads = []
14
15 def get_token(self):
16 identifier = "tvdb_token"
17 token = cache.get(identifier)
18 if token:
19 return token
20 else:
21 res = requests.post(self.baseUrl + "login", json=self.apiKey, headers=self.headers)
22 data = res.json()
23 token = data["data"].get("token")
24 cache.set(
25 identifier,
26 token,
27 timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0),
28 )
29 return token
30
31 def get_request(self, url):
32 token = self.get_token()
33 self.headers.update(
34 {
35 "Authorization": "Bearer {0}".format(token),
36 "Accept": "application/json"
37 }
38 )
39 url = self.baseUrl + url
40 response = requests.get(url, headers=self.headers)
41 if response:
42 response = response.json().get("data")
43 self.request_response = response
44 return response
45 else:
46 return None
47
48 def get_imdb_id(self, tvdb_id):
49 imdb_id = None
50 url = "series/{}/extended".format(tvdb_id)
51 data = self.get_request(url)
52 if data:
53 imdb_id = [x.get("id") for x in data["remoteIds"] if x.get("type") == 2]
54 return imdb_id[0] if imdb_id else None
55
56 def get_seasons(self, tvdb_id):
57 url = "seasons/{}/extended".format(tvdb_id)
58 data = self.get_request(url)
59 return data
plugin.video.jacktook/lib/api/webdav/webdav.py +198 -0
……@@ -0,0 +1,198 @@
1import os
2from urllib.parse import unquote, urlparse
3import requests
4from requests.auth import HTTPBasicAuth
5import xml.etree.ElementTree as ET
6from lib.utils.kodi.utils import kodilog
7
8
9VIDEO_EXTS = {
10 ".mkv",
11 ".mp4",
12 ".avi",
13 ".mov",
14 ".ts",
15 ".m2ts",
16 ".wmv",
17 ".flv",
18 ".webm",
19 ".iso",
20}
21AUDIO_EXTS = {".mp3", ".flac", ".wav", ".aac", ".m4a", ".ogg", ".wma", ".opus"}
22IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".tiff", ".ico"}
23TEXT_EXTS = {".txt", ".log", ".nfo", ".xml", ".json", ".md", ".srt"}
24
25
26class WebDAVClient:
27 def __init__(
28 self, hostname, username=None, password=None, port=None, remote_path=None
29 ):
30 self.username = username
31 self.password = password
32 kodilog(f"WebDAV Client initialized with hostname: {hostname}")
33
34 hostname = hostname.strip().rstrip("/")
35
36 if hostname.startswith("http://") or hostname.startswith("https://"):
37 base = hostname
38 else:
39 base = "http://" + hostname
40
41 # Store scheme for URL construction
42 self.scheme = "https" if base.startswith("https://") else "http"
43
44 remote_path = remote_path.strip("/") if remote_path else ""
45
46 if port and str(port).strip():
47 # Check if port is already in hostname
48 if ":" in hostname.replace("://", ""):
49 self.server_root = base
50 else:
51 self.server_root = f"{base}:{port}"
52 else:
53 self.server_root = base
54
55 # Use remote_path if provided, otherwise server_root
56 if remote_path:
57 self.api_root = f"{self.server_root}/{remote_path}"
58 else:
59 self.api_root = self.server_root
60
61 kodilog(f"WebDAV Server Root: {self.server_root}")
62 kodilog(f"WebDAV API Root: {self.api_root}")
63
64 self.auth = HTTPBasicAuth(username, password) if username and password else None
65
66 def _get_file_type(self, filename):
67 """Determine file type based on extension."""
68 _, ext = os.path.splitext(filename)
69 ext = ext.lower()
70
71 if ext in VIDEO_EXTS:
72 return "video"
73 elif ext in AUDIO_EXTS:
74 return "audio"
75 elif ext in IMAGE_EXTS:
76 return "image"
77 elif ext in TEXT_EXTS:
78 return "text"
79 return "file"
80
81 def list_dir(self, relative_path=""):
82 # Construct URL carefully to avoid double slashes but ensure trailing slash for PROPFIND
83 path_parts = [self.api_root]
84 if relative_path:
85 path_parts.append(relative_path.strip("/"))
86
87 url = "/".join(path_parts).rstrip("/") + "/"
88 kodilog(f"WebDAV URL: {url}")
89
90 headers = {"Depth": "1"}
91 try:
92 r = requests.request(
93 "PROPFIND", url, headers=headers, auth=self.auth, timeout=15
94 )
95 r.raise_for_status()
96 except requests.exceptions.RequestException as e:
97 # Check if it's an HTTP error with a response
98 if hasattr(e, "response") and e.response is not None:
99 kodilog(f"WebDAV Error {e.response.status_code}: {e}")
100 kodilog(f"WebDAV Error Response Content: {e.response.text[:500]}")
101 else:
102 kodilog(f"WebDAV Error: {e}")
103 return []
104
105 tree = ET.fromstring(r.text)
106 items = []
107 requested_path_norm = unquote(urlparse(url).path).rstrip("/")
108
109 for resp in tree.findall(".//{DAV:}response"):
110 href_elem = resp.find("{DAV:}href")
111 if href_elem is None:
112 continue
113
114 href = href_elem.text
115 href_decoded = unquote(href)
116 href_path_norm = urlparse(href_decoded).path.rstrip("/")
117
118 if href_path_norm == requested_path_norm:
119 continue
120
121 name = href_path_norm.split("/")[-1]
122 if not name:
123 continue
124
125 # Determine if it's a folder
126 is_directory = href.endswith("/")
127 res_type = resp.find(".//{DAV:}resourcetype")
128 if res_type is not None and res_type.find("{DAV:}collection") is not None:
129 is_directory = True
130
131 if is_directory:
132 items.append({"name": name, "type": "folder"})
133 else:
134 # Detect specific file type
135 file_type = self._get_file_type(name)
136
137 # Build Auth URL
138 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://", ""
145 )
146
147 # Extract only the path from href to avoid issues with absolute URLs
148 # (e.g. server returning http://localhost:8080/...)
149 href_path = urlparse(href).path
150 file_url = (
151 f"{self.scheme}://{creds}{clean_host}/{href_path.lstrip('/')}"
152 )
153
154 items.append(
155 {
156 "name": name,
157 "type": file_type,
158 "url": file_url,
159 }
160 )
161
162 return items
163
164 def test_connection(self):
165 if not self.api_root:
166 return {"success": False, "message": "WebDAV hostname not set."}
167
168 # Ensure trailing slash for directory PROPFIND
169 url = self.api_root.rstrip("/") + "/"
170
171 try:
172 headers = {"Depth": "1"}
173 kodilog(f"Testing connection to {url}")
174 r = requests.request(
175 "PROPFIND", url, headers=headers, auth=self.auth, timeout=10
176 )
177 r.raise_for_status()
178
179 # Parse XML to make sure server responded properly
180 ET.fromstring(r.text)
181
182 return {
183 "success": True,
184 "message": f"WebDAV server reachable",
185 }
186
187 except requests.exceptions.RequestException as e:
188 msg = f"Connection failed: {e}"
189 if hasattr(e, "response") and e.response is not None:
190 msg = f"WebDAV Error {e.response.status_code}: {e}"
191 kodilog(f"Test Connection Response Content: {e.response.text[:500]}")
192 kodilog(msg)
193 return {"success": False, "message": msg}
194 except ET.ParseError:
195 return {
196 "success": False,
197 "message": "Invalid response from WebDAV server. (Not XML)",
198 }
plugin.video.jacktook/lib/clients/anilist.py +178 -0
……@@ -0,0 +1,178 @@
1import requests
2
3
4class AniList:
5 def __init__(self, client_id, client_secret):
6 self.base_url = "https://graphql.anilist.co"
7 self.auth_url = "https://anilist.co/api/v2/oauth/token"
8 self.token = self.get_token(client_id, client_secret)
9 self.auth = {"Authorization": f"Bearer {self.token}"}
10
11 def make_request(self, query, variables):
12 res = requests.post(
13 self.base_url,
14 json={"query": query, "variables": variables},
15 headers=self.auth,
16 )
17
18 if res.status_code == 200:
19 media = res.json()["data"]["Page"]
20 return media
21 else:
22 raise Exception(f"Error:{res.text}")
23
24 def search(self, query, page, perPage=15):
25 variables = {
26 "query": query,
27 "page": page,
28 "perPage": perPage,
29 }
30 return self.make_request(SEARCH, variables)
31
32 def get_popular(self, page, perPage):
33 variables = {
34 "page": page,
35 "perPage": perPage,
36 "type": "ANIME",
37 "sort": "POPULARITY_DESC",
38 }
39 return self.make_request(BASE_GRAPH, variables)
40
41 def get_trending(self, page, perPage):
42 variables = {
43 "page": page,
44 "perPage": perPage,
45 "type": "ANIME",
46 "sort": "TRENDING_DESC",
47 }
48 return self.make_request(BASE_GRAPH, variables)
49
50 def get_by_id(self, id):
51 variables = {"id": id}
52
53 res = requests.post(
54 self.base_url,
55 json={"query": SEARCH_ID, "variables": variables},
56 headers=self.auth,
57 )
58
59 if res.status_code == 200:
60 media = res.json()["data"]["Media"]
61 return media
62 else:
63 raise Exception(f"Anilist Error:{res.text}")
64
65 def get_token(self, client_id, client_secret):
66 data = {
67 "grant_type": "client_credentials",
68 "client_id": client_id,
69 "client_secret": client_secret,
70 }
71 res = requests.post(self.auth_url, data=data)
72 if res.status_code == 200:
73 return res.json()["access_token"]
74 else:
75 raise Exception(f"Anilist Error:{res.text}")
76
77
78# GraphQL Anilist
79BASE_GRAPH = """
80 query (
81 $page: Int,
82 $perPage: Int,
83 $sort: [MediaSort],
84 $format:[MediaFormat],
85 $season: MediaSeason,
86 $type: MediaType,
87 $status: MediaStatus,
88 ) {
89 Page (page: $page, perPage: $perPage) {
90 ANIME: media (
91 type: $type,
92 format_in: $format,
93 season: $season,
94 status: $status,
95 sort: $sort
96 ) {
97 id
98 idMal
99 title {
100 english
101 romaji
102 }
103 format
104 episodes
105 status
106 description
107 startDate {
108 year, month, day
109 }
110 coverImage {
111 large
112 }
113 }
114 }
115 }
116 """
117
118
119SEARCH = """
120 query (
121 $query: String,
122 $page: Int,
123 $perPage: Int,
124 $format:[MediaFormat]
125 ) {
126 Page (page: $page, perPage: $perPage) {
127 ANIME: media (
128 search: $query,
129 type: ANIME,
130 format_in: $format,
131 ) {
132 id
133 idMal
134 format
135 title {
136 english
137 romaji
138 }
139 episodes
140 description
141 coverImage {
142 large
143 }
144 }
145 }
146 }
147 """
148
149SEARCH_ID = """
150 query ($id: Int) {
151 Media (
152 id: $id,
153 type: ANIME
154 ) {
155 id
156 idMal
157 title {
158 romaji
159 english
160 native
161 }
162 episodes
163 description
164 coverImage {
165 large
166 }
167 }
168 }
169 """
170
171
172def anilist_client():
173 return AniList(
174 get_setting("anilist_client_id", "14375"),
175 get_setting(
176 "anilist_client_secret", "tOJ5CJA9JM2pmJrHM8XaZgnM9XgL7HaLTM3krdML"
177 ),
178 )
plugin.video.jacktook/lib/clients/anizip.py +25 -0
……@@ -0,0 +1,25 @@
1import requests
2
3
4class AniZipApi:
5 base_url = "https://api.ani.zip/"
6
7 def mapping(self, anilist_id):
8 params = {"anilist_id": anilist_id}
9 res = self.make_request(url="mappings", params=params)
10 if res:
11 return res["mappings"]
12
13 def episodes(self, anilist_id):
14 params = {"anilist_id": anilist_id}
15 res = self.make_request(url="mappings", params=params)
16 if res:
17 return res["episodes"]
18
19 def make_request(self, url, params):
20 res = requests.get(
21 self.base_url + url,
22 params=params,
23 )
24 if res.status_code == 200:
25 return res.json()
plugin.video.jacktook/lib/clients/base.py +34 -0
……@@ -0,0 +1,34 @@
1from abc import ABC, abstractmethod
2from requests import Session
3from typing import Any, List, Optional, Callable
4from lib.domain.torrent import TorrentStream
5from lib.utils.kodi.utils import notification
6
7
8class BaseClient(ABC):
9 def __init__(self, host: Optional[str], notification: Optional[Callable]) -> None:
10 self.host = host.rstrip("/") if host else ""
11 self.notification = notification
12 self.session = Session()
13
14 @abstractmethod
15 def search(
16 self,
17 tmdb_id: str,
18 query: str,
19 mode: str,
20 media_type: str,
21 season: Optional[int],
22 episode: Optional[int],
23 ) -> List[TorrentStream]:
24 pass
25
26 @abstractmethod
27 def parse_response(self, res: Any) -> List[TorrentStream]:
28 pass
29
30 def handle_exception(self, exception: str) -> None:
31 exception_message = str(exception)
32 if len(exception_message) > 70:
33 exception_message = exception_message[:70] + "..."
34 notification(exception_message)
plugin.video.jacktook/lib/clients/debrid/alldebrid.py +210 -0
……@@ -0,0 +1,210 @@
1from datetime import datetime
2from typing import Dict, List, Any, Optional
3
4from lib.api.debrid.alldebrid import AllDebrid
5from lib.api.debrid.base import ProviderException
6from lib.clients.debrid.common import get_file_name, get_packed_release_message
7from lib.utils.kodi.utils import dialog_text, get_setting, kodilog, notification, translation
8from lib.utils.general.utils import (
9 DebridType,
10 IndexerType,
11 debrid_dialog_update,
12 filter_debrid_episode,
13 info_hash_to_magnet,
14 supported_video_extensions,
15)
16from lib.domain.torrent import TorrentStream
17import threading
18
19
20class AllDebridHelper:
21 def __init__(self):
22 self.client = AllDebrid(token=str(get_setting("alldebrid_token", "")))
23
24 def check_cached(
25 self,
26 results: List[TorrentStream],
27 cached_results: List[TorrentStream],
28 uncached_results: List[TorrentStream],
29 total: int,
30 dialog: Any,
31 lock: threading.Lock,
32 ) -> None:
33 # Checks if torrents are cached in AllDebrid.
34 torr_available = self.client.get_user_torrent_list()
35 magnets = (
36 torr_available.get("data", {}).get("magnets")
37 or torr_available.get("magnets")
38 or []
39 )
40 torr_available_hashes = [
41 m.get("hash") for m in magnets if isinstance(m, dict) and m.get("hash")
42 ]
43
44 for res in results:
45 debrid_dialog_update(DebridType.AD, total, dialog, lock)
46 res.type = IndexerType.DEBRID
47 res.debridType = DebridType.AD
48 with lock:
49 if res.infoHash in torr_available_hashes:
50 res.isCached = True
51 cached_results.append(res)
52 elif res.isCached == True:
53 cached_results.append(res)
54 else:
55 res.isCached = False
56 uncached_results.append(res)
57
58 if get_setting("show_uncached"):
59 cached_results.extend(uncached_results)
60
61 def get_link(
62 self, info_hash: str, data: Dict[str, Any]
63 ) -> Optional[Dict[str, Any]]:
64
65 result = self.client.add_magnet(info_hash_to_magnet(info_hash))
66 magnet = result.get("data", {}).get("magnets", [])[0]
67 if not magnet.get("ready"):
68 raise ProviderException("Torrent is not yet cached!")
69
70 torrent_id = magnet.get("id")
71
72 # --- Get the files (and their direct links) ---
73 torr_info = self.client.get_files_and_links(torrent_id)
74 files = torr_info[0].get("files", [])
75 if not files:
76 raise ProviderException("No files found in torrent info.")
77
78 # Flatten file list
79 flat_files = self.flatten_files(files)
80
81 # --- Single-file torrents ---
82 if not data.get("tv_data"):
83 # Filter only supported video files
84 all_links = [
85 f.get("l")
86 for f in flat_files
87 if any(
88 get_file_name(f).lower().endswith(ext)
89 for ext in supported_video_extensions()[:-1]
90 )
91 ]
92 if not all_links:
93 raise ProviderException("No cached links available!")
94 data["url"] = self.create_download_for_link(all_links[0])
95 return data
96
97 # --- Multi-file torrents (TV shows) ---
98 season = data["tv_data"].get("season", "")
99 episode = data["tv_data"].get("episode", "")
100
101 matched = filter_debrid_episode(
102 flat_files, episode_num=episode, season_num=season
103 )
104 if not matched:
105 raise ProviderException("No matching episode found in torrent.")
106
107 matched_link = matched[0]["l"]
108 direct_link = self.create_download_for_link(matched_link)
109 data["url"] = direct_link
110 return data
111
112 def add_torrent_file(
113 self, torrent_data: bytes, torrent_name: str = "torrent.torrent"
114 ) -> Optional[Dict[str, Any]]:
115 kodilog(
116 "AllDebridHelper.add_torrent_file: calling AllDebrid torrent file upload API with torrent_name={!r}, size={} bytes".format(
117 torrent_name,
118 len(torrent_data or b""),
119 )
120 )
121 response = self.client.add_torrent_file(torrent_data, torrent_name=torrent_name)
122 files = response.get("data", {}).get("files", []) or []
123 if not files:
124 raise ProviderException("Failed to upload torrent file to AllDebrid")
125
126 file_info = files[0]
127 if file_info.get("error"):
128 error = file_info.get("error", {})
129 raise ProviderException(error.get("message") or "Invalid torrent file")
130 return file_info
131
132 def flatten_files(self, files: list) -> list:
133 flat_list = []
134 for f in files:
135 if "e" in f:
136 flat_list.extend(self.flatten_files(f["e"]))
137 else:
138 if "l" in f:
139 flat_list.append(f)
140 return flat_list
141
142 def get_pack_link(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
143 """Gets a direct download link for a file inside a Real-Debrid torrent pack."""
144
145 pack_info = data.get("pack_info", {})
146 link = pack_info.get("url", "")
147
148 data["url"] = self.create_download_for_link(link)
149 return data
150
151 def get_pack_info(self, info_hash: str) -> Optional[Dict]:
152 result = self.client.add_magnet(info_hash_to_magnet(info_hash))
153 magnet = result.get("data", {}).get("magnets", [])[0]
154 if not magnet.get("ready"):
155 raise ProviderException("Torrent is not yet cached!")
156
157 torrent_id = magnet.get("id")
158
159 # --- Get the files (and their direct links) ---
160 torr_info = self.client.get_files_and_links(torrent_id)
161 files = torr_info[0].get("files", [])
162 if not files:
163 raise ProviderException("No files found in torrent info.")
164
165 # Flatten file list
166 flat_files = self.flatten_files(files)
167
168 # Filter only supported video files
169 files = [
170 (f.get("l"), get_file_name(f))
171 for f in flat_files
172 if any(
173 get_file_name(f).lower().endswith(ext)
174 for ext in supported_video_extensions()[:-1]
175 )
176 ]
177 if not files:
178 notification(get_packed_release_message("AllDebrid"))
179 raise
180
181 info = {"files": files}
182 return info
183
184 def create_download_for_link(self, link: str):
185 response = self.client.create_download_link(link)
186 if not response:
187 raise ProviderException("Failed to unlock the download link.")
188 url = response.get("data", {}).get("link")
189 if not url:
190 raise ProviderException("File not cached!")
191 return url
192
193 def get_info(self) -> None:
194 try:
195 response = self.client.get_user_info()
196 user = response["user"]
197 except Exception as e:
198 raise ProviderException("Failed to retrieve All-Debrid user info.")
199 status = "Premium" if user["isPremium"] else "Not Active"
200 expires = datetime.fromtimestamp(user["premiumUntil"])
201 days_remaining = (expires - datetime.today()).days
202 body = [
203 f"[B]Account:[/B] {user['email']}",
204 f"[B]Username:[/B] {user['username']}",
205 f"[B]Status:[/B] {status}",
206 f"[B]Expires:[/B] {expires}",
207 f"[B]Days Remaining:[/B] {days_remaining}",
208 f"[B]Fidelity Points:[/B] {user['fidelityPoints']}",
209 ]
210 dialog_text(translation(90658), "\n".join(body))
plugin.video.jacktook/lib/clients/debrid/common.py +62 -0
……@@ -0,0 +1,62 @@
1from typing import Any, Dict, Optional
2
3from lib.api.debrid.base import ProviderException
4from lib.utils.kodi.utils import translation
5from lib.utils.general.utils import supported_video_extensions
6
7
8PACKED_RELEASE_FALLBACK_TEMPLATE = (
9 "{provider} cannot directly play packed releases (.rar/.zip). "
10 "Choose a source with video files instead."
11)
12
13
14def get_packed_release_message(provider_name: str) -> str:
15 template = translation(30918)
16 if not isinstance(template, str) or not template:
17 template = PACKED_RELEASE_FALLBACK_TEMPLATE
18 return template.format(provider=provider_name)
19
20
21def get_file_name(file_data: Optional[Dict[str, Any]]) -> str:
22 if not file_data:
23 return ""
24
25 return str(
26 file_data.get("path")
27 or file_data.get("filename")
28 or file_data.get("short_name")
29 or file_data.get("name")
30 or file_data.get("n")
31 or ""
32 )
33
34
35def is_direct_playable_file(filename: Optional[str]) -> bool:
36 if not filename:
37 return False
38
39 lowered = filename.lower()
40 return any(lowered.endswith(ext) for ext in supported_video_extensions())
41
42
43def ensure_direct_playable_file(filename: Optional[str]) -> None:
44 if not filename:
45 return
46
47 if is_direct_playable_file(filename):
48 return
49
50 raise ProviderException(get_packed_release_message("This provider"))
51
52
53def ensure_direct_playable_file_for_provider(
54 filename: Optional[str], provider_name: str
55) -> None:
56 if not filename:
57 return
58
59 if is_direct_playable_file(filename):
60 return
61
62 raise ProviderException(get_packed_release_message(provider_name))
plugin.video.jacktook/lib/clients/debrid/debrider.py +168 -0
……@@ -0,0 +1,168 @@
1from datetime import datetime, timezone
2from typing import Dict, List, Any, Optional
3from lib.api.debrid.base import ProviderException
4from lib.api.debrid.debrider import Debrider
5from lib.clients.debrid.common import get_file_name, get_packed_release_message
6from lib.utils.kodi.utils import dialog_text, get_setting, notification
7from lib.utils.general.utils import (
8 DebridType,
9 IndexerType,
10 debrid_dialog_update,
11 filter_debrid_episode,
12 get_cached,
13 get_public_ip,
14 info_hash_to_magnet,
15 set_cached,
16 supported_video_extensions,
17)
18from lib.domain.torrent import TorrentStream
19
20
21class DebriderHelper:
22 def __init__(self):
23 self.client = Debrider(
24 token=get_setting("debrider_token"), user_ip=get_public_ip()
25 )
26
27 def check_cached(
28 self,
29 results: List[TorrentStream],
30 cached_results: List[TorrentStream],
31 uncached_results: List[TorrentStream],
32 total: int,
33 dialog: Any,
34 lock: Any,
35 ) -> None:
36 # Filter results that have an infoHash
37 filtered_results = [res for res in results if res.infoHash]
38
39 cached_lookup = {}
40 if filtered_results:
41 magnets = [info_hash_to_magnet(res.infoHash) for res in filtered_results]
42 torrents_info = self.client.get_torrent_instant_availability(magnets)
43 cached_list = torrents_info.get("result", [])
44
45 cached_lookup = {
46 res.infoHash: availability
47 for res, availability in zip(filtered_results, cached_list)
48 }
49
50 for res in results:
51 debrid_dialog_update(DebridType.DB, total, dialog, lock)
52
53 res.type = IndexerType.DEBRID
54 res.debridType = DebridType.DB
55
56 availability = cached_lookup.get(res.infoHash)
57 if availability and availability.get("cached", False):
58 res.isCached = True
59 cached_results.append(res)
60 else:
61 res.isCached = False
62 uncached_results.append(res)
63
64 def get_link(self, info_hash, data) -> Optional[Dict[str, Any]]:
65 magnet = info_hash_to_magnet(info_hash)
66 response_data = self.client.create_download_link(magnet)
67 torrent_files = response_data.get("files", [])
68
69 extensions = supported_video_extensions()[:-1]
70 torrent_files = [
71 item
72 for item in torrent_files
73 if any(get_file_name(item).lower().endswith(x) for x in extensions)
74 ]
75
76 if not torrent_files:
77 raise ProviderException(get_packed_release_message("Debrider"))
78
79 if len(torrent_files) > 1:
80 if data["tv_data"]:
81 season = data["tv_data"].get("season", "")
82 episode = data["tv_data"].get("episode", "")
83 torrent_files = filter_debrid_episode(
84 torrent_files, episode_num=episode, season_num=season
85 )
86 if not torrent_files:
87 return
88 data["url"] = torrent_files[0].get("download_link")
89 return data
90 else:
91 data["is_pack"] = True
92 return data
93 else:
94 data["url"] = torrent_files[0].get("download_link")
95 return data
96
97 def get_pack_info(self, info_hash):
98 info = get_cached(info_hash)
99 if info:
100 return info
101
102 extensions = supported_video_extensions()[:-1]
103 magnet = info_hash_to_magnet(info_hash)
104 response_data = self.client.create_download_link(magnet)
105 torrent_files = response_data.get("files", [])
106
107 if len(torrent_files) <= 1:
108 raise ProviderException("No files on the current source")
109
110 files = [
111 (item["download_link"], item["name"])
112 for item in torrent_files
113 if any(item["name"].lower().endswith(x) for x in extensions)
114 ]
115
116 info = {"files": files}
117 set_cached(info, info_hash)
118 return info
119
120 def add_magnet(self, info_hash: str) -> Optional[str]:
121 try:
122 magnet = info_hash_to_magnet(info_hash)
123 response = self.client.add_torrent_file(magnet)
124 if (
125 not response.get("message", "")
126 or "task added successfully" not in response.get("message", "").lower()
127 ):
128 raise ProviderException(
129 f"Failed to add magnet link to Debrider {response}"
130 )
131 except Exception as e:
132 notification(str(e))
133 raise
134
135 def get_info(self):
136 user = self.client.get_user_info()
137 subscription = user.get("subscription")
138
139 if not subscription or subscription.get("status") != "active":
140 dialog_text(DebridType.DB, "No active subscription found.")
141 return
142
143 plan = subscription.get("plan", {})
144 plan_name = plan.get("name", "Unknown")
145 plan_price = plan.get("price", "N/A")
146 plan_currency = plan.get("currency", "")
147 start_date_str = subscription.get("start_date")
148 end_date_str = subscription.get("end_date")
149
150 # Convert start/end dates from ISO strings to datetime
151 start_date = datetime.fromisoformat(start_date_str.replace("Z", "+00:00"))
152 if end_date_str:
153 end_date = datetime.fromisoformat(end_date_str.replace("Z", "+00:00"))
154 days_remaining = (end_date - datetime.now(timezone.utc)).days
155 expires_text = end_date.strftime("%Y-%m-%d %H:%M:%S UTC")
156 else:
157 days_remaining = "Unlimited"
158 expires_text = "No expiration"
159
160 body = [
161 f"[B]Plan:[/B] {plan_name} ({plan_price} {plan_currency})",
162 f"[B]Status:[/B] {subscription.get('status')}",
163 f"[B]Start Date:[/B] {start_date.strftime('%Y-%m-%d %H:%M:%S UTC')}",
164 f"[B]Expires:[/B] {expires_text}",
165 f"[B]Days Remaining:[/B] {days_remaining}",
166 ]
167
168 dialog_text(DebridType.DB, "\n".join(body))
plugin.video.jacktook/lib/clients/debrid/easydebrid.py +133 -0
……@@ -0,0 +1,133 @@
1from datetime import datetime
2from typing import Dict, List, Any, Optional
3from lib.api.debrid.easydebrid import EasyDebrid
4from lib.clients.debrid.common import get_file_name, get_packed_release_message
5from lib.utils.kodi.utils import dialog_text, get_setting, notification, translation
6from lib.utils.general.utils import (
7 DebridType,
8 IndexerType,
9 debrid_dialog_update,
10 filter_debrid_episode,
11 get_cached,
12 get_public_ip,
13 info_hash_to_magnet,
14 set_cached,
15 supported_video_extensions,
16)
17from lib.domain.torrent import TorrentStream
18
19
20class EasyDebridHelper:
21 def __init__(self):
22 self.client = EasyDebrid(
23 token=get_setting("easydebrid_token"), user_ip=get_public_ip()
24 )
25
26 def check_cached(
27 self,
28 results: List[TorrentStream],
29 cached_results: List[TorrentStream],
30 uncached_results: List[TorrentStream],
31 total: int,
32 dialog: Any,
33 lock: Any,
34 ) -> None:
35 # Filter results that have an infoHash
36 filtered_results = [res for res in results if res.infoHash]
37
38 cached_response = []
39 if filtered_results:
40 magnets = [info_hash_to_magnet(res.infoHash) for res in filtered_results]
41 torrents_info = self.client.get_torrent_instant_availability(magnets)
42 cached_response = torrents_info.get("cached", [])
43
44 for res in results:
45 debrid_dialog_update("ED", total, dialog, lock)
46 res.type = IndexerType.DEBRID
47 res.debridType = DebridType.ED
48
49 if res in filtered_results:
50 # Get index in filtered results for matching cached response
51 index = filtered_results.index(res)
52 is_cached = (
53 cached_response[index] if index < len(cached_response) else False
54 )
55 res.isCached = bool(is_cached)
56 if res.isCached:
57 cached_results.append(res)
58 else:
59 uncached_results.append(res)
60 else:
61 res.isCached = False
62 uncached_results.append(res)
63
64 def get_link(self, info_hash, data) -> Optional[Dict[str, Any]]:
65 magnet = info_hash_to_magnet(info_hash)
66 response_data = self.client.create_download_link(magnet)
67 torrent_files = response_data.get("files", [])
68
69 extensions = supported_video_extensions()[:-1]
70 torrent_files = [
71 item
72 for item in torrent_files
73 if any(get_file_name(item).lower().endswith(x) for x in extensions)
74 ]
75
76 if not torrent_files:
77 notification(get_packed_release_message("EasyDebrid"))
78 return
79
80 if len(torrent_files) > 1:
81 if data["tv_data"]:
82 season = data["tv_data"].get("season", "")
83 episode = data["tv_data"].get("episode", "")
84 torrent_files = filter_debrid_episode(
85 torrent_files, episode_num=episode, season_num=season
86 )
87 if not torrent_files:
88 return
89 data["url"] = torrent_files[0].get("url")
90 return data
91 else:
92 data["is_pack"] = True
93 return data
94 else:
95 data["url"] = torrent_files[0].get("url")
96 return data
97
98 def get_pack_info(self, info_hash):
99 info = get_cached(info_hash)
100 if info:
101 return info
102
103 extensions = supported_video_extensions()[:-1]
104 magnet = info_hash_to_magnet(info_hash)
105 response_data = self.client.create_download_link(magnet)
106 torrent_files = response_data.get("files", [])
107
108 if len(torrent_files) <= 1:
109 notification("No files on the current source")
110 return
111
112 files = [
113 (item["url"], item["filename"])
114 for item in torrent_files
115 if any(item["filename"].lower().endswith(x) for x in extensions)
116 ]
117
118 info = {"files": files}
119 set_cached(info, info_hash)
120 return info
121
122 def get_info(self):
123 user = self.client.get_user_info()
124 expiration_timestamp = user["paid_until"]
125
126 expires = datetime.fromtimestamp(expiration_timestamp)
127 days_remaining = (expires - datetime.today()).days
128
129 body = [
130 f"[B]Expires:[/B] {expires}",
131 f"[B]Days Remaining:[/B] {days_remaining}",
132 ]
133 dialog_text(translation(90657), "\n".join(body))
plugin.video.jacktook/lib/clients/debrid/premiumize.py +126 -0
……@@ -0,0 +1,126 @@
1import copy
2from typing import Dict, List, Any, Optional
3from lib.api.debrid.premiumize import Premiumize
4from lib.clients.debrid.common import (
5 ensure_direct_playable_file_for_provider,
6 get_file_name,
7)
8from lib.utils.kodi.utils import get_setting, kodilog, notification
9from lib.utils.general.utils import (
10 DebridType,
11 IndexerType,
12 debrid_dialog_update,
13 filter_debrid_episode,
14 get_cached,
15 info_hash_to_magnet,
16 set_cached,
17 supported_video_extensions,
18)
19from lib.domain.torrent import TorrentStream
20
21
22class PremiumizeHelper:
23 def __init__(self):
24 token = str(get_setting("premiumize_token") or "")
25 self.client = Premiumize(token=token)
26
27 def check_cached(
28 self,
29 results: List[TorrentStream],
30 cached_results: List[TorrentStream],
31 uncached_results: List[TorrentStream],
32 total: int,
33 dialog: Any,
34 lock: Any,
35 ) -> None:
36 """Checks if torrents are cached in Premiumize."""
37 hashes = [res.infoHash for res in results]
38 torrents_info = self.client.get_torrent_instant_availability(hashes)
39 cached_response = torrents_info.get("response", [])
40
41 for index, res in enumerate(copy.deepcopy(results)):
42 debrid_dialog_update("PM", total, dialog, lock)
43 res.type = IndexerType.DEBRID
44 res.debridType = DebridType.PM
45
46 if index < len(cached_response) and cached_response[index] is True:
47 res.isCached = True
48 cached_results.append(res)
49 else:
50 res.isCached = False
51 uncached_results.append(res)
52
53 def get_link(self, info_hash, data) -> Optional[Dict[str, Any]]:
54 """Gets a direct download link for a Premiumize torrent."""
55 magnet = info_hash_to_magnet(info_hash)
56 response_data = self.client.create_download_link(magnet)
57
58 if response_data.get("status") == "error":
59 error_msg = response_data.get("message", "Unknown error")
60 kodilog(f"Failed to get link from Premiumize: {error_msg}")
61 if "not premium" in error_msg.lower():
62 notification("Premiumize: Account does not have an active premium subscription", "Premiumize Error")
63 elif "not logged in" in error_msg.lower():
64 notification("Premiumize: Not logged in. Please authorize the addon.", "Premiumize Error")
65 else:
66 notification(f"Premiumize: {error_msg}", "Premiumize Error")
67 return None
68
69 content = response_data.get("content", [])
70 if len(content) > 1:
71 if data["tv_data"]:
72 season = data["tv_data"].get("season", "")
73 episode = data["tv_data"].get("episode", "")
74 content = filter_debrid_episode(
75 content, episode_num=episode, season_num=season
76 )
77 if not content:
78 return
79 data["url"] = content[0].get("stream_link")
80 return data
81 else:
82 data["is_pack"] = True
83 return data
84 else:
85 ensure_direct_playable_file_for_provider(
86 get_file_name(content[0]), "Premiumize"
87 )
88 data["url"] = content[0].get("stream_link")
89 return data
90
91 def get_pack_info(self, info_hash):
92 """Retrieves information about a torrent pack, including file names."""
93 info = get_cached(info_hash)
94 if info:
95 return info
96
97 extensions = supported_video_extensions()[:-1]
98 magnet = info_hash_to_magnet(info_hash)
99 response_data = self.client.create_download_link(magnet)
100
101 if response_data.get("status") == "error":
102 error_msg = response_data.get("message", "Unknown error")
103 kodilog(f"Failed to get pack info from Premiumize: {error_msg}")
104 if "not premium" in error_msg.lower():
105 notification("Premiumize: Account does not have an active premium subscription", "Premiumize Error")
106 elif "not logged in" in error_msg.lower():
107 notification("Premiumize: Not logged in. Please authorize the addon.", "Premiumize Error")
108 else:
109 notification(f"Premiumize: {error_msg}", "Premiumize Error")
110 return None
111
112 torrent_content = response_data.get("content", [])
113 if len(torrent_content) <= 1:
114 notification("No files on the current source")
115 return None
116
117 files = [
118 (item.get("link"), get_file_name(item).rsplit("/", 1)[-1])
119 for item in torrent_content
120 if any(get_file_name(item).lower().endswith(ext) for ext in extensions)
121 and item.get("link")
122 ]
123
124 info = {"files": files}
125 set_cached(info, info_hash)
126 return info
plugin.video.jacktook/lib/clients/debrid/realdebrid.py +317 -0
……@@ -0,0 +1,317 @@
1import copy
2import threading
3import time
4from datetime import datetime
5from typing import Any, List, Dict, Optional, cast
6
7from lib.api.debrid.base import ProviderException
8from lib.api.debrid.realdebrid import RealDebrid
9from lib.clients.debrid.common import (
10 ensure_direct_playable_file_for_provider,
11 get_file_name,
12)
13from lib.utils.kodi.logging import kodilog
14from lib.utils.kodi.utils import (
15 get_setting,
16 dialog_text,
17 translation,
18)
19from lib.utils.general.utils import (
20 DebridType,
21 IndexerType,
22 debrid_dialog_update,
23 filter_debrid_episode,
24 get_cached,
25 info_hash_to_magnet,
26 set_cached,
27 supported_video_extensions,
28)
29from lib.domain.torrent import TorrentStream
30
31
32class LinkNotFoundError(Exception):
33 pass
34
35
36class RealDebridHelper:
37 def __init__(self) -> None:
38 self.client = RealDebrid(token=str(get_setting("real_debrid_token", "")))
39
40 def check_cached(
41 self,
42 results: List[TorrentStream],
43 cached_results: List[TorrentStream],
44 uncached_results: List[TorrentStream],
45 total: int,
46 dialog: object,
47 lock: threading.Lock,
48 ) -> None:
49 # Checks if torrents are cached in Real-Debrid.
50 torr_available = self.client.get_user_torrent_list()
51 torr_available_hashes = [
52 t.get("hash")
53 for t in torr_available
54 if isinstance(t, dict) and t.get("hash")
55 ]
56
57 for res in copy.deepcopy(results):
58 debrid_dialog_update("RD", total, dialog, lock)
59 res.type = IndexerType.DEBRID
60 res.debridType = DebridType.RD
61 with lock:
62 if res.infoHash in torr_available_hashes:
63 res.isCached = True
64 cached_results.append(res)
65 elif res.isCached == True:
66 cached_results.append(res)
67 else:
68 res.isCached = False
69 uncached_results.append(res)
70
71 if get_setting("show_uncached"):
72 cached_results.extend(uncached_results)
73
74 def add_magnet(self, info_hash: str, is_pack: bool = False):
75 """Adds a magnet link to Real-Debrid and returns the torrent ID."""
76 try:
77 kodilog(
78 "RealDebridHelper.add_magnet: checking existing torrent for hash={!r}".format(
79 str(info_hash).lower()[:12]
80 )
81 )
82 torrent_info = self.client.get_available_torrent(info_hash)
83 if not torrent_info:
84 self.check_max_active_count()
85 kodilog("RealDebridHelper.add_magnet: calling Real-Debrid magnet upload API")
86 response = self.client.add_magnet_link(info_hash_to_magnet(info_hash))
87 torrent_id = response.get("id")
88 if not torrent_id:
89 raise ProviderException("Failed to add magnet link to Real-Debrid")
90 torrent_info = self.client.get_torrent_info(torrent_id)
91 self._handle_torrent_status(torrent_info, is_pack)
92 return torrent_info.get("id")
93 except Exception as e:
94 raise ProviderException(str(e))
95
96 def add_torrent_file(
97 self,
98 torrent_data: bytes,
99 torrent_name: str = "torrent.torrent",
100 is_pack: bool = False,
101 ) -> Optional[str]:
102 """Uploads a torrent file to Real-Debrid and returns the torrent ID."""
103 try:
104 self.check_max_active_count()
105 kodilog(
106 "RealDebridHelper.add_torrent_file: calling Real-Debrid torrent file upload API with torrent_name={!r}, size={} bytes".format(
107 torrent_name,
108 len(torrent_data or b""),
109 )
110 )
111 response = self.client.add_torrent_file(torrent_data, torrent_name=torrent_name)
112 torrent_id = response.get("id")
113 if not torrent_id:
114 raise ProviderException("Failed to upload torrent file to Real-Debrid")
115 torrent_info = self.client.get_torrent_info(torrent_id)
116 self._handle_torrent_status(torrent_info, is_pack)
117 return torrent_info.get("id")
118 except Exception as e:
119 raise ProviderException(str(e))
120
121 def _handle_torrent_status(
122 self, torrent_info: Dict, is_pack: bool = False
123 ) -> Optional[str]:
124 """Processes torrent_info status and handles errors or file selection."""
125 status = torrent_info["status"]
126 torrent_id = torrent_info.get("id", "unknown")
127 if status in ["magnet_error", "error", "virus", "dead"]:
128 self.client.delete_torrent(torrent_info["id"])
129 raise ProviderException(f"Torrent cannot be downloaded: {status}")
130 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.")
132 return torrent_id
133 elif status == "waiting_files_selection":
134 if "files" in torrent_info and torrent_info["files"]:
135 self.handle_file_selection(torrent_info, is_pack)
136 else:
137 raise ProviderException("No files available for this torrent yet.")
138
139 def handle_file_selection(self, torrent_info: Dict, is_pack: bool) -> None:
140 """Handles file selection for Real-Debrid torrents."""
141 files = torrent_info["files"]
142 extensions = supported_video_extensions()[:-1]
143
144 video_files = [
145 item
146 for item in files
147 if any(item["path"].lower().endswith(ext) for ext in extensions)
148 ]
149
150 if video_files:
151 torrents_ids = (
152 [str(i["id"]) for i in video_files]
153 if is_pack or len(video_files) > 1
154 else [str(video_files[0]["id"])]
155 )
156 if torrents_ids:
157 self.client.select_files(torrent_info["id"], ",".join(torrents_ids))
158
159 def get_link(
160 self, info_hash: str, data: Dict[str, Any]
161 ) -> Optional[Dict[str, Any]]:
162 """Gets a direct download link for a Real-Debrid torrent."""
163 torrent_id = self.add_magnet(info_hash)
164 torrent_info = self.client.get_torrent_info(torrent_id)
165 links = torrent_info.get("links", [])
166 files = torrent_info.get("files", [])
167
168 if not links:
169 raise ProviderException("No links found in torrent info.")
170
171 def create_download_for_link(link: str) -> str:
172 response = self.client.create_download_link(link)
173 url = response.get("download")
174 if not url:
175 raise ProviderException("File not cached!")
176 return url
177
178 # --- Single-file torrent ---
179 if len(links) == 1:
180 single_file = files[0] if files else {}
181 ensure_direct_playable_file_for_provider(
182 get_file_name(single_file), "Real-Debrid"
183 )
184 data["url"] = create_download_for_link(links[0])
185 return data
186
187 # --- Multi-file torrent ---
188 tv_data = data.get("tv_data")
189 if tv_data:
190 season = tv_data.get("season")
191 episode = tv_data.get("episode")
192
193 possible_matches = filter_debrid_episode(
194 files, episode_num=episode, season_num=season
195 )
196 if not possible_matches:
197 raise ProviderException("No matching episode found in torrent.")
198
199 match_file = next(
200 (f for f in possible_matches if f.get("selected") == 1), None
201 )
202 if not match_file:
203 raise ValueError("File is not cached")
204
205 ensure_direct_playable_file_for_provider(
206 get_file_name(match_file), "Real-Debrid"
207 )
208
209 selected_files = [f for f in files if f.get("selected") == 1]
210 try:
211 file_index = selected_files.index(match_file)
212 except ValueError:
213 raise ProviderException("Could not map episode to Real-Debrid link.")
214
215 data["url"] = create_download_for_link(links[file_index])
216 return data
217
218 selected_files = [f for f in files if f.get("selected") == 1]
219 if selected_files:
220 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 )
224 try:
225 file_index = selected_files.index(largest_file)
226 except ValueError:
227 file_index = -1
228
229 if 0 <= file_index < len(links):
230 data["url"] = create_download_for_link(links[file_index])
231 return data
232
233 data["is_pack"] = True
234 return data
235
236 def get_pack_link(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
237 """Gets a direct download link for a file inside a Real-Debrid torrent pack."""
238
239 pack_info = cast(Dict[str, Any], data.get("pack_info", {}) or {})
240 file_position = pack_info.get("file_position", "")
241 torrent_id = pack_info.get("torrent_id", "")
242
243 torrent_info = self.client.get_torrent_info(torrent_id)
244 links = torrent_info.get("links", [])
245
246 response = self.client.create_download_link(links[file_position])
247 url = response.get("download")
248 if not url:
249 raise ProviderException("Failed to retrieve download link")
250
251 data["url"] = url
252 return data
253
254 def get_pack_info(self, info_hash: str) -> Optional[Dict[str, Any]]:
255 """Retrieves information about a torrent pack, including file names."""
256 cached_info = get_cached(info_hash)
257 if cached_info:
258 return cast(Dict[str, Any], cached_info)
259
260 torrent_id = self.add_magnet(info_hash, is_pack=True)
261 if not torrent_id:
262 return None
263
264 torr_info = self.client.get_torrent_info(torrent_id)
265 torrent_files = torr_info["files"]
266 if len(torrent_files) <= 1:
267 raise ProviderException("No files on the current source")
268
269 torr_items = [item for item in torrent_files if item["selected"] == 1]
270 files = [(item["id"], item["path"].split("/", 1)[1]) for item in torr_items]
271
272 info = {"torrent_id": torr_info["id"], "files": files}
273 set_cached(info, info_hash)
274 return info
275
276 def get_info(self) -> None:
277 """Fetches Real-Debrid account details and displays them."""
278 user = self.client.get_user()
279 expiration = user["expiration"]
280
281 try:
282 expires = datetime.strptime(expiration, "%Y-%m-%dT%H:%M:%S.%fZ")
283 except ValueError:
284 expires = datetime(
285 *(time.strptime(expiration, "%Y-%m-%dT%H:%M:%S.%fZ")[0:6])
286 )
287
288 days_remaining = (expires - datetime.today()).days
289 body = [
290 f"[B]Account:[/B] {user['email']}",
291 f"[B]Username:[/B] {user['username']}",
292 f"[B]Status:[/B] {user['type'].capitalize()}",
293 f"[B]Expires:[/B] {expires}",
294 f"[B]Days Remaining:[/B] {days_remaining}",
295 f"[B]Fidelity Points:[/B] {user['points']}",
296 ]
297 dialog_text(translation(90659), "\n".join(body))
298
299 def check_max_active_count(self) -> None:
300 """Ensures Real-Debrid does not exceed active torrent limit."""
301 active_count = self.client.get_torrent_active_count()
302
303 if active_count.get("nb", 0) >= active_count.get("limit", 0):
304 hashes = active_count.get("list", [])
305 if hashes:
306 torrents = self.client.get_user_torrent_list()
307 torrent_info = next(
308 (
309 item
310 for item in torrents
311 if isinstance(item, dict) and item.get("hash", "") == hashes[0]
312 ),
313 None,
314 )
315
316 if torrent_info:
317 self.client.delete_torrent(torrent_info.get("id"))
plugin.video.jacktook/lib/clients/debrid/torbox.py +286 -0
……@@ -0,0 +1,286 @@
1import copy
2from typing import Dict, List, Any, Optional
3from lib.api.debrid.torbox import Torbox
4from lib.clients.debrid.common import get_file_name, get_packed_release_message
5from lib.utils.kodi.utils import get_setting, notification, dialog_text, kodilog, translation
6from lib.utils.general.utils import (
7 DebridType,
8 IndexerType,
9 debrid_dialog_update,
10 get_cached,
11 get_public_ip,
12 info_hash_to_magnet,
13 set_cached,
14 supported_video_extensions,
15)
16from lib.domain.torrent import TorrentStream
17
18EXTENSIONS = supported_video_extensions()[:-1]
19
20
21class TorboxException(Exception):
22 def __init__(self, message):
23 self.message = message
24 super().__init__(self.message)
25
26
27class TorboxHelper:
28 def __init__(self):
29 self.client = Torbox(token=get_setting("torbox_token"))
30
31 def check_cached(
32 self,
33 results: List[TorrentStream],
34 cached_results: List[TorrentStream],
35 uncached_results: List[TorrentStream],
36 total: int,
37 dialog: Any,
38 lock: Any,
39 ) -> None:
40 hashes = [res.infoHash for res in results if res.infoHash]
41 cached_response = set()
42
43 if hashes:
44 kodilog(
45 "TorboxHelper.check_cached: checking {} hashes against TorBox cache".format(
46 len(hashes)
47 )
48 )
49 response = self.client.get_torrent_instant_availability(hashes)
50 raw_data = response.get("data", [])
51 for item in raw_data:
52 if isinstance(item, dict):
53 if item.get("hash"):
54 cached_response.add(item.get("hash"))
55 else:
56 cached_response.add(item)
57
58 missing_hashes = len(results) - len(hashes)
59 if missing_hashes:
60 kodilog(
61 "TorboxHelper.check_cached: skipping {} results without info_hash".format(
62 missing_hashes
63 )
64 )
65
66 for res in copy.deepcopy(results):
67 debrid_dialog_update("TB", total, dialog, lock)
68 res.type = IndexerType.DEBRID
69 res.debridType = DebridType.TB
70
71 with lock:
72 if res.infoHash in cached_response:
73 res.isCached = True
74 cached_results.append(res)
75 else:
76 res.isCached = False
77 uncached_results.append(res)
78
79 def add_torbox_torrent(self, info_hash):
80 kodilog(
81 "TorboxHelper.add_torbox_torrent: checking existing torrent for hash={!r}".format(
82 str(info_hash).lower()[:12]
83 )
84 )
85 torrent_info = self.client.get_available_torrent(info_hash)
86 if (
87 torrent_info
88 and torrent_info.get("download_finished")
89 and torrent_info.get("download_present")
90 ):
91 return torrent_info
92
93 magnet = info_hash_to_magnet(info_hash)
94 kodilog("TorboxHelper.add_torbox_torrent: calling TorBox magnet upload API")
95 response = self.client.add_magnet_link(magnet)
96
97 if not response.get("success"):
98 raise TorboxException(f"Failed to add magnet link to Torbox: {response}")
99
100 if "Found Cached" in response.get("detail", ""):
101 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 return response
105
106 def add_torrent_file(self, torrent_data: bytes, torrent_name: str = "torrent.torrent"):
107 kodilog(
108 "TorboxHelper.add_torrent_file: calling TorBox torrent file upload API with torrent_name={!r}, size={} bytes".format(
109 torrent_name,
110 len(torrent_data or b""),
111 )
112 )
113 response = self.client.add_torrent_file(torrent_data, torrent_name=torrent_name)
114
115 if not response.get("success"):
116 raise TorboxException(f"Failed to add torrent file to Torbox: {response}")
117
118 data = response.get("data", {}) or {}
119 torrent_hash = data.get("hash", "")
120 if torrent_hash:
121 torrent_info = self.client.get_available_torrent(torrent_hash)
122 if torrent_info:
123 return torrent_info
124 return data
125
126 def get_cloud_downloads(self) -> List[Dict[str, Any]]:
127 response = self.client.get_user_torrent_list()
128 torrents = response.get("data", {}) or []
129 downloads = []
130 skipped_not_dict = 0
131 skipped_not_present = 0
132 skipped_no_playable = 0
133
134 for torrent in torrents:
135 if not isinstance(torrent, dict):
136 skipped_not_dict += 1
137 continue
138
139 if not torrent.get("download_present"):
140 skipped_not_present += 1
141 continue
142
143 files = torrent.get("files", []) or []
144 video_files = [
145 item
146 for item in files
147 if any(get_file_name(item).lower().endswith(ext) for ext in EXTENSIONS)
148 ]
149 if not video_files:
150 skipped_no_playable += 1
151 continue
152
153 file_item = max(video_files, key=lambda item: item.get("size", 0))
154
155 downloads.append(
156 {
157 "name": file_item.get("name") or torrent.get("name") or "Unknown",
158 "torrent_id": torrent.get("id"),
159 "file_id": file_item.get("id"),
160 "info_hash": torrent.get("hash", ""),
161 "created_at": torrent.get("created_at", ""),
162 "updated_at": torrent.get("updated_at", ""),
163 }
164 )
165
166 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 )
174 )
175
176 return downloads
177
178 def get_link(self, info_hash, data) -> Optional[Dict[str, Any]]:
179 torrent_id = data.get("torrent_id", "")
180 file_id = data.get("file_id", "")
181
182 if torrent_id and file_id:
183 response_data = self.client.create_download_link(
184 torrent_id, file_id, get_public_ip()
185 )
186 if response_data:
187 data["url"] = response_data.get("data", {})
188 return data
189 kodilog(
190 "Torbox cloud playback failed to create link for torrent_id={!r}, file_id={!r}".format(
191 torrent_id, file_id
192 )
193 )
194 return None
195
196 torrent_info = self.add_torbox_torrent(info_hash)
197 if not torrent_info:
198 return None
199
200 video_files = [
201 item
202 for item in torrent_info["files"]
203 if any(get_file_name(item).lower().endswith(ext) for ext in EXTENSIONS)
204 ]
205 if not video_files:
206 raise TorboxException(get_packed_release_message("Torbox"))
207
208 file = max(video_files, key=lambda x: x.get("size", 0))
209 response_data = self.client.create_download_link(
210 torrent_info.get("id"), file.get("id"), get_public_ip()
211 )
212 if response_data:
213 data["url"] = response_data.get("data", {})
214 return data
215
216 def get_pack_link(self, data) -> Optional[Dict[str, Any]]:
217 pack_info = data.get("pack_info", {})
218 file_id = pack_info.get("file_id", "")
219 torrent_id = pack_info.get("torrent_id", "")
220
221 response_data = self.client.create_download_link(torrent_id, file_id)
222 if response_data:
223 data["url"] = response_data.get("data", {})
224 return data
225
226 def get_pack_info(self, info_hash):
227 info = get_cached(info_hash)
228 if info:
229 return info
230
231 torrent_info = self.add_torbox_torrent(info_hash)
232 if not torrent_info:
233 return None
234
235 info = {
236 "id": torrent_info["id"],
237 "torrent_id": torrent_info["id"],
238 "files": [],
239 }
240 torrent_files = torrent_info.get("files", [])
241
242 if not torrent_files:
243 notification("No files on the current source")
244 return None
245
246 files = [
247 (item.get("id"), item["name"])
248 for id, item in enumerate(torrent_files)
249 if any(get_file_name(item).lower().endswith(ext) for ext in EXTENSIONS)
250 ]
251
252 info["files"] = files
253 set_cached(info, info_hash)
254 return info
255
256 def get_info(self) -> None:
257 """Fetches Torbox account details and displays them."""
258 response = self.client.get_user()
259 if not response.get("success"):
260 notification("Failed to fetch Torbox user info")
261 return
262
263 user = response.get("data", {})
264 customer_email = user.get("customer_email", "")
265 plan = user.get("plan", 0)
266
267 # 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 }
274 plan_name = plans.get(plan, f"Plan {plan}")
275
276 body = [
277 f"[B]Account:[/B] {customer_email}",
278 f"[B]Plan:[/B] {plan_name}",
279 f"[B]User ID:[/B] {user.get('auth_id')}",
280 f"[B]Cooldown Until:[/B] {user.get('cooldown_until') or 'None'}",
281 ]
282
283 days = self.client.days_remaining()
284 if days is not None:
285 body.append(f"[B]Days Remaining:[/B] {days}")
286 dialog_text(translation(90656), "\n".join(body))
plugin.video.jacktook/lib/clients/easynews.py +244 -0
……@@ -0,0 +1,244 @@
1import re
2from urllib.parse import quote
3from lib.clients.base import BaseClient, TorrentStream
4from lib.utils.kodi.utils import kodilog
5from lib.utils.general.utils import IndexerType, Indexer
6from typing import List, Optional, Any, Callable, Tuple
7
8VIDEO_EXTENSIONS = (
9 "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,"
10 "mk3d,avc,vp3,svq3,nuv,viv,dv,fli,flv,wpl,xspf,vdr,dvr-ms,xsp,mts,m2t,m2ts,evo,ogv,sdp,avs,rec,url,pxml,vc1,h264,rcv,rss,mpls,mpl,webm,bdmv,bdm,wtv,trp,f4v,pvr,disc"
11)
12
13SEARCH_PARAMS = {
14 "st": "adv",
15 "sb": 1,
16 "fex": VIDEO_EXTENSIONS,
17 "fty[]": "VIDEO",
18 "spamf": 1,
19 "u": "1",
20 "gx": 1,
21 "pno": 1,
22 "sS": 3,
23 "s1": "relevance",
24 "s1d": "-",
25 "s2": "dsize",
26 "s2d": "-",
27 "s3": "dtime",
28 "s3d": "-",
29 "pby": 150,
30}
31
32
33class Easynews(BaseClient):
34 def __init__(
35 self, user: str, password: str, timeout: int, notification: Callable
36 ) -> None:
37 super().__init__("https://members.easynews.com", notification)
38 self.user = user
39 self.password = password
40 self.timeout = timeout
41 self.base_url = f"{self.host}/2.0/search/solr-search/advanced"
42
43 def search(
44 self,
45 query: str,
46 mode: str,
47 media_type: str,
48 season: Optional[int] = None,
49 episode: Optional[int] = None,
50 ) -> List[TorrentStream]:
51 search_query = query
52 if mode == "tv" and season:
53 if episode:
54 search_query = f"{query} S{season:02d}E{episode:02d}"
55 else:
56 # Season search or pack search
57 search_query = f"{query} S{season:02d}"
58
59 params = SEARCH_PARAMS.copy()
60 params["gps"] = search_query
61
62 try:
63 response = self.session.get(
64 self.base_url,
65 params=params,
66 auth=(self.user, self.password),
67 timeout=self.timeout,
68 )
69 response.raise_for_status()
70 return self.parse_response(response)
71 except Exception as e:
72 kodilog(f"EasyNews search error: {e}")
73 return []
74
75 def parse_response(self, res: Any) -> List[TorrentStream]:
76 results = []
77 try:
78 response_json = res.json()
79 except Exception:
80 return results
81
82 if not response_json or "data" not in response_json:
83 return results
84
85 down_url = response_json.get("downURL")
86 dl_farm = response_json.get("dlFarm")
87 dl_port = response_json.get("dlPort")
88
89 for item in response_json.get("data", []):
90 try:
91 post_hash = item.get("0")
92 size_val = item.get("4")
93 try:
94 size = int(size_val)
95 except (ValueError, TypeError):
96 from lib.utils.kodi.utils import convert_size_to_bytes
97
98 size = convert_size_to_bytes(str(size_val))
99 post_title = item.get("10")
100 ext = item.get("11")
101 duration = item.get("14", "")
102
103 if item.get("type", "").upper() != "VIDEO":
104 continue
105 if item.get("virus"):
106 continue
107 if re.match(r"^\d+s", duration) or re.match(r"^[0-5]m", duration):
108 continue
109
110 url = down_url + quote(
111 f"/{dl_farm}/{dl_port}/{post_hash}{ext}/{post_title}{ext}"
112 )
113 title = post_title
114
115 results.append(
116 TorrentStream(
117 title=title,
118 type=IndexerType.DIRECT,
119 indexer=Indexer.EASYNEWS,
120 provider=Indexer.EASYNEWS,
121 size=size,
122 url=url,
123 quality=self._guess_quality(title),
124 isCached=True,
125 )
126 )
127 except Exception as e:
128 kodilog(f"Easynews parse item error: {e}")
129
130 return results
131
132 def resolve_url(self, url: str) -> Optional[str]:
133 try:
134 response = self.session.get(
135 url,
136 auth=(self.user, self.password),
137 stream=True,
138 timeout=self.timeout * 3,
139 )
140 if not response.ok:
141 return None
142 chunk = next(response.iter_content(chunk_size=1048576), b"")
143 if len(chunk):
144 resolved_link = response.url
145 else:
146 resolved_link = None
147 return resolved_link
148 except Exception as e:
149 kodilog(f"Easynews resolve URL error: {e}")
150 return None
151
152 def account(self) -> Tuple[Optional[List], Optional[List]]:
153 account_info, usage_info = None, None
154 try:
155 account_html = self.session.get(
156 "https://account.easynews.com/editinfo.php",
157 auth=(self.user, self.password),
158 timeout=self.timeout,
159 ).text
160 # Basic regex to extract account info fields
161 account_info = re.findall(r"<td[^>]*>([^<]+)</td>", account_html)
162 if account_info and len(account_info) >= 11:
163 account_info = account_info[0:11][1::3]
164 except Exception as e:
165 kodilog(f"Easynews account info error: {e}")
166
167 try:
168 usage_html = self.session.get(
169 "https://account.easynews.com/usageview.php",
170 auth=(self.user, self.password),
171 timeout=self.timeout,
172 ).text
173 usage_info = re.findall(r"<td[^>]*>(.*?)</td>", usage_html, re.DOTALL)
174 if usage_info and len(usage_info) >= 11:
175 usage_info = usage_info[0:11][1::3]
176 if len(usage_info) > 1:
177 usage_info[1] = re.sub(r"[</].+?>", "", usage_info[1]).strip()
178 except Exception as e:
179 kodilog(f"Easynews usage info error: {e}")
180
181 return account_info, usage_info
182
183 def get_info(self) -> None:
184 from datetime import datetime
185 from lib.utils.kodi.utils import dialog_text, kodilog, translation
186
187 try:
188 acc_info, usage_info = self.account()
189
190 if not acc_info or not usage_info:
191 if self.notification:
192 self.notification("Easynews Account Error")
193 return
194
195 try:
196 expires = datetime.strptime(acc_info[2], "%Y-%m-%d")
197 days_remaining = (expires - datetime.now()).days
198 expires_str = expires.strftime("%Y-%m-%d")
199 except ValueError:
200 expires_str = acc_info[2]
201 days_remaining = "Unknown"
202
203 body = [
204 f"Account Status: {acc_info[1]}",
205 f"Account Type: {acc_info[0]}",
206 f"Features: {acc_info[3]}",
207 f"Expires: {expires_str}",
208 f"Days Remaining: {days_remaining}",
209 f"Usage Limits: {usage_info[2]}",
210 f"Gigs Allowed: {usage_info[0]}",
211 f"Gigs Remaining: {usage_info[1]}",
212 ]
213
214 dialog_text(translation(90651), "\n\n".join(body))
215 except Exception as e:
216 import traceback
217
218 kodilog(f"Error fetching Easynews info: {e}\n{traceback.format_exc()}")
219 if self.notification:
220 self.notification("Error fetching Easynews info")
221
222 def _guess_quality(self, title: str) -> str:
223 t = title.upper().replace(" ", ".").split(".")
224 if any(x in t for x in ["2160P", "4K", "UHD", "2160"]):
225 return "4K"
226 if any(x in t for x in ["1080P", "1080I", "1080"]):
227 return "1080p"
228 if any(x in t for x in ["720P", "720I", "720"]):
229 return "720p"
230 if any(x in t for x in ["480P", "480I", "480", "SD", "DVD", "DVDRIP"]):
231 return "SD"
232
233 # Fallback to simple string search if dots/spaces don't catch it
234 t = title.upper()
235 if "2160" in t or "4K" in t:
236 return "4K"
237 if "1080" in t:
238 return "1080p"
239 if "720" in t:
240 return "720p"
241 if "480" in t or "SD" in t or "DVD" in t:
242 return "SD"
243
244 return "N/A"
plugin.video.jacktook/lib/clients/elfhosted.py +69 -0
……@@ -0,0 +1,69 @@
1import re
2from typing import List, Dict, Any, Optional, Callable
3from lib.clients.base import BaseClient, TorrentStream
4from lib.utils.kodi.utils import convert_size_to_bytes, translation
5
6
7class Elfhosted(BaseClient):
8 def __init__(self, host: str, notification: Callable) -> None:
9 super().__init__(host, notification)
10
11 def search(
12 self,
13 imdb_id: str,
14 mode: str,
15 media_type: str,
16 season: Optional[int],
17 episode: Optional[int],
18 ) -> Optional[List[TorrentStream]]:
19 try:
20 if mode == "tv" or media_type == "tv":
21 url = f"{self.host}/stream/series/{imdb_id}:{season}:{episode}.json"
22 elif mode == "movies" or media_type == "movies":
23 url = f"{self.host}/stream/{mode}/{imdb_id}.json"
24 else:
25 self.handle_exception(translation(30231))
26 return None
27
28 res = self.session.get(url, timeout=10)
29 if res.status_code != 200:
30 return
31 response = self.parse_response(res)
32 return response
33 except Exception as e:
34 self.handle_exception(f"{translation(30231)}: {str(e)}")
35
36 def parse_response(self, res: Any) -> List[TorrentStream]:
37 res = res.json()
38 results = []
39 for item in res["streams"]:
40 parsed_item = self.parse_stream_title(item["title"])
41 results.append(
42 TorrentStream(
43 title=parsed_item["title"],
44 type="Torrent",
45 indexer="Elfhosted",
46 guid=item["infoHash"],
47 infoHash=item["infoHash"],
48 size=parsed_item["size"],
49 publishDate="",
50 seeders=0,
51 peers=0,
52 languages=[],
53 fullLanguages="",
54 provider="",
55 )
56 )
57 return results
58
59 def parse_stream_title(self, title: str) -> Dict[str, Any]:
60 name = title.splitlines()[0]
61
62 size_match = re.search(r"💾 (\d+(?:\.\d+)?\s*(GB|MB))", title, re.IGNORECASE)
63 size = size_match.group(1) if size_match else ""
64 size = convert_size_to_bytes(size)
65
66 return {
67 "title": name,
68 "size": size,
69 }
plugin.video.jacktook/lib/clients/fma.py +85 -0
……@@ -0,0 +1,85 @@
1import re
2import requests
3
4
5class FindMyAnime:
6 base_url = "https://find-my-anime.dtimur.de/api"
7
8 def get_anime_data(self, anime_id, anime_id_provider):
9 params = {"id": anime_id, "provider": anime_id_provider, "includeAdult": "true"}
10 return self.make_request(params=params)
11
12 def make_request(self, params):
13 res = requests.get(
14 self.base_url,
15 params=params,
16 )
17 if res.status_code == 200:
18 return res.json()
19 else:
20 raise Exception(f"FMA error::{res.text}")
21
22
23def extract_season(res):
24 regexes = [
25 r"season\s(\d+)",
26 r"\s(\d+)st\sseason(?:\s|$)",
27 r"\s(\d+)nd\sseason(?:\s|$)",
28 r"\s(\d+)rd\sseason(?:\s|$)",
29 r"\s(\d+)th\sseason(?:\s|$)",
30 ]
31 s_ids = []
32 for regex in regexes:
33 if isinstance(res.get("title"), dict):
34 s_ids += [
35 re.findall(regex, name, re.IGNORECASE)
36 for lang, name in res.get("title").items()
37 if name is not None
38 ]
39 else:
40 s_ids += [
41 re.findall(regex, name, re.IGNORECASE) for name in res.get("title")
42 ]
43
44 s_ids += [
45 re.findall(regex, name, re.IGNORECASE) for name in res.get("synonyms")
46 ]
47
48 s_ids = [s[0] for s in s_ids if s]
49
50 if not s_ids:
51 regex = r"\s(\d+)$"
52 cour = False
53 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 ):
58 cour = True
59 break
60 if not cour:
61 s_ids += [
62 re.findall(regex, name, re.IGNORECASE)
63 for lang, name in res.get("title").items()
64 if name is not None
65 ]
66 s_ids += [
67 re.findall(regex, name, re.IGNORECASE)
68 for name in res.get("synonyms")
69 ]
70 else:
71 for name in res.get("title"):
72 if " part " in name.lower() or " cour " in name.lower():
73 cour = True
74 break
75 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 ]
83 s_ids = [s[0] for s in s_ids if s and int(s[0]) < 20]
84
85 return s_ids
plugin.video.jacktook/lib/clients/introdb.py +95 -0
……@@ -0,0 +1,95 @@
1import requests
2from datetime import timedelta
3from lib.db.cached import MemoryCache
4from lib.utils.kodi.utils import kodilog
5
6INTRODB_BASE_URL = "http://api.introdb.app"
7INTRODB_TIMEOUT = 5
8INTRODB_CACHE_EXPIRY = timedelta(hours=24)
9
10_cache = MemoryCache(database="introdb")
11_SENTINEL = "__introdb_none__"
12
13
14def get_segments(imdb_id, season, episode):
15 """
16 Fetch intro/recap/outro segment timestamps from IntroDB for a given episode.
17 Results are cached in memory for 24 hours to avoid redundant API calls.
18
19 Args:
20 imdb_id: IMDb ID string (e.g., "tt0944947")
21 season: Season number (int)
22 episode: Episode number (int)
23
24 Returns:
25 dict with keys 'intro', 'recap', 'outro' (each a dict or None),
26 or None if the request fails or no data is available.
27 """
28 if not imdb_id or not season or not episode:
29 return None
30
31 cache_key = f"{imdb_id}.S{season}E{episode}"
32 request_params = {
33 "imdb_id": imdb_id,
34 "season": int(season),
35 "episode": int(episode),
36 }
37
38 kodilog(f"IntroDB: get_segments called for {imdb_id} S{season}E{episode}")
39
40 # Check cache first
41 cached = _cache.get(cache_key)
42 if cached is not None:
43 if cached == _SENTINEL:
44 kodilog(f"IntroDB: Cache hit (no data) for {imdb_id} S{season}E{episode}")
45 return None
46 kodilog(
47 f"IntroDB: Cache hit for {imdb_id} S{season}E{episode} data={cached}"
48 )
49 return cached
50
51 try:
52 kodilog(
53 f"IntroDB: Requesting /segments for {imdb_id} S{season}E{episode} with params {request_params}"
54 )
55 response = requests.get(
56 f"{INTRODB_BASE_URL}/segments",
57 params=request_params,
58 timeout=INTRODB_TIMEOUT,
59 )
60 kodilog(
61 f"IntroDB: Response status {response.status_code} for {imdb_id} S{season}E{episode} body={response.text}"
62 )
63
64 if response.status_code == 404:
65 kodilog(f"IntroDB: No segments found for {imdb_id} S{season}E{episode}")
66 _cache.set(cache_key, _SENTINEL, expires=INTRODB_CACHE_EXPIRY)
67 return None
68
69 if response.status_code != 200:
70 kodilog(
71 f"IntroDB: Unexpected status {response.status_code} for {imdb_id} S{season}E{episode}"
72 )
73 return None
74
75 data = response.json()
76 kodilog(
77 f"IntroDB: Got segments for {imdb_id} S{season}E{episode}: "
78 f"intro={'yes' if data.get('intro') else 'no'}, "
79 f"recap={'yes' if data.get('recap') else 'no'}, "
80 f"outro={'yes' if data.get('outro') else 'no'}"
81 )
82 _cache.set(cache_key, data, expires=INTRODB_CACHE_EXPIRY)
83 return data
84
85 except requests.exceptions.Timeout:
86 kodilog(f"IntroDB: Request timed out for {imdb_id} S{season}E{episode}")
87 return None
88 except requests.exceptions.RequestException as e:
89 kodilog(f"IntroDB: Request failed for {imdb_id} S{season}E{episode}: {e}")
90 return None
91 except (ValueError, KeyError) as e:
92 kodilog(
93 f"IntroDB: Failed to parse response for {imdb_id} S{season}E{episode}: {e}"
94 )
95 return None
plugin.video.jacktook/lib/clients/jackett.py +244 -0
……@@ -0,0 +1,244 @@
1from urllib.parse import quote
2from typing import Any, List, Optional
3import concurrent
4import requests
5
6from lib.clients.base import BaseClient
7from lib.domain.torrent import TorrentStream
8from lib.jacktook.utils import kodilog
9from lib.utils.kodi.logging import summarize_locator_for_log
10from lib.utils.general.utils import USER_AGENT_HEADER
11from lib.utils.kodi.settings import get_jackett_timeout
12from lib.utils.kodi.utils import get_setting, notification, translation
13from lib.utils.parsers import xmltodict
14
15
16class Jackett(BaseClient):
17 def __init__(
18 self,
19 host: str,
20 apikey: str,
21 port: str,
22 notification,
23 session: Optional[requests.Session] = None,
24 ) -> None:
25 super().__init__(host, notification)
26 self.apikey = apikey
27 self.port = port
28 self.host = host
29 self.base_url = self._make_base_url("all")
30 self.session = session or requests.Session()
31 self.session.headers.update(USER_AGENT_HEADER.copy())
32
33 def _make_base_url(self, indexer_id: str) -> str:
34 return f"{self.host}:{self.port}/api/v2.0/indexers/{indexer_id}/results/torznab/api?apikey={self.apikey}"
35
36 def _build_url(
37 self,
38 base_url: str,
39 query: str,
40 mode: str,
41 season: Optional[int] = None,
42 episode: Optional[int] = None,
43 categories: Optional[List[int]] = None,
44 additional_params: Optional[dict] = None,
45 season_pack=False,
46 year: Optional[int] = None,
47 ) -> str:
48 url = f"{base_url}&q={quote(query)}"
49 if mode == "tv":
50 url += "&t=tvsearch"
51 if season_pack:
52 url += f"&season={season}"
53 else:
54 url += f"&season={season}&ep={episode}"
55 elif mode == "movies":
56 url += "&t=movie"
57 else:
58 url += "&t=search"
59
60 if categories:
61 url += f"&cat={','.join(map(str, categories))}"
62
63 if year and mode == "movies":
64 url += f"&year={year}"
65
66 if additional_params:
67 for key, value in additional_params.items():
68 url += f"&{key}={value}"
69
70 return url
71
72 def search_indexer(
73 self,
74 base_url,
75 query: str,
76 mode: str,
77 season: Optional[int] = None,
78 episode: Optional[int] = None,
79 categories: Optional[List[int]] = None,
80 additional_params: Optional[dict] = None,
81 year: Optional[int] = None,
82 ) -> Optional[List[TorrentStream]]:
83 try:
84 results = []
85 futures = []
86 with concurrent.futures.ThreadPoolExecutor() as executor:
87 if mode == "tv" and season:
88 if episode:
89 url_ep = self._build_url(
90 base_url,
91 query,
92 mode,
93 season,
94 episode,
95 categories,
96 additional_params,
97 year=year,
98 )
99 futures.append(
100 executor.submit(
101 self.session.get, url_ep, timeout=get_jackett_timeout()
102 )
103 )
104 if get_setting("include_season_packs"):
105 url_season = self._build_url(
106 base_url,
107 query,
108 mode,
109 season,
110 None,
111 categories,
112 additional_params,
113 season_pack=True,
114 year=year,
115 )
116 futures.append(
117 executor.submit(
118 self.session.get,
119 url_season,
120 timeout=get_jackett_timeout(),
121 )
122 )
123 else:
124 url = self._build_url(
125 base_url,
126 query,
127 mode,
128 season,
129 episode,
130 categories,
131 additional_params,
132 year=year,
133 )
134 futures.append(
135 executor.submit(
136 self.session.get, url, timeout=get_jackett_timeout()
137 )
138 )
139
140 for future in concurrent.futures.as_completed(futures):
141 response = future.result()
142 if response.status_code == 200:
143 res = self.parse_response(response)
144 if res:
145 results.extend(res)
146 else:
147 notification(f"{translation(30229)} ({response.status_code})")
148 return results if results else None
149 except Exception as e:
150 self.handle_exception(f"{translation(30229)}: {str(e)}")
151 return None
152
153 def search(
154 self,
155 query: str,
156 mode: str,
157 season: Optional[int] = None,
158 episode: Optional[int] = None,
159 categories: Optional[List[int]] = None,
160 additional_params: Optional[dict] = None,
161 variant=None,
162 year: Optional[int] = None,
163 ) -> Optional[List[TorrentStream]]:
164 try:
165 return self.search_indexer(
166 self.base_url,
167 query,
168 mode,
169 season,
170 episode,
171 categories,
172 additional_params,
173 year=year,
174 )
175 except Exception as e:
176 self.handle_exception(f"{translation(30229)}: {str(e)}")
177 return None
178
179 def parse_response(self, res: Any) -> Optional[List[TorrentStream]]:
180 try:
181 res_dict = xmltodict.parse(res.content)
182 if res_dict:
183 channel = res_dict.get("rss", {}).get("channel", {})
184 items = channel.get("item")
185 if not items:
186 return []
187 results: List[TorrentStream] = []
188 for item in items if isinstance(items, list) else [items]:
189 self.extract_result(results, item)
190 kodilog(f"Parsed {len(results)} results from Jackett")
191 return results
192 except Exception as e:
193 self.handle_exception(f"Error parsing Jackett response: {str(e)}")
194 return None
195
196 def extract_result(self, results: List[TorrentStream], item: dict) -> None:
197 attrs = item.get("torznab:attr", [])
198 if isinstance(attrs, dict):
199 attrs = [attrs]
200 attributes = {attr.get("@name"): attr.get("@value") for attr in attrs}
201 enclosure = item.get("enclosure", {})
202 if not isinstance(enclosure, dict):
203 enclosure = {}
204 link = item.get("link", "")
205 guid = item.get("guid", "")
206 info_hash = str(attributes.get("infohash", ""))
207 enclosure_url = enclosure.get("@url", "")
208 provider = item.get("jackettindexer", {}).get("#text", "")
209
210 kodilog(
211 "Jackett parsed result: title={!r}, provider={!r}, guid={!r}, link={!r}, enclosure={!r}, infohash={!r}, has_magnet={}, has_http_url={}, has_infohash={}".format(
212 item.get("title", ""),
213 provider,
214 summarize_locator_for_log(guid),
215 summarize_locator_for_log(link),
216 summarize_locator_for_log(enclosure_url),
217 info_hash[:12].lower(),
218 bool(str(link).startswith("magnet:?") or str(guid).startswith("magnet:?")),
219 bool(
220 str(link).startswith(("http://", "https://"))
221 or str(guid).startswith(("http://", "https://"))
222 or str(enclosure_url).startswith(("http://", "https://"))
223 ),
224 bool(info_hash),
225 )
226 )
227
228 results.append(
229 TorrentStream(
230 title=item.get("title", ""),
231 type="Torrent",
232 indexer="Jackett",
233 publishDate=item.get("pubDate", ""),
234 provider=provider,
235 guid=guid,
236 url=link,
237 size=item.get("size", ""),
238 seeders=int(attributes.get("seeders", 0) or 0),
239 peers=int(attributes.get("peers", 0) or 0),
240 infoHash=info_hash,
241 languages=[],
242 fullLanguages="",
243 )
244 )
plugin.video.jacktook/lib/clients/jackgram/client.py +130 -0
……@@ -0,0 +1,130 @@
1from typing import List, Dict, Optional, Any, Callable
2from lib.clients.base import BaseClient, TorrentStream
3from lib.utils.kodi.utils import kodilog, translation
4
5
6class Jackgram(BaseClient):
7 def __init__(
8 self, host: str, notification: Callable, token: Optional[str] = None
9 ) -> None:
10 super().__init__(host, notification)
11 self.token = token
12 if token:
13 self.session.headers.update({"Authorization": f"Bearer {token}"})
14
15 def search(
16 self,
17 tmdb_id: str,
18 query: str,
19 mode: str,
20 media_type: str,
21 season: Optional[int],
22 episode: Optional[int],
23 ) -> Optional[List[TorrentStream]]:
24 try:
25 if mode == "tv" or media_type == "tv":
26 url = f"{self.host}/stream/series/{tmdb_id}:{season}:{episode}.json"
27 elif mode == "movies" or media_type == "movies":
28 url = f"{self.host}/stream/movie/{tmdb_id}.json"
29 else:
30 url = f"{self.host}/search?query={query}"
31
32 kodilog(f"URL: {url}")
33
34 res = self.session.get(url, timeout=10)
35 if res.status_code != 200:
36 return
37
38 if mode in ["tv", "movies"]:
39 return self.parse_response(res)
40 else:
41 return self.parse_response_search(res)
42 except Exception as e:
43 self.handle_exception(f"{translation(30232)}: {e}")
44
45 def get_latest_movies(self, page: int) -> Optional[Dict[str, Any]]:
46 try:
47 url = f"{self.host}/stream/movies/latest?page={page}"
48 res = self.session.get(url, timeout=10)
49 if res.status_code != 200:
50 kodilog(f"get_latest_movies failed with status {res.status_code}")
51 return
52 return res.json()
53 except Exception as e:
54 self.handle_exception(f"{translation(30232)}: {e}")
55
56 def get_latest_series(self, page: int) -> Optional[Dict[str, Any]]:
57 try:
58 url = f"{self.host}/stream/series/latest?page={page}"
59 res = self.session.get(url, timeout=10)
60 if res.status_code != 200:
61 kodilog(f"get_latest_series failed with status {res.status_code}")
62 return
63 return res.json()
64 except Exception as e:
65 self.handle_exception(f"{translation(30232)}: {e}")
66
67 def get_files(self, page: int) -> Optional[Dict[str, Any]]:
68 try:
69 url = f"{self.host}/stream/files?page={page}"
70 res = self.session.get(url, timeout=10)
71 if res.status_code != 200:
72 kodilog(f"get_files failed with status {res.status_code}")
73 return
74 return res.json()
75 except Exception as e:
76 self.handle_exception(f"{translation(30232)}: {e}")
77
78 def parse_response(self, res: Any) -> List[TorrentStream]:
79 res = res.json()
80 results = []
81 for item in res["streams"]:
82 url = item["url"]
83 if self.token and url:
84 url = f"{url}|Authorization=Bearer {self.token}"
85
86 results.append(
87 TorrentStream(
88 title=item["title"],
89 type="Direct",
90 indexer=item["name"],
91 size=item["size"],
92 publishDate=item["date"],
93 url=url,
94 guid=item.get("guid", ""),
95 infoHash=item.get("infoHash", ""),
96 seeders=item.get("seeders", 0),
97 languages=item.get("languages", []),
98 fullLanguages=item.get("fullLanguages", ""),
99 provider=item.get("provider", ""),
100 peers=item.get("peers", 0),
101 )
102 )
103 return results
104
105 def parse_response_search(self, res: Any) -> List[TorrentStream]:
106 res = res.json()
107 results = []
108 for item in res["results"]:
109 if item.get("type") == "file":
110 file_info = self._extract_file_info(item)
111 results.append(TorrentStream(**file_info))
112 else:
113 for file in item.get("files", []):
114 file_info = self._extract_file_info(file)
115 results.append(TorrentStream(**file_info))
116 return results
117
118 def _extract_file_info(self, file):
119 url = file.get("url", "")
120 if self.token and url:
121 url = f"{url}|Authorization=Bearer {self.token}"
122
123 return {
124 "title": file.get("title", ""),
125 "type": "Direct",
126 "indexer": file.get("name", ""),
127 "size": file.get("size", ""),
128 "publishDate": file.get("date", ""),
129 "url": url,
130 }
plugin.video.jacktook/lib/clients/jackgram/utils.py +189 -0
……@@ -0,0 +1,189 @@
1from lib.clients.tmdb.utils.utils import tmdb_get
2from lib.utils.general.utils import (
3 Indexer,
4 IndexerType,
5 add_next_button,
6 build_list_item,
7 execute_thread_pool,
8 set_content_type,
9 set_media_infoTag,
10 set_watched_title,
11)
12
13from lib.utils.kodi.utils import (
14 ADDON_HANDLE,
15 apply_section_view,
16 build_url,
17 end_of_directory,
18 get_setting,
19 kodilog,
20 make_list_item,
21 notification,
22 translation,
23)
24
25from xbmcplugin import addDirectoryItem
26import json
27
28
29def check_jackgram_active():
30 jackgram_enabled = get_setting("jackgram_enabled")
31 if not jackgram_enabled:
32 notification(translation(90422))
33 return False
34 return True
35
36
37def check_and_get_jackgram_client():
38 if not check_jackgram_active():
39 return None
40 from lib.utils.clients.utils import get_client
41
42 return get_client(Indexer.JACKGRAM)
43
44
45def list_jackgram_latest_movies(query):
46 page = int(query.get("page"))
47 jackgram_client = check_and_get_jackgram_client()
48 if not jackgram_client:
49 end_of_directory(cache=False)
50 return
51 results = jackgram_client.get_latest_movies(page=page)
52 process_results(
53 results, add_jackgram_title_item, "list_jackgram_latest_movies", page
54 )
55
56
57def list_jackgram_latest_series(query):
58 page = int(query.get("page"))
59 jackgram_client = check_and_get_jackgram_client()
60 if not jackgram_client:
61 end_of_directory(cache=False)
62 return
63 results = jackgram_client.get_latest_series(page=page)
64 process_results(
65 results, add_jackgram_title_item, "list_jackgram_latest_series", page
66 )
67
68
69def list_jackgram_raw_files(query):
70 page = int(query.get("page"))
71 jackgram_client = check_and_get_jackgram_client()
72 if not jackgram_client:
73 end_of_directory(cache=False)
74 return
75 results = jackgram_client.get_files(page=page)
76 process_results(
77 results, add_jackgram_raw_file_item, "list_jackgram_raw_files", page
78 )
79
80
81def add_jackgram_raw_file_item(item):
82 list_item = build_list_item(item["file_name"], icon="trending.png")
83 list_item.setProperty("IsPlayable", "true")
84 item["type"] = IndexerType.DIRECT
85 item["is_torrent"] = False
86
87 token = get_setting("jackgram_token", "")
88 if token and "url" in item:
89 item["url"] = f"{item['url']}|Authorization=Bearer {token}"
90
91 addDirectoryItem(
92 ADDON_HANDLE,
93 build_url("play_media", data=item),
94 list_item,
95 isFolder=False,
96 )
97
98
99def add_jackgram_title_item(entry):
100 api_type = entry["type"]
101 title = entry["title"]
102 tmdb_id = entry["tmdb_id"]
103
104 details = tmdb_get(f"{api_type}_details", tmdb_id)
105 if details is None:
106 kodilog(f"Failed to get details for {api_type} with ID {tmdb_id}")
107 return
108
109 # Normalize mode
110 mode = "movies" if api_type == "movie" else api_type
111
112 imdb_id = getattr(details, "external_ids").get("imdb_id")
113 tvdb_id = getattr(details, "external_ids").get("tvdb_id")
114 entry["ids"] = {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id}
115
116 list_item = make_list_item(label=title)
117 set_media_infoTag(list_item, data=details, mode=mode)
118
119 addDirectoryItem(
120 ADDON_HANDLE,
121 build_url("list_jackgram_title_sources", data=json.dumps(entry)),
122 list_item,
123 isFolder=True,
124 )
125
126
127def list_jackgram_title_sources(query):
128 parent_data = json.loads(query["data"])
129 set_watched_title(
130 title=parent_data["title"],
131 ids=parent_data["ids"],
132 tg_data=parent_data,
133 mode="tg_latest",
134 )
135 set_content_type(parent_data["type"])
136 execute_thread_pool(parent_data["files"], add_jackgram_source_item, parent_data)
137 end_of_directory()
138
139
140def add_jackgram_source_item(file_entry, parent_data):
141 mode = file_entry["mode"]
142 title = file_entry["title"]
143
144 list_item = make_list_item(label=title)
145 if mode == "tv":
146 details = tmdb_get(
147 "episode_details",
148 params={
149 "id": parent_data["tmdb_id"],
150 "season": file_entry["season"],
151 "episode": file_entry["episode"],
152 },
153 )
154 else:
155 details = tmdb_get("movie_details", parent_data["tmdb_id"])
156
157 list_item.setProperty("IsPlayable", "true")
158 set_media_infoTag(list_item, data=details, mode=mode)
159
160 merged_data = {**parent_data, **file_entry}
161
162 merged_data["type"] = IndexerType.DIRECT
163 merged_data["is_torrent"] = False
164
165 token = get_setting("jackgram_token", "")
166 if token and "url" in merged_data:
167 merged_data["url"] = f"{merged_data['url']}|Authorization=Bearer {token}"
168
169 addDirectoryItem(
170 ADDON_HANDLE,
171 build_url("play_media", data=json.dumps(merged_data)),
172 list_item,
173 isFolder=False,
174 )
175
176
177def process_results(results, callback, next_button_action, page):
178 if not results:
179 kodilog("No results found or request failed.")
180 end_of_directory()
181 return
182
183 # Sort results by date (newest first)
184 results = sorted(results, key=lambda x: x.get("date") or "", reverse=True)
185
186 execute_thread_pool(results, callback)
187 add_next_button(next_button_action, page=page)
188 end_of_directory()
189 apply_section_view("view.downloads", content_type="files")
plugin.video.jacktook/lib/clients/mdblist/mdblist.py +174 -0
……@@ -0,0 +1,174 @@
1from urllib.parse import quote
2from lib.clients.tmdb.utils.utils import mdblist_get, tmdb_get
3from lib.utils.general.utils import (
4 build_list_item,
5 make_listing,
6 set_content_type,
7 set_pluging_category,
8)
9from lib.utils.kodi.utils import (
10 ADDON_HANDLE,
11 build_url,
12 end_of_directory,
13 notification,
14 show_keyboard,
15 translation,
16)
17
18from xbmcplugin import addDirectoryItem
19
20
21def search_mdbd_lists(params):
22 mode = params.get("mode", "movie")
23 page = int(params.get("page", 1))
24 set_pluging_category("MDblist - Search Lists")
25
26 query = ""
27 # Show keyboard for search query
28 query = show_keyboard(id=90006, default=query)
29 if not query:
30 end_of_directory(cache=False)
31 return
32 results = mdblist_get(path="search_lists", params={"query": query, "page": page})
33 if not results:
34 notification(translation(90519))
35 end_of_directory(cache=False)
36 return
37 for item in results:
38 label = item.get("name", "Unnamed List")
39 list_id = item.get("id")
40 list_item = build_list_item(label, "mdblist.png")
41 addDirectoryItem(
42 ADDON_HANDLE,
43 build_url("show_mdblist_list", list_id=list_id, mode=mode),
44 list_item,
45 isFolder=True,
46 )
47 end_of_directory()
48
49
50def user_mdbd_lists(params):
51 mode = params.get("mode", "movie")
52 set_pluging_category("MDblist - User Lists")
53 results = mdblist_get(path="get_user_lists")
54 if not results:
55 notification(translation(90520))
56 return
57 for item in results:
58 label = item.get("name", "Unnamed List")
59 list_id = item.get("id")
60 list_item = build_list_item(label, "mdblist.png")
61 addDirectoryItem(
62 ADDON_HANDLE,
63 build_url("show_mdblist_list", list_id=list_id, mode=mode),
64 list_item,
65 isFolder=True,
66 )
67 end_of_directory()
68
69
70def top_mdbd_lists(params):
71 mode = params.get("mode", "movie")
72 set_pluging_category("MDblist - Top Lists")
73 results = mdblist_get(path="top_mdbd_lists")
74 if not results:
75 notification(translation(90521))
76 return
77 for item in results:
78 label = item.get("name", "Unnamed List")
79 list_id = item.get("id")
80 list_item = build_list_item(label, "mdblist.png")
81 addDirectoryItem(
82 ADDON_HANDLE,
83 build_url("show_mdblist_list", list_id=list_id, mode=mode),
84 list_item,
85 isFolder=True,
86 )
87 end_of_directory()
88
89
90def show_mdblist_list(params):
91 list_id = params.get("list_id")
92 mode = params.get("mode", "movies")
93 offset = int(params.get("offset", 0))
94 limit = int(params.get("limit", 10))
95
96 set_pluging_category(f"MDblist List {list_id}")
97 set_content_type(mode)
98
99 result = mdblist_get(
100 "get_list_items",
101 params={
102 "list_id": list_id,
103 "limit": limit,
104 "offset": offset,
105 "append_to_response": "genre,poster",
106 "unified": True,
107 },
108 )
109 if not result:
110 notification(translation(90431))
111 return
112
113 for item in result:
114 ids = {
115 "tmdb_id": item.get("id", ""),
116 "tvdb_id": item.get("tvdb_id", ""),
117 "imdb_id": item.get("imdb_id", ""),
118 }
119
120 res = tmdb_get("find_by_imdb_id", ids.get("imdb_id"))
121 if res:
122 if res.get("tv_results"):
123 overview = res["tv_results"][0]["overview"]
124 poster_path = res["tv_results"][0]["poster_path"]
125 elif res.get("movie_results"):
126 overview = res["movie_results"][0]["overview"]
127 poster_path = res["movie_results"][0]["poster_path"]
128 else:
129 overview = None
130 poster_path = None
131
132 item.update({"overview": overview})
133 item.update({"poster_path": poster_path})
134
135 item["ids"] = ids
136
137 if item.get("mediatype") == "show":
138 url = build_url(
139 "show_seasons_details",
140 ids=ids,
141 mode="tv",
142 )
143 is_folder = True
144 else:
145 url = build_url(
146 "search",
147 mode="movies",
148 query=quote(item.get("title", "") or ""),
149 ids=ids,
150 )
151 is_folder = False
152
153 list_item = make_listing(item)
154 addDirectoryItem(
155 ADDON_HANDLE,
156 url,
157 list_item,
158 isFolder=is_folder,
159 )
160
161 list_item = build_list_item("Next Page", "nextpage.png")
162 addDirectoryItem(
163 ADDON_HANDLE,
164 build_url(
165 "show_mdblist_list",
166 list_id=list_id,
167 mode=mode,
168 offset=offset + limit,
169 limit=limit,
170 ),
171 list_item,
172 isFolder=True,
173 )
174 end_of_directory()
plugin.video.jacktook/lib/clients/medifusion.py +132 -0
……@@ -0,0 +1,132 @@
1import re
2from typing import List, Dict, Any, Optional, Callable
3
4from lib.clients.base import BaseClient, TorrentStream
5from lib.utils.kodi.utils import convert_size_to_bytes, get_setting, translation
6from lib.utils.general.utils import (
7 MEDIA_FUSION_DEFAULT_KEY,
8 USER_AGENT_HEADER,
9 get_cached,
10 set_cached,
11)
12from requests.utils import urlparse
13
14
15class MediaFusion(BaseClient):
16 def __init__(self, host: str, notification: Callable) -> None:
17 super().__init__(host, notification)
18 self.encryption_url = "https://mediafusion.elfhosted.com/encrypt-user-data"
19 self.api_key = self.extract_api_key()
20
21 def extract_api_key(self) -> str:
22 if get_setting("real_debrid_enabled"):
23 api_key = get_cached(path="md.rd.key")
24 if api_key:
25 return api_key
26 config_json = self.get_config_json()
27 config_json["streaming_provider"] = {
28 "token": get_setting("real_debrid_token"),
29 "service": "realdebrid",
30 "only_show_cached_streams": True,
31 }
32 path = self.session.post(self.encryption_url, json=config_json, timeout=4.0)
33 path = path.json()["encrypted_str"]
34 api_key = (
35 path.replace(self.host, "").replace("manifest.json", "").strip("/")
36 )
37 set_cached(data=api_key, path="md.rd.key")
38 return api_key
39 else:
40 return MEDIA_FUSION_DEFAULT_KEY
41
42 def get_config_json(self) -> Dict[str, Any]:
43 return {
44 "streaming_provider": {
45 "token": "",
46 "service": "",
47 "only_show_cached_streams": False,
48 },
49 "enable_catalogs": False,
50 "max_streams_per_resolution": 99,
51 "torrent_sorting_priority": [],
52 "certification_filter": ["Disable"],
53 "nudity_filter": ["Disable"],
54 }
55
56 def search(
57 self,
58 imdb_id: str,
59 mode: str,
60 media_type: str,
61 season: Optional[int],
62 episode: Optional[int],
63 ) -> Optional[List[TorrentStream]]:
64 try:
65 if mode == "tv" or media_type == "tv":
66 url = f"{self.host}/{self.api_key}/stream/series/{imdb_id}:{season}:{episode}.json"
67 elif mode == "movies" or media_type == "movies":
68 url = f"{self.host}/{self.api_key}/stream/movie/{imdb_id}.json"
69 else:
70 self.handle_exception(translation(30233))
71 return None
72
73 res = self.session.get(url, headers=USER_AGENT_HEADER, timeout=10)
74 if res.status_code != 200:
75 return
76 return self.parse_response(res.json())
77 except Exception as e:
78 self.handle_exception(f"{translation(30233)}: {str(e)}")
79
80 def parse_response(self, res: Dict[str, Any]) -> List[TorrentStream]:
81 results = []
82 for item in res["streams"]:
83 info_hash = self.extract_info_hash(item)
84 parsed_item = self.parse_stream_title(item)
85 results.append(
86 TorrentStream(
87 title=parsed_item["title"],
88 type="Torrent",
89 indexer="MediaFusion",
90 guid=info_hash,
91 infoHash=info_hash,
92 size=parsed_item["size"],
93 seeders=parsed_item["seeders"],
94 languages=parsed_item["languages"],
95 provider=parsed_item["provider"],
96 publishDate="",
97 peers=0,
98 fullLanguages="",
99 )
100 )
101 return results
102
103 def extract_info_hash(self, item: Dict[str, Any]) -> str:
104 if "url" in item:
105 path = urlparse(item["url"]).path.split("/")
106 info_hash = path[path.index("stream") + 1]
107 else:
108 info_hash = item["infoHash"]
109 return info_hash
110
111 def parse_stream_title(self, item: Dict[str, Any]) -> Dict[str, Any]:
112 description = item["description"].splitlines()
113 title = description[0]
114 provider = item["name"].split()[0].title()
115 size = convert_size_to_bytes(self.extract_size_string(item["description"]))
116 seeders = self.extract_seeders(item["description"])
117
118 return {
119 "title": title,
120 "size": size,
121 "seeders": seeders,
122 "languages": [],
123 "provider": provider,
124 }
125
126 def extract_size_string(self, details: str) -> str:
127 size_match = re.search(r"💾 (\d+(?:\.\d+)?\s*(GB|MB))", details, re.IGNORECASE)
128 return size_match.group(1) if size_match else ""
129
130 def extract_seeders(self, details: str) -> int:
131 seeders_match = re.search(r"👤 (\d+)", details)
132 return int(seeders_match.group(1)) if seeders_match else 0
plugin.video.jacktook/lib/clients/peerflix.py +49 -0
……@@ -0,0 +1,49 @@
1import re
2from lib.clients.base import BaseClient
3from lib.utils.general.utils import USER_AGENT_HEADER
4from lib.utils.kodi.utils import translation
5
6
7class Peerflix(BaseClient):
8 def __init__(self, host, notification, language):
9 super().__init__(host, notification)
10 self.language = language.lower()
11
12 def search(self, imdb_id, mode, media_type, season, episode):
13 try:
14 if mode == "tv" or media_type == "tv":
15 url = f"{self.host}/language={self.language}/stream/series/{imdb_id}:{season}:{episode}.json"
16 elif mode == "movies" or media_type == "movies":
17 url = f"{self.host}/language={self.language}/stream/movie/{imdb_id}.json"
18 res = self.session.get(url, headers=USER_AGENT_HEADER, timeout=10)
19 if res.status_code != 200:
20 return
21 return self.parse_response(res)
22 except Exception as e:
23 self.handle_exception(f"{translation(30234)}: {str(e)}")
24
25 def parse_response(self, res):
26 res = res.json()
27 results = []
28 for item in res["streams"]:
29 results.append(
30 {
31 "title": item["title"].splitlines()[0],
32 "type": "Torrent",
33 "indexer": "Peerflix",
34 "guid": item["infoHash"],
35 "infoHash": item["infoHash"],
36 "size":item["sizebytes"] or 0,
37 "seeders": item.get("seed", 0) or 0,
38 "languages": [item["language"]],
39 "fullLanguages": [item["language"]],
40 "provider": self.extract_provider(item["title"]),
41 "publishDate": "",
42 "peers": 0,
43 }
44 )
45 return results
46
47 def extract_provider(self, title):
48 match = re.search(r"🌐.* ([^ \n]+)", title)
49 return match.group(1) if match else ""
plugin.video.jacktook/lib/clients/plex.py +47 -0
……@@ -0,0 +1,47 @@
1from lib.api.plex.media_server import convert_to_plex_id, get_media
2
3
4class Plex:
5 def __init__(self, discovery_url, auth_token, access_token, notification):
6 self.discovery_url = discovery_url
7 self.auth_token = auth_token
8 self.access_token = access_token
9 self._notification = notification
10
11 def search(self, imdb_id, mode, media_type, season, episode):
12 plex_id = convert_to_plex_id(
13 url=self.discovery_url,
14 access_token=self.access_token,
15 auth_token=self.auth_token,
16 id=imdb_id,
17 mode=mode,
18 media_type=media_type,
19 season=season,
20 episode=episode,
21 )
22
23 if not plex_id:
24 return
25
26 media = get_media(
27 url=self.discovery_url,
28 token=self.access_token,
29 guid=plex_id,
30 )
31
32 streams = [meta.get_streams() for meta in media]
33 return self.parse_response(streams)
34
35 def parse_response(self, streams):
36 results = []
37 for stream in streams:
38 for item in stream:
39 results.append(
40 {
41 "title": item["title"],
42 "indexer": item["indexer"],
43 "downloadUrl": item["url"],
44 "publishDate": "",
45 }
46 )
47 return results
plugin.video.jacktook/lib/clients/prowlarr.py +193 -0
……@@ -0,0 +1,193 @@
1from lib.clients.base import BaseClient, TorrentStream
2from lib.jacktook.utils import kodilog
3from lib.utils.kodi.logging import summarize_locator_for_log
4from lib.utils.kodi.utils import get_setting, notification, translation
5from lib.utils.kodi.settings import get_prowlarr_timeout
6
7from typing import Dict, List, Optional, Any, Callable
8import concurrent.futures
9
10
11class Prowlarr(BaseClient):
12 def __init__(
13 self, host: str, apikey: str, port: str, notification: Callable
14 ) -> None:
15 super().__init__(host, notification)
16 self.base_url = f"{self.host}:{port}/api/v1/search"
17 self.apikey = apikey
18
19 def search(
20 self,
21 query: str,
22 mode: str,
23 season: Optional[int] = None,
24 episode: Optional[int] = None,
25 indexers: Optional[str] = None,
26 variant=None,
27 year: Optional[int] = None,
28 ) -> Optional[List[TorrentStream]]:
29 headers = {
30 "Accept": "application/json",
31 "Content-Type": "application/json",
32 "X-Api-Key": self.apikey,
33 }
34 try:
35 season = self._coerce_int(season)
36 episode = self._coerce_int(episode)
37 results = []
38 futures = []
39
40 if mode == "movies" and year:
41 query_with_year = f"{query} {year}"
42 else:
43 query_with_year = query
44
45 with concurrent.futures.ThreadPoolExecutor() as executor:
46 if mode == "tv" and season:
47 if episode:
48 params_ep: Dict[str, Any] = {
49 "query": f"{query} S{season:02d}E{episode:02d}",
50 "type": "search",
51 "categories": [5000, 8000],
52 }
53 if indexers:
54 indexer_ids = [
55 i.strip()
56 for i in indexers.replace(",", " ").split()
57 if i.strip()
58 ]
59 if indexer_ids:
60 params_ep["indexerIds"] = indexer_ids
61 futures.append(
62 executor.submit(
63 self.session.get,
64 self.base_url,
65 params=params_ep,
66 timeout=get_prowlarr_timeout(),
67 headers=headers,
68 )
69 )
70 # Season pack query
71 if get_setting("include_season_packs"):
72 params_season: Dict[str, Any] = {
73 "query": f"{query} S{season:02d}",
74 "type": "search",
75 "categories": [5000, 8000],
76 }
77 if indexers:
78 indexer_ids = [
79 i.strip()
80 for i in indexers.replace(",", " ").split()
81 if i.strip()
82 ]
83 if indexer_ids:
84 params_season["indexerIds"] = indexer_ids
85 futures.append(
86 executor.submit(
87 self.session.get,
88 self.base_url,
89 params=params_season,
90 timeout=get_prowlarr_timeout(),
91 headers=headers,
92 )
93 )
94 else:
95 params: Dict[str, Any] = {
96 "query": query_with_year,
97 "type": "search",
98 }
99 if mode == "movies":
100 params["categories"] = [2000, 8000]
101 elif mode == "anime":
102 params["categories"] = [2000, 5070, 5000, 127720, 140679]
103 if indexers:
104 indexer_ids = [
105 i.strip()
106 for i in indexers.replace(",", " ").split()
107 if i.strip()
108 ]
109 if indexer_ids:
110 params["indexerIds"] = indexer_ids
111 futures.append(
112 executor.submit(
113 self.session.get,
114 self.base_url,
115 params=params,
116 timeout=get_prowlarr_timeout(),
117 headers=headers,
118 )
119 )
120
121 for future in concurrent.futures.as_completed(futures):
122 response = future.result()
123 if response.status_code != 200:
124 notification(f"{translation(30230)} {response.status_code}")
125 else:
126 res = self.parse_response(response)
127 if res:
128 results.extend(res)
129 return results if results else None
130 except Exception as e:
131 self.handle_exception(f"{translation(30230)}: {str(e)}")
132 return None
133
134 @staticmethod
135 def _coerce_int(value: Any) -> Optional[int]:
136 try:
137 return int(value)
138 except (TypeError, ValueError):
139 return None
140
141 def parse_response(self, res: Any) -> List[TorrentStream]:
142 response = res.json()
143 results = []
144 for res in response:
145 title = res.get("title", "")
146 provider = res.get("indexer")
147 guid = res.get("guid", "")
148 download_url = res.get("downloadUrl", "")
149 magnet_url = res.get("magnetUrl", "")
150 info_url = res.get("infoUrl", "")
151 info_hash = res.get("infoHash", "")
152
153 kodilog(
154 "Prowlarr parsed result: title={!r}, provider={!r}, guid={!r}, download={!r}, magnet={!r}, info={!r}, infohash={!r}, has_magnet={}, has_http_url={}, has_infohash={}".format(
155 title,
156 provider,
157 summarize_locator_for_log(guid),
158 summarize_locator_for_log(download_url),
159 summarize_locator_for_log(magnet_url),
160 summarize_locator_for_log(info_url),
161 str(info_hash).lower()[:12],
162 bool(
163 str(magnet_url).startswith("magnet:?")
164 or str(download_url).startswith("magnet:?")
165 or str(guid).startswith("magnet:?")
166 ),
167 bool(
168 str(download_url).startswith(("http://", "https://"))
169 or str(info_url).startswith(("http://", "https://"))
170 or str(guid).startswith(("http://", "https://"))
171 ),
172 bool(info_hash),
173 )
174 )
175
176 results.append(
177 TorrentStream(
178 title=title,
179 type="Torrent",
180 indexer="Prowlarr",
181 provider=provider,
182 peers=int(res.get("peers", 0)),
183 seeders=int(res.get("seeders", 0)),
184 guid=guid,
185 infoHash=info_hash,
186 size=int(res.get("size", 0)),
187 languages=res.get("languages", []),
188 fullLanguages=res.get("fullLanguages", ""),
189 publishDate=res.get("publishDate", ""),
190 url=download_url,
191 )
192 )
193 return results
plugin.video.jacktook/lib/clients/simkl.py +52 -0
……@@ -0,0 +1,52 @@
1import json
2import requests
3
4class SIMKL:
5 def __init__(self):
6 self.ClientID = (
7 "59dfdc579d244e1edf6f89874d521d37a69a95a1abd349910cb056a1872ba2c8"
8 )
9 self.base_url = "https://api.simkl.com/"
10 self.imagePath = "https://wsrv.nl/?url=https://simkl.in/episodes/%s_w.webp"
11
12 def _to_url(self, url=""):
13 if url.startswith("/"):
14 url = url[1:]
15 return "%s/%s" % (self.base_url[:-1], url)
16
17 def make_request(self, endpoint, params):
18 res = requests.get(
19 f"{self.base_url}{endpoint}",
20 params=params,
21 )
22 if res.status_code == 200:
23 return json.loads(res.text)
24 else:
25 raise Exception(f"Simkl Error::{res.text}")
26
27 def get_anime_info(self, anilist_id):
28 _, simkl_id = self.get_simkl_id("anilist", anilist_id)
29 params = {"extended": "full", "client_id": self.ClientID}
30 return self.make_request("anime/" + str(simkl_id), params=params)
31
32 def get_anilist_episodes(self, mal_id):
33 _, simkl_id = self.get_simkl_id("mal", mal_id)
34 params = {
35 "extended": "full",
36 }
37 return self.make_request("anime/episodes/" + str(simkl_id), params=params)
38
39 def get_simkl_id(self, send_id, anime_id):
40 params = {
41 send_id: anime_id,
42 "client_id": self.ClientID,
43 }
44 res = self.make_request("search/id", params=params)
45 simkl = res[0]["ids"]["simkl"]
46 return simkl
47
48 def get_mapping_ids(self, send_id, anime_id):
49 simkl_id = self.get_simkl_id(send_id, anime_id)
50 params = {"extended": "full", "client_id": self.ClientID}
51 res = self.make_request("anime/" + str(simkl_id), params=params)
52 return res["ids"]
plugin.video.jacktook/lib/clients/stremio/addon_client.py +302 -0
……@@ -0,0 +1,302 @@
1from lib.api.stremio.addon_manager import Addon, build_addon_instance_label
2from lib.api.stremio.models import Stream, Meta, MetaPreview
3from lib.clients.base import BaseClient, TorrentStream
4
5from lib.utils.debrid.debrid_utils import process_external_cache
6from lib.utils.general.utils import USER_AGENT_HEADER, IndexerType, info_hash_to_magnet
7from lib.utils.kodi.settings import get_int_setting
8from lib.utils.kodi.utils import convert_size_to_bytes, get_setting, kodilog
9from lib.utils.localization.language_detection import find_languages_in_string
10
11import re
12from typing import List, Dict, Optional, Any
13
14
15EXCLUDED_RD_ADDONS = ["org.nuvio.streams", "org.mycine.addon"]
16
17
18class StremioAddonCatalogsClient(BaseClient):
19 def __init__(self, params: Dict[str, Any]) -> None:
20 super().__init__(None, None)
21 self.params = params
22 self.base_url = self.params["addon_url"]
23
24 def search(
25 self,
26 imdb_id: str,
27 mode: str,
28 media_type: str,
29 season: Optional[int],
30 episode: Optional[int],
31 dialog: Any,
32 ) -> None:
33 pass
34
35 def parse_response(self, res: Any) -> List[TorrentStream]:
36 return []
37
38 def search_catalog(self, query: str) -> Optional[Dict[str, Any]]:
39 return self.get_catalog_info(search=query)
40
41 def get_catalog_info(self, **kwargs) -> Optional[Dict[str, Any]]:
42 extra_path = ""
43 for key, value in kwargs.items():
44 if value:
45 extra_path += f"/{key}={value}"
46
47 if not extra_path:
48 path_suffix = ".json"
49 else:
50 path_suffix = f"{extra_path}.json"
51
52 catalog_type = self.params.get("catalog_type")
53 catalog_id = self.params.get("catalog_id")
54 url = f"{self.base_url}/catalog/{catalog_type}/{catalog_id}{path_suffix}"
55
56 kodilog(f"Using Stremio addon catalog URL: {url}")
57
58 res = self.session.get(
59 url, headers=USER_AGENT_HEADER, timeout=get_int_setting("stremio_timeout")
60 )
61 if res.status_code != 200:
62 return
63
64 data = res.json()
65 if "metas" in data:
66 data["metas"] = [MetaPreview.from_dict(m) for m in data["metas"]]
67 return data
68
69 def get_meta_info(self) -> Optional[Dict[str, Any]]:
70 catalog_type = self.params.get("catalog_type")
71 meta_id = self.params.get("meta_id")
72 url = f"{self.base_url}/meta/{catalog_type}/{meta_id}.json"
73
74 kodilog(f"Using Stremio addon meta URL: {url}")
75
76 res = self.session.get(
77 url, headers=USER_AGENT_HEADER, timeout=get_int_setting("stremio_timeout")
78 )
79 if res.status_code != 200:
80 return
81
82 data = res.json()
83 if "meta" in data:
84 data["meta"] = Meta.from_dict(data["meta"])
85 return data
86
87 def get_stream_info(self) -> Optional[Dict[str, Any]]:
88 catalog_type = self.params.get("catalog_type")
89 meta_id = self.params.get("meta_id")
90 url = f"{self.base_url}/stream/{catalog_type}/{meta_id}.json"
91
92 kodilog(f"Using Stremio addon stream URL: {url}")
93
94 res = self.session.get(
95 url, headers=USER_AGENT_HEADER, timeout=get_int_setting("stremio_timeout")
96 )
97 if res.status_code != 200:
98 return
99
100 data = res.json()
101 if "streams" in data:
102 data["streams"] = [Stream.from_dict(s) for s in data["streams"]]
103 return data
104
105
106class StremioAddonClient(BaseClient):
107 def __init__(self, addon: Addon) -> None:
108 super().__init__(None, None)
109 self.addon = addon
110 self.instance_label = build_addon_instance_label(addon)
111
112 def search(
113 self,
114 video_id: str,
115 mode: str,
116 media_type: str,
117 season: Optional[int],
118 episode: Optional[int],
119 ) -> List[TorrentStream]:
120 try:
121 prefix = video_id.split(":")[0] if ":" in video_id else "tt"
122
123 if mode == "tv" or media_type == "tv":
124 if not self.addon.isSupported("stream", "series", prefix):
125 return []
126
127 if prefix == "kitsu":
128 url = f"{self.addon.url()}/stream/series/{video_id}:{episode}.json"
129 else:
130 url = f"{self.addon.url()}/stream/series/{video_id}:{season}:{episode}.json"
131 elif mode == "movies" or media_type == "movies":
132 if not self.addon.isSupported("stream", "movie", prefix):
133 return []
134 url = f"{self.addon.url()}/stream/movie/{video_id}.json"
135 else:
136 return []
137
138 kodilog("Using Stremio addon search URL: " + url)
139
140 res = self.session.get(
141 url,
142 headers=USER_AGENT_HEADER,
143 timeout=get_int_setting("stremio_timeout"),
144 )
145 if res.status_code != 200:
146 return []
147 response = self.parse_response(res)
148 kodilog(
149 f"Stremio addon {self.addon.manifest.name} returned {len(response)} results"
150 )
151 return response
152 except Exception as e:
153 self.handle_exception(f"Error in {self.addon.manifest.name}: {str(e)}")
154 return []
155
156 def parse_response(
157 self, res: Any, is_external_cache: bool = False
158 ) -> List[TorrentStream]:
159 if not is_external_cache:
160 res_json = res.json()
161 streams = res_json.get("streams", [])
162 else:
163 pass
164
165 if hasattr(res, "json"):
166 data = res.json()
167 elif isinstance(res, dict):
168 data = res
169 else:
170 data = {}
171
172 results = []
173
174 streams = data.get("streams", [])
175
176 for item in streams:
177 stream = Stream.from_dict(item)
178 parsed = self.parse_torrent_description(
179 stream.description or stream.title or ""
180 )
181
182 if is_external_cache:
183 match = re.search(r"\b\w{40}\b", stream.url or "")
184 info_hash = match.group() if match else item.get("infoHash")
185 url = ""
186 is_cached = True
187 else:
188 info_hash = stream.infoHash
189 url = stream.url
190 is_cached = True if url else False
191
192 stream_type = IndexerType.STREMIO_DEBRID if url else IndexerType.TORRENT
193 stream_provider = stream.get_provider() or parsed["provider"]
194 stream_subindexer = stream.get_sub_indexer(self.addon)
195 stream_size = stream.get_parsed_size() or item.get("sizebytes") or parsed["size"]
196 stream_seeders = item.get("seed", 0) or parsed["seeders"]
197
198 results.append(
199 TorrentStream(
200 title=stream.get_parsed_title(),
201 type=stream_type,
202 indexer=self.addon.manifest.name.split(" ")[0],
203 subindexer=stream_subindexer,
204 addonKey=self.addon.key(),
205 addonName=self.addon.manifest.name,
206 addonInstanceLabel=self.instance_label,
207 guid=info_hash_to_magnet(info_hash) if info_hash else "",
208 infoHash=info_hash if info_hash else "",
209 size=stream_size,
210 seeders=stream_seeders,
211 languages=parsed["languages"],
212 fullLanguages=parsed["languages"],
213 provider=stream_provider,
214 publishDate="",
215 peers=0,
216 url=url if url else "",
217 proxyHeaders=stream.get_proxy_headers(),
218 isCached=is_cached,
219 )
220 )
221 return results
222
223 def parse_torrent_description(self, desc: str) -> Dict[str, Any]:
224 if not desc:
225 return {
226 "size": 0,
227 "seeders": 0,
228 "provider": "",
229 "languages": [],
230 }
231 # Extract size
232 size_pattern = r"💾 ([\d.]+ (?:GB|MB))"
233 size_match = re.search(size_pattern, desc)
234 size = size_match.group(1) if size_match else None
235 if size:
236 size = convert_size_to_bytes(size)
237
238 # Extract seeders
239 seeders_pattern = r"👤 (\d+)"
240 seeders_match = re.search(seeders_pattern, desc)
241 seeders = int(seeders_match.group(1)) if seeders_match else None
242
243 # Extract provider
244 provider_pattern = r"([🌐🔗⚙️])\s*([^🌐🔗⚙️]+)"
245 provider_match = re.findall(provider_pattern, desc)
246
247 words = [match[1].strip() for match in provider_match]
248 if words:
249 words = words[-1].splitlines()[0]
250
251 provider = words
252
253 return {
254 "size": size or 0,
255 "seeders": seeders or 0,
256 "provider": provider or "",
257 "languages": find_languages_in_string(desc),
258 }
259
260 def should_use_rd_cache(self) -> bool:
261 """Checks if RD cache should be used for this addon."""
262 return bool(
263 get_setting("real_debrid_enabled")
264 and get_setting("real_debrid_cached_check")
265 and not get_setting("torrent_enable")
266 and not get_setting("stremio_loggedin")
267 and self.addon.manifest.id not in EXCLUDED_RD_ADDONS
268 )
269
270 def should_use_ad_cache(self) -> bool:
271 """Checks if AD cache should be used for this addon."""
272 return bool(
273 get_setting("alldebrid_enabled")
274 and get_setting("alldebrid_cached_check")
275 and not get_setting("torrent_enable")
276 and not get_setting("stremio_loggedin")
277 and self.addon.manifest.id not in EXCLUDED_RD_ADDONS
278 )
279
280 def get_cached_results(
281 self,
282 imdb_id: str,
283 mode: str,
284 season: Optional[int],
285 episode: Optional[int],
286 debridType="",
287 debridToken="",
288 ) -> List[TorrentStream]:
289 cached_results = process_external_cache(
290 data={
291 "imdb_id": imdb_id,
292 "season": season,
293 "episode": episode,
294 "mode": mode,
295 },
296 debrid=debridType,
297 token=str(debridToken),
298 url=self.addon.url(),
299 )
300 if not cached_results:
301 return []
302 return self.parse_response(cached_results, is_external_cache=True)
plugin.video.jacktook/lib/clients/stremio/addon_selection.py +517 -0
……@@ -0,0 +1,517 @@
1import requests
2from datetime import timedelta
3from collections import Counter
4from lib.db.cached import cache
5from lib.utils.general.utils import USER_AGENT_HEADER
6from lib.utils.kodi.utils import (
7 kodilog,
8 get_setting,
9 set_setting,
10 ADDON,
11 translation,
12)
13from lib.utils.kodi.settings import get_int_setting
14from lib.clients.stremio.constants import (
15 STREMIO_ADDONS_KEY,
16 STREMIO_ADDONS_CATALOGS_KEY,
17 STREMIO_TV_ADDONS_KEY,
18 STREMIO_USER_ADDONS,
19 excluded_addons,
20 encode_selected_ids,
21 decode_selected_ids,
22)
23from lib.clients.stremio.helpers import get_addons, ping_addons, get_addon_merge_key
24from lib.api.stremio.addon_manager import build_addon_instance_key
25import xbmcgui
26
27
28def _ping_addons_with_progress(addons):
29 """Ping addons and show progress dialog."""
30 total_addons = len(addons)
31
32 progress_dialog = xbmcgui.DialogProgress()
33 progress_dialog.create(translation(90153), translation(90607))
34
35 def update_progress(completed, total):
36 percent = int((completed / total) * 100)
37 progress_dialog.update(percent, translation(90608) % (completed, total))
38
39 reachable_addons = ping_addons(addons, progress_callback=update_progress)
40 progress_dialog.close()
41
42 # Show summary
43 reachable_count = len(reachable_addons)
44 unreachable_count = total_addons - reachable_count
45
46 dialog = xbmcgui.Dialog()
47 dialog.ok(
48 translation(90609),
49 translation(90610) % (reachable_count, unreachable_count),
50 )
51
52 return reachable_addons
53
54
55def _build_addon_options(addons):
56 """Build list items for addon multiselect with deduplication."""
57 name_counts = Counter(addon.manifest.name for addon in addons)
58 options = []
59
60 for addon in addons:
61 name = addon.manifest.name
62 if name_counts[name] > 1:
63 label = addon.label()
64 else:
65 label = name
66
67 option = xbmcgui.ListItem(label=label, label2=f"{addon.manifest.description}")
68
69 logo = addon.manifest.logo
70 if not logo or logo.endswith(".svg"):
71 logo = "DefaultAddon.png"
72
73 option.setArt({"icon": logo})
74 options.append(option)
75
76 return options
77
78
79def _show_addon_multiselect(title, addons, selected_ids):
80 """Show multiselect dialog and return selected addon keys."""
81 options = _build_addon_options(addons)
82
83 dialog = xbmcgui.Dialog()
84 selected_ids_list = decode_selected_ids(selected_ids)
85 addon_ids = Counter(addon.manifest.id for addon in addons)
86 selected_addon_ids = [
87 addons.index(addon)
88 for addon in addons
89 if addon.key() in selected_ids_list
90 or (addon.manifest.id in selected_ids_list and addon_ids[addon.manifest.id] == 1)
91 ]
92
93 selected_indexes = dialog.multiselect(
94 title, options, preselect=selected_addon_ids, useDetails=True
95 )
96
97 if selected_indexes is None:
98 return None
99
100 return [addons[index].key() for index in selected_indexes]
101
102
103def _show_no_addons_dialog():
104 xbmcgui.Dialog().ok(
105 translation(90331),
106 translation(90332),
107 )
108
109
110def _deduplicate_addons(addons):
111 """Remove duplicate addons by key."""
112 seen_keys = set()
113 unique_addons = []
114 for addon in addons:
115 if addon.key() not in seen_keys:
116 seen_keys.add(addon.key())
117 unique_addons.append(addon)
118 return unique_addons
119
120
121def _filter_excluded_addons(addons):
122 """Filter out excluded addons and those requiring configuration."""
123 return [
124 addon
125 for addon in addons
126 if addon.manifest.id not in excluded_addons
127 and (
128 not addon.manifest.isConfigurationRequired()
129 or addon.transport_name == "custom"
130 )
131 ]
132
133
134def _filter_stream_addons_by_id_prefix(addons, allowed_prefixes):
135 normalized_allowed = {
136 str(prefix).rstrip(":") for prefix in allowed_prefixes if prefix is not None
137 }
138 filtered = []
139
140 for addon in addons:
141 for resource in addon.manifest.resources:
142 if isinstance(resource, str):
143 continue
144 if resource.name != "stream":
145 continue
146
147 resource_prefixes = {
148 str(p).rstrip(":") for p in (resource.id_prefixes or []) if p is not None
149 }
150 if resource_prefixes.intersection(normalized_allowed):
151 filtered.append(addon)
152 break
153
154 return filtered
155
156
157def stremio_filtered_selection(params):
158 dialog = xbmcgui.Dialog()
159 options = [translation(90333), translation(90334), translation(90335)]
160 selection = dialog.select(translation(90611), options)
161
162 if selection == 0:
163 stremio_toggle_addons(params, check_availability=True)
164 elif selection == 1:
165 stremio_toggle_catalogs(params, check_availability=True)
166 elif selection == 2:
167 stremio_toggle_tv_addons(params, check_availability=True)
168
169
170def stremio_toggle_addons(params, check_availability=False):
171 selected_ids = cache.get(STREMIO_ADDONS_KEY) or ""
172 addon_manager = get_addons()
173 addons = addon_manager.get_addons_with_resource("stream")
174 addons = _filter_stream_addons_by_id_prefix(addons, ["tt", "tmdb"])
175
176 addons = _deduplicate_addons(addons)
177 addons = _filter_excluded_addons(addons)
178
179 if check_availability:
180 addons = _ping_addons_with_progress(addons)
181
182 addons = list(reversed(addons))
183
184 if not addons:
185 _show_no_addons_dialog()
186 return
187
188 settings = ADDON.getSettings()
189 stremio_email = settings.getString("stremio_email")
190 title = stremio_email or translation(90333)
191
192 selected_addon_keys = _show_addon_multiselect(title, addons, selected_ids)
193
194 if selected_addon_keys is None:
195 return
196
197 cache.set(
198 STREMIO_ADDONS_KEY,
199 encode_selected_ids(selected_addon_keys),
200 timedelta(days=365 * 20),
201 )
202
203
204def stremio_toggle_catalogs(params, check_availability=False):
205 selected_ids = cache.get(STREMIO_ADDONS_CATALOGS_KEY) or ""
206 addon_manager = get_addons()
207 addons = addon_manager.get_addons_with_resource("catalog")
208
209 # Filter out Live TV addons
210 filtered_addons = []
211 for addon in addons:
212 is_tv = False
213 for res in addon.manifest.resources:
214 if isinstance(res, str):
215 if res == "stream" and (
216 "tv" in addon.manifest.types or "channel" in addon.manifest.types
217 ):
218 is_tv = True
219 break
220 elif res.name == "stream":
221 if "tv" in res.types or "channel" in res.types:
222 is_tv = True
223 break
224 if not is_tv:
225 filtered_addons.append(addon)
226 addons = filtered_addons
227
228 if check_availability:
229 addons = _ping_addons_with_progress(addons)
230
231 if not addons:
232 _show_no_addons_dialog()
233 return
234
235 settings = ADDON.getSettings()
236 stremio_email = settings.getString("stremio_email")
237 title = stremio_email or translation(90334)
238
239 selected_addon_keys = _show_addon_multiselect(title, addons, selected_ids)
240
241 if selected_addon_keys is None:
242 return
243
244 cache.set(
245 STREMIO_ADDONS_CATALOGS_KEY,
246 encode_selected_ids(selected_addon_keys),
247 timedelta(days=365 * 20),
248 )
249
250
251def stremio_toggle_tv_addons(params, check_availability=False):
252 selected_ids = cache.get(STREMIO_TV_ADDONS_KEY) or ""
253 addon_manager = get_addons()
254
255 addons = []
256 for addon in addon_manager.addons:
257 if addon.manifest.id == "org.stremio.local":
258 continue
259 for resource in addon.manifest.resources:
260 # Resource can be str or object
261 if isinstance(resource, str):
262 if resource == "stream" and (
263 "tv" in addon.manifest.types or "channel" in addon.manifest.types
264 ):
265 addons.append(addon)
266 break
267 elif resource.name == "stream":
268 if "tv" in resource.types or "channel" in resource.types:
269 addons.append(addon)
270 break
271
272 addons = _deduplicate_addons(addons)
273 addons = _filter_excluded_addons(addons)
274
275 if check_availability:
276 addons = _ping_addons_with_progress(addons)
277
278 addons = list(reversed(addons))
279
280 if not addons:
281 _show_no_addons_dialog()
282 return
283
284 settings = ADDON.getSettings()
285 stremio_email = settings.getString("stremio_email")
286 title = stremio_email or translation(90335)
287
288 selected_addon_keys = _show_addon_multiselect(title, addons, selected_ids)
289
290 if selected_addon_keys is None:
291 return
292
293 cache.set(
294 STREMIO_TV_ADDONS_KEY,
295 encode_selected_ids(selected_addon_keys),
296 timedelta(days=365 * 20),
297 )
298
299
300def add_custom_stremio_addon(params):
301 dialog = xbmcgui.Dialog()
302 url = dialog.input(
303 translation(90523), type=xbmcgui.INPUT_ALPHANUM
304 )
305 if not url:
306 dialog.ok(translation(90522), translation(90524))
307 return
308
309 if url.startswith("stremio://"):
310 url = url.replace("stremio://", "https://")
311
312 # Try to fetch the manifest from the URL
313 try:
314 response = requests.get(
315 url, headers=USER_AGENT_HEADER, timeout=get_int_setting("stremio_timeout")
316 )
317 response.raise_for_status()
318 manifest = response.json()
319 except Exception as e:
320 kodilog(f"Failed to fetch custom addon manifest: {e}")
321 dialog.ok(translation(90522), translation(90525) % e)
322 return
323
324 try:
325 id_ = manifest.get("id") or manifest.get("name")
326 if not id_:
327 dialog.ok(translation(90522), translation(90526))
328 return
329 addon_key = build_addon_instance_key(
330 {"manifest": manifest, "transportUrl": response.url}
331 )
332
333 resources = manifest.get("resources", [])
334 # Normalize resources to list of dicts or strings
335 if isinstance(resources, dict):
336 resources = [resources]
337 elif isinstance(resources, str):
338 resources = [resources]
339
340 # Determine capabilities
341 is_stream = False
342 is_catalog = False
343 is_tv_stream = False
344 types = manifest.get("types", [])
345
346 for res in resources:
347 if isinstance(res, dict):
348 res_types = res.get("types", types)
349 if res.get("name") == "stream":
350 id_prefixes = res.get("idPrefixes", [])
351 if "tt" in id_prefixes:
352 is_stream = True
353 if "tv" in res_types or "channel" in res_types:
354 is_tv_stream = True
355
356 if res.get("name") == "catalog":
357 is_catalog = True
358 elif isinstance(res, str):
359 if res == "stream":
360 # For string resource, rely on top-elevel types
361 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
363 if "tv" in types or "channel" in types:
364 is_tv_stream = True
365 if res == "catalog":
366 is_catalog = True
367
368 # Add to selected addons if stream
369 if is_stream:
370 selected_keys = decode_selected_ids(cache.get(STREMIO_ADDONS_KEY))
371 if addon_key not in selected_keys:
372 selected_keys.append(addon_key)
373 cache.set(
374 STREMIO_ADDONS_KEY,
375 encode_selected_ids(selected_keys),
376 timedelta(days=365 * 20),
377 )
378
379 # Add to selected TV stream addons
380 if is_tv_stream:
381 selected_tv_keys = decode_selected_ids(cache.get(STREMIO_TV_ADDONS_KEY))
382 if addon_key not in selected_tv_keys:
383 selected_tv_keys.append(addon_key)
384 cache.set(
385 STREMIO_TV_ADDONS_KEY,
386 encode_selected_ids(selected_tv_keys),
387 timedelta(days=365 * 20),
388 )
389
390 # Add to selected catalogs if catalog
391 if is_catalog:
392 selected_catalog_keys = decode_selected_ids(
393 cache.get(STREMIO_ADDONS_CATALOGS_KEY)
394 )
395 if addon_key not in selected_catalog_keys:
396 selected_catalog_keys.append(addon_key)
397 cache.set(
398 STREMIO_ADDONS_CATALOGS_KEY,
399 encode_selected_ids(selected_catalog_keys),
400 timedelta(days=365 * 20),
401 )
402
403 # Always add custom addon to user addons catalog
404 user_addons = list(cache.get(STREMIO_USER_ADDONS) or [])
405
406 custom_addon = {
407 "manifest": manifest,
408 "transportUrl": response.url,
409 "transportName": "custom",
410 }
411 custom_merge_key = get_addon_merge_key(custom_addon)
412 if not any(get_addon_merge_key(a) == custom_merge_key for a in user_addons):
413 user_addons.append(custom_addon)
414 cache.set(STREMIO_USER_ADDONS, user_addons, timedelta(days=365 * 20))
415
416 if is_stream or is_catalog or is_tv_stream:
417 dialog.ok(translation(90522), translation(90527))
418 else:
419 dialog.ok(translation(90522), translation(90528))
420 else:
421 dialog.ok(translation(90522), translation(90529))
422 except Exception as e:
423 dialog.ok(translation(90522), translation(90530) % e)
424
425
426def remove_custom_stremio_addon(params=None):
427 user_addons = cache.get(STREMIO_USER_ADDONS) or []
428 removable_addons = [a for a in user_addons if a.get("transportName") == "custom"]
429 if not removable_addons:
430 xbmcgui.Dialog().ok(translation(90531), translation(90532))
431 return
432
433 options = []
434 for addon in removable_addons:
435 manifest = addon.get("manifest", {})
436 name = (
437 manifest.get("name")
438 or manifest.get("id")
439 or addon.get("transportUrl", "Unknown")
440 )
441 desc = manifest.get("description", "")
442 item = xbmcgui.ListItem(label=name, label2=desc)
443
444 logo = manifest.get("logo")
445 transport_url = addon.get("transportUrl")
446 if logo and transport_url and not logo.startswith(("http://", "https://")):
447 from urllib.parse import urljoin
448
449 logo = urljoin(transport_url, logo)
450
451 if not logo or logo.endswith(".svg"):
452 logo = "DefaultAddon.png"
453 item.setArt({"icon": logo})
454 options.append(item)
455
456 dialog = xbmcgui.Dialog()
457 selected = dialog.multiselect(translation(90533), options, useDetails=True)
458 if not selected:
459 return
460
461 # Remove selected addons
462 to_remove_keys = set()
463 for idx in selected:
464 addon = removable_addons[idx]
465 addon_key = build_addon_instance_key(addon)
466 if addon_key:
467 to_remove_keys.add(addon_key)
468
469 # 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 ]
475 cache.set(STREMIO_USER_ADDONS, new_user_addons, timedelta(days=365 * 20))
476
477 # Remove from selected stream/catalogs/tv if present
478 for cache_key in [
479 STREMIO_ADDONS_KEY,
480 STREMIO_ADDONS_CATALOGS_KEY,
481 STREMIO_TV_ADDONS_KEY,
482 ]:
483 selected_keys = decode_selected_ids(cache.get(cache_key))
484 if selected_keys:
485 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 )
489
490 xbmcgui.Dialog().ok(translation(90531), translation(90534))
491
492
493def stremio_bypass_addons_select(params=None):
494 addon_manager = get_addons()
495 addons = addon_manager.get_addons_with_resource("stream")
496 addons = _filter_stream_addons_by_id_prefix(addons, ["tt", "tmdb"])
497 addons = _deduplicate_addons(addons)
498 addons = _filter_excluded_addons(addons)
499 addons = list(reversed(addons))
500
501 selected_list_str = str(get_setting("stremio_bypass_addon_list", "") or "").strip()
502 selected_values = [value.strip() for value in selected_list_str.split(",") if value.strip()]
503
504 selected_ids = []
505 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:
509 selected_ids.append(addon.key())
510
511 title = translation(90213)
512 selected_addon_keys = _show_addon_multiselect(title, addons, selected_ids)
513
514 if selected_addon_keys is None:
515 return
516
517 set_setting("stremio_bypass_addon_list", ",".join(selected_addon_keys))
plugin.video.jacktook/lib/clients/stremio/authentication.py +109 -0
……@@ -0,0 +1,109 @@
1from datetime import timedelta
2from lib.api.stremio.api_client import Stremio
3from lib.db.cached import cache
4from lib.utils.kodi.utils import (
5 get_setting,
6 kodilog,
7 set_setting,
8 translation,
9)
10from lib.clients.stremio.constants import (
11 STREMIO_ADDONS_KEY,
12 STREMIO_ADDONS_CATALOGS_KEY,
13 STREMIO_USER_ADDONS,
14)
15from lib.clients.stremio.helpers import merge_addons_lists
16import xbmcgui
17
18
19def stremio_login(params):
20 dialog = xbmcgui.Dialog()
21 dialog.ok(
22 translation(90612),
23 translation(90613)
24 )
25
26 email = dialog.input(heading=translation(90614), type=xbmcgui.INPUT_ALPHANUM)
27 if not email:
28 return
29
30 password = dialog.input(heading=translation(90615), type=xbmcgui.INPUT_ALPHANUM)
31 if not password:
32 return
33
34 log_in(email, password, dialog)
35
36
37def log_in(email, password, dialog):
38 try:
39 stremio = Stremio()
40 stremio.login(email, password)
41 except Exception as e:
42 dialog.ok(translation(90616), translation(90617) % e)
43 return
44
45 try:
46 # Only merge user account addons with custom addons
47 user_account_addons = stremio.get_my_addons() or []
48 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 ]
52 all_addons = merge_addons_lists(user_account_addons, custom_addons)
53 cache.set(STREMIO_USER_ADDONS, all_addons, timedelta(days=365 * 20))
54
55 set_setting("stremio_email", email)
56 set_setting("stremio_pass", password)
57 set_setting("stremio_loggedin", "true")
58
59 kodilog(f"Stremio addons imported: {len(all_addons)}")
60 except Exception as e:
61 dialog.ok(
62 translation(90618),
63 translation(90619),
64 )
65 kodilog(f"Failed to import addons: {e}")
66 return
67
68 dialog.ok(translation(90620), translation(90621))
69
70
71def stremio_update(params):
72 dialog = xbmcgui.Dialog()
73 confirm = dialog.yesno(
74 translation(90622),
75 translation(90623),
76 nolabel=translation(90242),
77 yeslabel=translation(32043),
78 )
79 if not confirm:
80 return
81
82 email = get_setting("stremio_email")
83 password = get_setting("stremio_pass")
84
85 log_in(email, password, dialog)
86
87
88def stremio_logout(params):
89 dialog = xbmcgui.Dialog()
90
91 confirm = dialog.yesno(
92 translation(90624),
93 translation(90625),
94 nolabel=translation(90242),
95 yeslabel=translation(90626),
96 )
97 if confirm:
98 cache.set(STREMIO_ADDONS_KEY, None, timedelta(seconds=1))
99 cache.set(STREMIO_ADDONS_CATALOGS_KEY, None, timedelta(seconds=1))
100 # Do not clear custom addons, only clear login state and user (login) addons
101 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 ]
105 cache.set(STREMIO_USER_ADDONS, custom_addons, timedelta(days=365 * 20))
106
107 set_setting("stremio_loggedin", "false")
108 set_setting("stremio_email", "")
109 set_setting("stremio_pass", "")
plugin.video.jacktook/lib/clients/stremio/catalog_menus.py +1021 -0
……@@ -0,0 +1,1021 @@
1import json
2import os
3from datetime import datetime, timedelta
4from dataclasses import asdict
5from lib.api.stremio.models import extract_proxy_headers
6from lib.clients.stremio.helpers import (
7 get_addon_by_base_url,
8 get_selected_catalogs_addons,
9 get_selected_tv_addons,
10)
11from lib.clients.tmdb.utils.utils import (
12 add_tmdb_episode_context_menu,
13 add_tmdb_movie_context_menu,
14 add_tmdb_show_context_menu,
15)
16from lib.clients.tmdb.utils.utils import tmdb_get
17from lib.utils.general.utils import add_next_button, truncate_text
18from lib.db.cached import cache
19from lib.db.pickle_db import PickleDatabase
20from lib.utils.stremio.catalogs_utils import catalogs_get_cache
21from lib.utils.kodi.utils import (
22 add_directory_items_batch,
23 build_url,
24 container_update,
25 end_of_directory,
26 kodi_play_media,
27 make_list_item,
28 notification,
29 show_keyboard,
30 kodilog,
31 translation,
32)
33from lib.utils.general.utils import info_hash_to_magnet, IndexerType
34
35from xbmcplugin import addDirectoryItem, setContent
36from lib.utils.kodi.settings import get_cache_expiration
37from lib.utils.kodi.utils import ADDON_HANDLE, ADDON_PATH
38
39
40CATALOG_PAGE_SIZE = 25
41
42
43def _stremio_search_history_key(params):
44 return "stremio_search_catalog|{}|{}|{}".format(
45 params.get("addon_url", ""),
46 params.get("catalog_type", ""),
47 params.get("catalog_id", ""),
48 )
49
50
51def _show_search_catalog_history(params):
52 history_key = _stremio_search_history_key(params)
53 list_item = make_list_item(label=translation(90006))
54 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "search.png")})
55 addDirectoryItem(
56 ADDON_HANDLE,
57 build_url(
58 "search_catalog",
59 page=1,
60 addon_url=params.get("addon_url", ""),
61 catalog_type=params.get("catalog_type", ""),
62 catalog_id=params.get("catalog_id", ""),
63 ),
64 list_item,
65 isFolder=True,
66 )
67
68 for (text,) in cache.get_list(key=history_key):
69 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 )
73 addDirectoryItem(
74 ADDON_HANDLE,
75 build_url(
76 "search_catalog",
77 page=1,
78 addon_url=params.get("addon_url", ""),
79 catalog_type=params.get("catalog_type", ""),
80 catalog_id=params.get("catalog_id", ""),
81 query=text,
82 is_keyboard=False,
83 ),
84 list_item,
85 isFolder=True,
86 )
87
88 list_item = make_list_item(label=translation(90210))
89 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")})
90 addDirectoryItem(
91 ADDON_HANDLE,
92 build_url(
93 "clear_stremio_search_history",
94 addon_url=params.get("addon_url", ""),
95 catalog_type=params.get("catalog_type", ""),
96 catalog_id=params.get("catalog_id", ""),
97 ),
98 list_item,
99 isFolder=True,
100 )
101
102 end_of_directory()
103
104
105def clear_stremio_search_history(params):
106 cache.clear_list(_stremio_search_history_key(params))
107 _show_search_catalog_history(params)
108
109
110def _build_stremio_ids(meta_id, tmdb_id="", imdb_id=""):
111 return {
112 "tmdb_id": tmdb_id or "",
113 "tvdb_id": "",
114 "imdb_id": imdb_id or "",
115 "original_id": meta_id if ":" in str(meta_id or "") else "",
116 }
117
118
119def _build_stremio_meta_payload(
120 *,
121 name,
122 meta_type,
123 meta_id,
124 tmdb_id="",
125 imdb_id="",
126 overview="",
127 poster="",
128 fanart="",
129 genres=None,
130 addon_url="",
131 catalog_type="",
132 meta_id_for_nav="",
133 tv_data=None,
134):
135 return {
136 "source": "stremio_catalog",
137 "title": name,
138 "overview": overview or "",
139 "poster": poster or "",
140 "fanart": fanart or poster or "",
141 "genres": genres or [],
142 "ids": _build_stremio_ids(meta_id, tmdb_id, imdb_id),
143 "mode": "tv" if meta_type in ["series", "anime"] else "movies",
144 "addon_url": addon_url,
145 "catalog_type": catalog_type,
146 "meta_id": meta_id_for_nav or meta_id,
147 "tv_data": tv_data or {},
148 "timestamp": datetime.now().strftime("%a, %d %b %Y %I:%M %p"),
149 }
150
151
152def _has_reliable_tmdb_ids(ids):
153 return bool((ids or {}).get("tmdb_id"))
154
155
156def _resolve_tmdb_ids_for_context_menu(ids, meta_type):
157 if _has_reliable_tmdb_ids(ids):
158 return ids
159
160 imdb_id = (ids or {}).get("imdb_id")
161 if not imdb_id:
162 return ids
163
164 result = cache.get(f"find_by_imdb_id|{imdb_id}")
165 if not result:
166 return ids
167
168 resolved_tmdb_id = ""
169 if meta_type == "movie":
170 movie_results = getattr(result, "movie_results", []) or []
171 if movie_results:
172 resolved_tmdb_id = str(movie_results[0].get("id", ""))
173 elif meta_type in ["series", "anime"]:
174 tv_results = getattr(result, "tv_results", []) or []
175 if tv_results:
176 resolved_tmdb_id = str(tv_results[0].get("id", ""))
177
178 if not resolved_tmdb_id:
179 return ids
180
181 resolved_ids = dict(ids or {})
182 resolved_ids["tmdb_id"] = resolved_tmdb_id
183 return resolved_ids
184
185
186def _filter_tmdb_context_menu(context_menu):
187 excluded_labels = {translation(90205), translation(90116)}
188 return [item for item in context_menu if item[0] not in excluded_labels]
189
190
191def _build_stremio_library_menu_item(payload):
192 return (
193 translation(90205),
194 kodi_play_media(name="add_to_library", data=json.dumps(payload)),
195 )
196
197
198def _build_stremio_settings_menu_item():
199 return (
200 translation(90116),
201 container_update(name="settings"),
202 )
203
204
205def _append_context_menu_items(list_item, context_menu):
206 if not context_menu:
207 return
208 list_item.addContextMenuItems(context_menu, False)
209
210
211def _param_truthy(value):
212 return str(value).lower() in ("1", "true", "yes")
213
214
215def list_stremio_catalogs(menu_type="", sub_menu_type=""):
216 if menu_type == "tv":
217 selected_addons = get_selected_tv_addons()
218 else:
219 selected_addons = get_selected_catalogs_addons()
220
221 if not selected_addons:
222 kodilog(f"No addons found for menu_type={menu_type}")
223 return
224
225 directory_items = []
226
227 for addon in selected_addons:
228 addon_name = addon.manifest.name
229 addon_types = addon.manifest.types
230
231 if menu_type not in addon_types:
232 continue
233
234 for catalog in addon.manifest.catalogs:
235 catalog_name = catalog.name
236 catalog_id = catalog.id
237 catalog_type = catalog.type
238
239 target_type = sub_menu_type if sub_menu_type else menu_type
240
241 # Allow 'anime' catalogs when target is 'series' and we are in 'anime' menu
242 allowed_types = [target_type]
243 if menu_type == "anime" and target_type == "series":
244 allowed_types.append("anime")
245
246 if (
247 target_type in ["movie", "series", "tv"]
248 and catalog_type not in allowed_types
249 ):
250 continue
251
252 search_capabilities = any(
253 extra.get("name") == "search" for extra in catalog.extra
254 )
255
256 if search_capabilities:
257 listitem = make_list_item(label=f"{translation(90006)} {catalog_name}")
258 listitem.setArt({"icon": addon.manifest.logo})
259
260 directory_items.append(
261 (
262 build_url(
263 "search_catalog",
264 page=1,
265 addon_url=addon.url(),
266 catalog_type=catalog.type,
267 catalog_id=catalog_id,
268 menu_type=menu_type,
269 sub_menu_type=sub_menu_type,
270 has_meta_resource=_addon_has_resource(addon, "meta", catalog_type),
271 has_stream_resource=_addon_has_resource(addon, "stream", catalog_type),
272 ),
273 listitem,
274 True,
275 )
276 )
277
278 if catalog_name or catalog_id:
279 addon_name = addon.manifest.name
280 if addon_name == "Cinemeta":
281 label = f"{addon_name} - {catalog_name or catalog_id}"
282 else:
283 label = catalog_name or catalog_id
284
285 listitem = make_list_item(label=label)
286 listitem.setArt({"icon": addon.manifest.logo})
287
288 directory_items.append(
289 (
290 build_url(
291 action="list_catalog",
292 addon_url=addon.url(),
293 menu_type=menu_type,
294 sub_menu_type=sub_menu_type,
295 catalog_type=catalog.type,
296 catalog_id=catalog.id,
297 ),
298 listitem,
299 True,
300 )
301 )
302
303 add_directory_items_batch(directory_items)
304
305
306def _get_manifest_catalog(addon_url, catalog_type, catalog_id):
307 addon = get_addon_by_base_url(addon_url)
308 if not addon:
309 return None
310
311 for catalog in addon.manifest.catalogs:
312 if catalog.type == catalog_type and catalog.id == catalog_id:
313 return catalog
314
315 return None
316
317
318def _catalog_supports_extra(addon_url, catalog_type, catalog_id, extra_name):
319 catalog = _get_manifest_catalog(addon_url, catalog_type, catalog_id)
320 if not catalog:
321 return False
322
323 return any(extra.get("name") == extra_name for extra in (catalog.extra or []))
324
325
326def list_catalog(params):
327 content_type = "movies" if params["menu_type"] == "movie" else "tvshows"
328 setContent(ADDON_HANDLE, content_type)
329
330 skip = int(params.get("skip", 0))
331
332 extras = {}
333 # Common Stremio extra params
334 for key in ["genre", "search"]:
335 if key in params:
336 extras[key] = params[key]
337
338 supports_skip = _catalog_supports_extra(
339 params["addon_url"], params["catalog_type"], params["catalog_id"], "skip"
340 )
341
342 request_kwargs = dict(extras)
343 if supports_skip and skip:
344 request_kwargs["skip"] = skip
345
346 response = catalogs_get_cache("list_catalog", params, **request_kwargs)
347 if not response:
348 end_of_directory()
349 return
350
351 metas = response.get("metas", [])
352 total_metas = len(metas)
353
354 if not supports_skip:
355 metas = metas[skip : skip + CATALOG_PAGE_SIZE]
356
357 if not metas:
358 notification(translation(90516))
359 end_of_directory()
360 return
361
362 add_meta_items(metas, params)
363
364 has_next_page = False
365 if supports_skip:
366 has_next_page = len(metas) >= CATALOG_PAGE_SIZE
367 else:
368 has_next_page = total_metas > skip + len(metas)
369
370 if has_next_page:
371 next_url = build_url(
372 "list_catalog",
373 addon_url=params["addon_url"],
374 menu_type=params["menu_type"],
375 sub_menu_type=params.get("sub_menu_type", ""),
376 catalog_type=params["catalog_type"],
377 catalog_id=params["catalog_id"],
378 skip=skip + len(metas),
379 )
380 list_item = make_list_item(label=translation(90515))
381 addDirectoryItem(
382 handle=ADDON_HANDLE, url=next_url, listitem=list_item, isFolder=True
383 )
384
385 end_of_directory()
386
387
388def search_catalog(params):
389 page = int(params["page"])
390 pickle_db = PickleDatabase()
391 history_key = _stremio_search_history_key(params)
392
393 if "menu_type" not in params:
394 params = dict(params)
395 params["menu_type"] = params.get("catalog_type", "movie")
396 if "sub_menu_type" not in params:
397 params["sub_menu_type"] = ""
398
399 if page == 1:
400 if str(params.get("is_keyboard", "True")).lower() in ("false", "0", "no"):
401 query = params.get("query", "")
402 else:
403 query = show_keyboard(id=30241, default=params.get("query", ""))
404 if not query:
405 _show_search_catalog_history(params)
406 return
407 cache.add_to_list(
408 key=history_key,
409 item=(query,),
410 expires=timedelta(hours=get_cache_expiration()),
411 )
412 pickle_db.set_key("search_catalog_query", query)
413 else:
414 query = pickle_db.get_key("search_catalog_query")
415
416 response = catalogs_get_cache("search_catalog", params, query)
417 if not response:
418 return
419
420 add_meta_items(response.get("metas", []), params)
421
422 add_next_button("search_catalog", page=page, mode=params["catalog_type"])
423 end_of_directory()
424
425
426def _addon_has_resource(addon, resource_name, content_type):
427 if not addon:
428 return False
429 for resource in addon.manifest.resources:
430 if resource.name == resource_name and content_type in resource.types:
431 return True
432 return False
433
434
435def addon_has_meta(addon_url, content_type, addon=None):
436 """Check if the addon serves its own meta (seasons/episodes) for the given type."""
437 addon = addon or get_addon_by_base_url(addon_url)
438 return _addon_has_resource(addon, "meta", content_type)
439
440
441def addon_has_stream(addon_url, content_type, addon=None):
442 """Check if the addon serves its own streams for the given type."""
443 addon = addon or get_addon_by_base_url(addon_url)
444 return _addon_has_resource(addon, "stream", content_type)
445
446
447def add_meta_items(metas, params):
448 catalog_type = params["catalog_type"]
449 menu_type = params.get("menu_type", catalog_type)
450 sub_menu_type = params.get("sub_menu_type", "")
451 addon_url = params["addon_url"]
452
453 content_type = "movies" if menu_type == "movie" else "tvshows"
454 setContent(ADDON_HANDLE, content_type)
455
456 def should_include(meta):
457 meta_type = meta.type
458 if menu_type in ["anime", "movie"] and sub_menu_type == "movie":
459 return meta_type == "movie"
460 if menu_type in ["anime", "series"] and sub_menu_type == "series":
461 return meta_type in ["series", "anime"]
462 if menu_type == "tv":
463 return meta_type == "tv"
464 return True
465
466 metas = [m for m in metas if should_include(m)]
467 if not metas:
468 notification(f"{translation(90511)} {menu_type}")
469 end_of_directory()
470 return
471
472 addon = None
473 has_meta_resource = _param_truthy(params.get("has_meta_resource"))
474 has_stream_resource = _param_truthy(params.get("has_stream_resource"))
475 if not has_meta_resource and not has_stream_resource:
476 addon = get_addon_by_base_url(addon_url)
477 has_meta_resource = addon_has_meta(addon_url, catalog_type, addon=addon)
478 has_stream_resource = addon_has_stream(addon_url, catalog_type, addon=addon)
479
480 for meta in metas:
481 name = meta.name
482 meta_type = meta.type
483 meta_id = meta.id
484 tmdb_id = meta.moviedb_id
485 imdb_id = meta.imdb_id
486
487 if "tmdb" in meta_id:
488 tmdb_id = meta_id.split(":")[1]
489 elif meta_id.startswith("tt"):
490 imdb_id = meta_id
491 tmdb_id = ""
492
493 ids = _build_stremio_ids(meta_id, tmdb_id, imdb_id)
494 poster = meta.poster or ""
495 background = meta.background or ""
496 library_payload = _build_stremio_meta_payload(
497 name=name,
498 meta_type=meta_type,
499 meta_id=meta_id,
500 tmdb_id=tmdb_id,
501 imdb_id=imdb_id,
502 overview=meta.description or "",
503 poster=poster,
504 fanart=background,
505 genres=meta.genres,
506 addon_url=addon_url,
507 catalog_type=catalog_type,
508 meta_id_for_nav=meta_id,
509 )
510
511 if meta_type == "series":
512 if has_meta_resource:
513 url = build_url(
514 "list_stremio_seasons",
515 addon_url=addon_url,
516 catalog_type=catalog_type,
517 meta_id=meta_id,
518 )
519 elif tmdb_id or imdb_id:
520 url = build_url(
521 "show_seasons_details", ids=ids, mode="tv", media_type="tv"
522 )
523 else:
524 url = build_url(
525 "list_stremio_seasons",
526 addon_url=addon_url,
527 catalog_type=catalog_type,
528 meta_id=meta_id,
529 )
530 elif meta_type == "movie":
531 if has_stream_resource:
532 is_custom_movie = True
533 url = build_url(
534 "list_stremio_movie",
535 addon_url=addon_url,
536 catalog_type=catalog_type,
537 meta_id=meta_id,
538 ids=json.dumps(ids),
539 title=name,
540 overview=meta.description or "",
541 poster=poster,
542 fanart=background,
543 genres=json.dumps(meta.genres or []),
544 )
545 elif tmdb_id or imdb_id or ":" in meta_id:
546 is_custom_movie = False
547 url = build_url("search", mode="movies", query=name, ids=ids)
548 else:
549 is_custom_movie = True
550 url = build_url(
551 "list_stremio_movie",
552 addon_url=addon_url,
553 catalog_type=catalog_type,
554 meta_id=meta_id,
555 ids=json.dumps(ids),
556 title=name,
557 overview=meta.description or "",
558 poster=poster,
559 fanart=background,
560 genres=json.dumps(meta.genres or []),
561 )
562 elif meta_type == "tv":
563 if meta.streams:
564 streams_data = [asdict(s) for s in meta.streams]
565 url = build_url(
566 "list_stremio_tv_streams",
567 streams=json.dumps(streams_data),
568 ids=json.dumps(ids),
569 title=name,
570 overview=meta.description or "",
571 poster=poster,
572 fanart=background,
573 genres=json.dumps(meta.genres or []),
574 addon_url=addon_url,
575 catalog_type=catalog_type,
576 meta_id=meta_id,
577 )
578 else:
579 url = build_url(
580 "list_stremio_tv",
581 addon_url=addon_url,
582 catalog_type=catalog_type,
583 meta_id=meta_id,
584 ids=json.dumps(ids),
585 title=name,
586 overview=meta.description or "",
587 poster=poster,
588 fanart=background,
589 genres=json.dumps(meta.genres or []),
590 )
591 else:
592 continue
593
594 list_item = make_list_item(label=name)
595 info_tag = list_item.getVideoInfoTag()
596 info_tag.setUniqueID(meta_id, type="imdb" if meta_id.startswith("tt") else "mf")
597 info_tag.setTitle(name)
598 info_tag.setPlot(truncate_text(meta.description or ""))
599 info_tag.setGenres(meta.genres)
600 info_tag.setMediaType("video")
601
602 is_folder = meta_type != "movie" or (meta_type == "movie" and is_custom_movie)
603 if not is_folder:
604 list_item.setProperty("IsPlayable", "true")
605
606 list_item.setArt(
607 {
608 "thumb": poster,
609 "poster": poster,
610 "fanart": poster,
611 "icon": poster,
612 "banner": background,
613 "landscape": background,
614 }
615 )
616
617 context_menu_ids = _resolve_tmdb_ids_for_context_menu(ids, meta_type)
618
619 context_menu = [
620 _build_stremio_library_menu_item(library_payload),
621 _build_stremio_settings_menu_item(),
622 ]
623 if _has_reliable_tmdb_ids(context_menu_ids):
624 if meta_type == "movie":
625 context_menu.extend(
626 _filter_tmdb_context_menu(
627 add_tmdb_movie_context_menu(
628 mode="movies",
629 media_type="movie",
630 title=name,
631 ids=context_menu_ids,
632 )
633 )
634 )
635 elif meta_type in ["series", "anime"]:
636 context_menu.extend(
637 _filter_tmdb_context_menu(
638 add_tmdb_show_context_menu(
639 mode="tv", ids=context_menu_ids, title=name
640 )
641 )
642 )
643
644 _append_context_menu_items(list_item, context_menu)
645
646 addDirectoryItem(
647 handle=ADDON_HANDLE, url=url, listitem=list_item, isFolder=is_folder
648 )
649
650
651
652def list_stremio_seasons(params):
653 kodilog("list_stremio_seasons")
654 response = catalogs_get_cache("list_stremio_seasons", params)
655 if not response:
656 return
657
658 meta_data = response.get("meta")
659 if not meta_data:
660 notification(translation(90517))
661 return
662
663 videos = meta_data.videos
664 if not videos:
665 notification(translation(90517))
666 return
667
668 available_seasons = set(
669 int(video.imdbSeason) if video.imdbSeason else int(video.season)
670 for video in videos
671 )
672 items = []
673 for season in available_seasons:
674 url = build_url(
675 "list_stremio_episodes",
676 addon_url=params["addon_url"],
677 catalog_type=params["catalog_type"],
678 meta_id=params["meta_id"],
679 season=season,
680 )
681 list_item = make_list_item(label=f"{translation(90512)} {season}")
682 info_tag = list_item.getVideoInfoTag()
683 info_tag.setUniqueID(
684 meta_data.id, type="imdb" if meta_data.id.startswith("tt") else "mf"
685 )
686 info_tag.setTitle(meta_data.name)
687 info_tag.setPlot(truncate_text(meta_data.description or ""))
688 info_tag.setRating(float(meta_data.imdbRating or 0))
689 info_tag.setGenres(meta_data.genres)
690 info_tag.setTvShowTitle(meta_data.name)
691 info_tag.setSeason(season)
692
693 list_item.setArt(
694 {
695 "thumb": meta_data.poster or "",
696 "poster": meta_data.poster or "",
697 "fanart": meta_data.poster or "",
698 "icon": meta_data.poster or "",
699 "banner": meta_data.background or "",
700 "landscape": meta_data.background or "",
701 }
702 )
703
704 items.append((url, list_item, True))
705 add_directory_items_batch(items)
706 end_of_directory()
707
708
709def list_stremio_episodes(params):
710 kodilog("list_stremio_episodes")
711 response = catalogs_get_cache("list_stremio_episodes", params)
712 if not response:
713 return
714
715 meta_data = response.get("meta")
716 if not meta_data:
717 notification(translation(90510))
718 return
719
720 videos = meta_data.videos
721 if not videos:
722 notification(translation(90513))
723 return
724
725 # Resolve IDs once for the show
726 tmdb_id = ""
727 imdb_id = ""
728 # Store original ID (e.g. kitsu:123) for addons that support it
729 original_id = meta_data.id if ":" in meta_data.id else ""
730
731 if meta_data.imdb_id:
732 imdb_id = meta_data.imdb_id
733 res = tmdb_get("find_by_imdb_id", imdb_id)
734 if getattr(res, "tv_results", []):
735 tmdb_id = str(getattr(res, "tv_results")[0]["id"])
736
737 if not tmdb_id and not imdb_id and meta_data.name:
738 kodilog(f"No IDs found for {meta_data.name}. Searching TMDB...")
739 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 )
743 if results and results.results:
744 try:
745 tmdb_id = str(results.results[0].id)
746 kodilog(f"Fallback found TMDB ID: {tmdb_id} for {meta_data.name}")
747 except Exception:
748 pass
749
750 if tmdb_id and not imdb_id:
751 details = tmdb_get("tv_details", tmdb_id)
752 if details and getattr(details, "external_ids", None):
753 imdb_id = details.external_ids.get("imdb_id", "")
754 kodilog(f"Resolved IMDB ID: {imdb_id} from TMDB ID: {tmdb_id}")
755
756 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 )
760 items = []
761 for video in videos:
762 try:
763 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 )
767 except (ValueError, TypeError):
768 continue
769
770 if season != int(params["season"]):
771 continue
772
773 title = video.title or getattr(video, "name", "") or meta_data.name
774
775 tv_data = {
776 "name": title,
777 "episode": episode,
778 "season": season,
779 }
780
781 ids = {
782 "tmdb_id": tmdb_id,
783 "tvdb_id": "",
784 "imdb_id": imdb_id,
785 "original_id": original_id,
786 }
787
788 scoped_addon_url = params["addon_url"] if has_stream_resource else ""
789
790 url = build_url(
791 "search",
792 mode="tv",
793 media_type="tv",
794 query=meta_data.name,
795 ids=ids,
796 tv_data=tv_data,
797 scoped_addon_url=scoped_addon_url,
798 stremio_addon_url=params["addon_url"],
799 stremio_catalog_type=params["catalog_type"],
800 stremio_meta_id=params["meta_id"],
801 )
802
803 list_item = make_list_item(label=f"{season}x{episode}. {title}")
804 info_tag = list_item.getVideoInfoTag()
805 info_tag.setUniqueID(
806 meta_data.id, type="imdb" if meta_data.id.startswith("tt") else "mf"
807 )
808 info_tag.setTitle(title)
809 info_tag.setPlot(truncate_text(video.overview or ""))
810 info_tag.setRating(float(meta_data.imdbRating or 0))
811 info_tag.setGenres(meta_data.genres)
812 info_tag.setTvShowTitle(title)
813 info_tag.setSeason(season)
814 info_tag.setEpisode(episode)
815
816 list_item.setProperty("IsPlayable", "true")
817
818 context_menu = [
819 _build_stremio_library_menu_item(
820 _build_stremio_meta_payload(
821 name=meta_data.name,
822 meta_type="series",
823 meta_id=meta_data.id,
824 tmdb_id=tmdb_id,
825 imdb_id=imdb_id,
826 overview=meta_data.description or video.overview or "",
827 poster=meta_data.poster or video.thumbnail or "",
828 fanart=meta_data.background or meta_data.poster or video.thumbnail or "",
829 genres=meta_data.genres,
830 addon_url=params["addon_url"],
831 catalog_type=params["catalog_type"],
832 meta_id_for_nav=params["meta_id"],
833 tv_data=tv_data,
834 )
835 ),
836 _build_stremio_settings_menu_item(),
837 ]
838 context_menu_ids = _resolve_tmdb_ids_for_context_menu(ids, "series")
839 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,
846 )
847 ) + context_menu
848 else:
849 context_menu.insert(
850 0,
851 (
852 translation(90049),
853 kodi_play_media(
854 name="search",
855 mode="tv",
856 media_type="tv",
857 query=meta_data.name,
858 ids=ids,
859 tv_data=tv_data,
860 scoped_addon_url=scoped_addon_url,
861 stremio_addon_url=params["addon_url"],
862 stremio_catalog_type=params["catalog_type"],
863 stremio_meta_id=params["meta_id"],
864 rescrape=True,
865 ),
866 ),
867 )
868 _append_context_menu_items(list_item, context_menu)
869
870 list_item.setArt(
871 {
872 "thumb": meta_data.poster or "",
873 "poster": meta_data.poster or "",
874 "fanart": meta_data.poster or "",
875 "icon": meta_data.poster or "",
876 "banner": meta_data.background or "",
877 "landscape": meta_data.background or "",
878 }
879 )
880
881 items.append((url, list_item, False))
882
883 add_directory_items_batch(items)
884 end_of_directory()
885
886
887def list_stremio_movie(params):
888 response = catalogs_get_cache("list_stremio_movie", params)
889 if not response:
890 return
891
892 streams = response.get("streams", [])
893 if not streams:
894 notification(translation(90514))
895 return
896
897 items = []
898 for stream in streams:
899 playback_data = {
900 "mode": "movie",
901 "source": "stremio_catalog",
902 "title": stream.title,
903 "overview": stream.description or params.get("overview", ""),
904 "poster": params.get("poster", ""),
905 "fanart": params.get("fanart", params.get("poster", "")),
906 "genres": json.loads(params.get("genres", "[]") or "[]"),
907 "ids": json.loads(params.get("ids", "{}") or "{}"),
908 "addon_url": params.get("addon_url", ""),
909 "catalog_type": params.get("catalog_type", ""),
910 "meta_id": params.get("meta_id", ""),
911 }
912
913 if stream.url:
914 if stream.url.startswith("magnet:"):
915 playback_data["magnet"] = stream.url
916 playback_data["is_torrent"] = True
917 else:
918 playback_data["url"] = stream.url
919 playback_data["proxyHeaders"] = stream.get_proxy_headers()
920 playback_data["type"] = IndexerType.DIRECT
921 elif stream.infoHash:
922 playback_data["magnet"] = info_hash_to_magnet(stream.infoHash)
923 playback_data["info_hash"] = stream.infoHash
924 playback_data["is_torrent"] = True
925 else:
926 continue
927
928 url = build_url(
929 "play_media",
930 data=json.dumps(playback_data),
931 )
932
933 list_item = make_list_item(label=stream.title)
934 list_item.setProperty("IsPlayable", "true")
935 info_tag = list_item.getVideoInfoTag()
936 info_tag.setPlot(truncate_text(stream.description or ""))
937
938 items.append((url, list_item, False))
939 add_directory_items_batch(items)
940 end_of_directory()
941
942
943def list_stremio_tv(params):
944 response = catalogs_get_cache("list_stremio_tv", params)
945 if not response:
946 return
947
948 streams = response.get("streams", [])
949 if not streams:
950 notification(translation(90514))
951 return
952
953 items = []
954 for stream in streams:
955 playback_data = {
956 "mode": "movie",
957 "url": stream.url,
958 "proxyHeaders": stream.get_proxy_headers(),
959 "type": IndexerType.DIRECT,
960 "source": "stremio_catalog",
961 "title": stream.title,
962 "overview": stream.description or params.get("overview", ""),
963 "poster": params.get("poster", ""),
964 "fanart": params.get("fanart", params.get("poster", "")),
965 "genres": json.loads(params.get("genres", "[]") or "[]"),
966 "ids": json.loads(params.get("ids", "{}") or "{}"),
967 "addon_url": params.get("addon_url", ""),
968 "catalog_type": params.get("catalog_type", ""),
969 "meta_id": params.get("meta_id", ""),
970 }
971 url = build_url(
972 "play_media",
973 data=json.dumps(playback_data),
974 )
975
976 list_item = make_list_item(label=stream.title)
977 list_item.setProperty("IsPlayable", "true")
978 info_tag = list_item.getVideoInfoTag()
979 info_tag.setPlot(truncate_text(stream.description or ""))
980
981 items.append((url, list_item, False))
982 add_directory_items_batch(items)
983 end_of_directory()
984
985
986def list_stremio_tv_streams(params):
987 streams = json.loads(params.get("streams", {}))
988 items = []
989 for stream in streams:
990 playback_data = {
991 "mode": "movie",
992 "url": stream["url"],
993 "proxyHeaders": extract_proxy_headers(
994 (stream.get("behaviorHints") or {}).get("proxyHeaders")
995 ),
996 "type": IndexerType.DIRECT,
997 "source": "stremio_catalog",
998 "title": stream["name"],
999 "overview": stream.get("description", "") or params.get("overview", ""),
1000 "poster": params.get("poster", ""),
1001 "fanart": params.get("fanart", params.get("poster", "")),
1002 "genres": json.loads(params.get("genres", "[]") or "[]"),
1003 "ids": json.loads(params.get("ids", "{}") or "{}"),
1004 "addon_url": params.get("addon_url", ""),
1005 "catalog_type": params.get("catalog_type", ""),
1006 "meta_id": params.get("meta_id", ""),
1007 }
1008 url = build_url(
1009 "play_media",
1010 data=json.dumps(playback_data),
1011 )
1012
1013 list_item = make_list_item(label=stream["name"])
1014 list_item.setProperty("IsPlayable", "true")
1015 info_tag = list_item.getVideoInfoTag()
1016 info_tag.setPlot(truncate_text(stream.get("description", "")))
1017
1018 items.append((url, list_item, False))
1019
1020 add_directory_items_batch(items)
1021 end_of_directory()
plugin.video.jacktook/lib/clients/stremio/constants.py +84 -0
……@@ -0,0 +1,84 @@
1import json
2
3
4def encode_selected_ids(ids_list):
5 """Encode a list of addon IDs for cache storage as JSON."""
6 return json.dumps(ids_list)
7
8
9def decode_selected_ids(raw):
10 """Decode addon IDs from cache.
11
12 Handles both JSON list (new format) and comma-separated (old format).
13 """
14 if not raw:
15 return []
16 if isinstance(raw, list):
17 return raw
18 try:
19 result = json.loads(raw)
20 if isinstance(result, list):
21 return result
22 except (json.JSONDecodeError, TypeError, ValueError):
23 pass
24 # Fallback: comma-separated (old format — only safe if keys don't contain commas)
25 return [k for k in raw.split(",") if k]
26
27
28STREMIO_ADDONS_KEY = "stremio_addons"
29STREMIO_ADDONS_CATALOGS_KEY = "stremio_catalog_addons"
30STREMIO_TV_ADDONS_KEY = "stremio_tv_addons"
31STREMIO_USER_ADDONS = "stremio_user_addons"
32TORRENTIO_PROVIDERS_KEY = "torrentio.providers"
33
34all_torrentio_providers = [
35 ("yts", "YTS", "torrentio.png"),
36 ("nyaasi", "Nyaa", "torrentio.png"),
37 ("eztv", "EZTV", "torrentio.png"),
38 ("rarbg", "RARBG", "torrentio.png"),
39 ("mejortorrent", "MejorTorrent", "torrentio.png"),
40 ("wolfmax4k", "WolfMax4K", "torrentio.png"),
41 ("cinecalidad", "CineCalidad", "torrentio.png"),
42 ("1337x", "1337x", "torrentio.png"),
43 ("thepiratebay", "The Pirate Bay", "torrentio.png"),
44 ("kickasstorrents", "Kickass", "torrentio.png"),
45 ("torrentgalaxy", "TorrentGalaxy", "torrentio.png"),
46 ("magnetdl", "MagnetDL", "torrentio.png"),
47 ("horriblesubs", "HorribleSubs", "torrentio.png"),
48 ("tokyotosho", "Tokyotosho", "torrentio.png"),
49 ("anidex", "Anidex", "torrentio.png"),
50 ("rutor", "Rutor", "torrentio.png"),
51 ("rutracker", "Rutracker", "torrentio.png"),
52 ("comando", "Comando", "torrentio.png"),
53 ("torrent9", "Torrent9", "torrentio.png"),
54 ("ilcorsaronero", "Il Corsaro Nero", "torrentio.png"),
55 ("besttorrents", "BestTorrents", "torrentio.png"),
56 ("bludv", "BluDV", "torrentio.png"),
57]
58
59excluded_addons = {
60 "imdb.ratings.local",
61 "org.stremio.deepdivecompanion",
62 "community.ratings.aggregator",
63 "org.stremio.ageratings",
64 "com.stremio.autostream.addon",
65 "org.cinetorrent",
66 "community.peario",
67 "community.stremioeasynews",
68 "Community-knightcrawler.elfhosted.com",
69 "jackettio.elfhosted.com",
70 "org.stremio.zamunda",
71 "com.stremify",
72 "org.anyembedaddon",
73 "org.stremio.tmdbcollections",
74 "org.stremio.ytztvio",
75 "com.skyflix",
76 "org.stremio.local",
77 "com.animeflv.stremio.addon",
78 "org.cinecalidad.addon",
79 "org.stremio.hellspy",
80 "org.prisonmike.streamvix",
81 "community.SeedSphere",
82 "org.moviesindetail.openlink",
83 "app.torbox.stremio",
84}
plugin.video.jacktook/lib/clients/stremio/helpers.py +160 -0
……@@ -0,0 +1,160 @@
1from typing import List, Callable, Optional
2import requests
3import concurrent.futures
4from lib.api.stremio.addon_manager import (
5 Addon,
6 AddonManager,
7 build_addon_instance_key,
8 normalize_transport_url,
9)
10from lib.api.stremio.api_client import Stremio
11from lib.db.cached import cache
12from lib.utils.kodi.utils import get_setting, kodilog
13from lib.clients.stremio.constants import (
14 STREMIO_ADDONS_KEY,
15 STREMIO_ADDONS_CATALOGS_KEY,
16 STREMIO_TV_ADDONS_KEY,
17 STREMIO_USER_ADDONS,
18 decode_selected_ids,
19)
20def get_addon_merge_key(addon):
21 return build_addon_instance_key(addon)
22
23
24def merge_addons_lists(*lists):
25 seen = set()
26 merged = []
27
28 for addon_source in lists:
29 if isinstance(addon_source, dict):
30 addons = addon_source.get("addons", [])
31 else:
32 addons = addon_source
33 for addon in addons:
34 key = get_addon_merge_key(addon)
35 if key and key not in seen:
36 seen.add(key)
37 merged.append(addon)
38 return merged
39
40
41def _resolve_selected_addons(catalog: AddonManager, selected_ids_list: List[str]) -> List[Addon]:
42 if not selected_ids_list:
43 return []
44
45 addons_by_key = {addon.key(): addon for addon in catalog.addons}
46 addons_by_id = {}
47 for addon in catalog.addons:
48 addons_by_id.setdefault(addon.manifest.id, []).append(addon)
49
50 selected_addons = []
51 seen_keys = set()
52
53 for selected_id in selected_ids_list:
54 addon = addons_by_key.get(selected_id)
55 if addon:
56 if addon.key() not in seen_keys:
57 seen_keys.add(addon.key())
58 selected_addons.append(addon)
59 continue
60
61 legacy_matches = addons_by_id.get(selected_id, [])
62 if len(legacy_matches) == 1:
63 addon = legacy_matches[0]
64 if addon.key() not in seen_keys:
65 seen_keys.add(addon.key())
66 selected_addons.append(addon)
67 elif len(legacy_matches) > 1:
68 kodilog(
69 "Ambiguous legacy Stremio addon selection for id "
70 f"'{selected_id}', please reselect addons."
71 )
72
73 return selected_addons
74
75
76def get_addons():
77 all_user_addons = cache.get(STREMIO_USER_ADDONS) or []
78 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 ]
82 user_addons = list(cached_account_addons)
83
84 logged_in = get_setting("stremio_loggedin")
85 if logged_in:
86 stremio = Stremio()
87 try:
88 email = get_setting("stremio_email")
89 password = get_setting("stremio_pass")
90 if email and password:
91 stremio.login(email, password)
92 live_user_addons = stremio.get_my_addons() or []
93 user_addons = merge_addons_lists(live_user_addons, cached_account_addons)
94 else:
95 kodilog("Stremio credentials missing, cannot fetch user addons.")
96 except Exception as e:
97 kodilog(f"Failed to fetch user addons: {e}")
98 merged_addons = merge_addons_lists(custom_addons, user_addons)
99
100 kodilog(f"Loaded {len(merged_addons)} addons from catalog")
101 return AddonManager(merged_addons)
102
103
104def get_selected_stream_addons() -> List[Addon]:
105 catalog = get_addons()
106 selected_ids_list = decode_selected_ids(cache.get(STREMIO_ADDONS_KEY))
107 return _resolve_selected_addons(catalog, selected_ids_list)
108
109
110def get_selected_catalogs_addons() -> List[Addon]:
111 catalog = get_addons()
112 selected_ids_list = decode_selected_ids(cache.get(STREMIO_ADDONS_CATALOGS_KEY))
113 return _resolve_selected_addons(catalog, selected_ids_list)
114
115
116def get_selected_tv_addons() -> List[Addon]:
117 catalog = get_addons()
118 selected_ids_list = decode_selected_ids(cache.get(STREMIO_TV_ADDONS_KEY))
119 return _resolve_selected_addons(catalog, selected_ids_list)
120
121
122def get_addon_by_base_url(addon_url):
123 """Resolve an Addon object from its base URL."""
124 catalog = get_addons()
125 for addon in catalog.addons:
126 if addon.url() == addon_url:
127 return addon
128 return None
129
130
131def ping_addons(
132 addons: List[Addon], progress_callback: Optional[Callable[[int, int], None]] = None
133) -> List[Addon]:
134 reachable_addons = []
135 total = len(addons)
136 completed = 0
137
138 def check_addon(addon):
139 try:
140 url = addon.transport_url
141 if not url.endswith("/manifest.json"):
142 if not url.endswith("/"):
143 url += "/"
144 url += "manifest.json"
145 requests.get(url, timeout=5)
146 return addon
147 except Exception:
148 return None
149
150 with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
151 futures = {executor.submit(check_addon, addon): addon for addon in addons}
152 for future in concurrent.futures.as_completed(futures):
153 result = future.result()
154 if result:
155 reachable_addons.append(result)
156 completed += 1
157 if progress_callback:
158 progress_callback(completed, total)
159
160 return reachable_addons
plugin.video.jacktook/lib/clients/stremio/manage_phone.py +43 -0
……@@ -0,0 +1,43 @@
1# -*- coding: utf-8 -*-
2from lib.services.webserver import StremioWebServer, get_local_ip
3from lib.utils.debrid.qrcode_utils import make_qrcode
4from lib.gui.qr_progress_dialog import QRProgressDialog
5from lib.utils.kodi.utils import ADDON_PATH, kodilog, translation
6
7
8_SERVER_PORT = 8081
9
10
11def stremio_manage_phone(params):
12 local_ip = get_local_ip()
13 url = f"http://{local_ip}:{_SERVER_PORT}/"
14
15 server = StremioWebServer(port=_SERVER_PORT)
16 server.start()
17
18 if not server.is_running:
19 from lib.utils.kodi.utils import notification
20
21 notification(translation(90560))
22 return
23
24 try:
25 kodilog(f"Server started at {url}")
26
27 # Generate QR code image
28 qr_path = make_qrcode(url)
29
30 # Show blocking QR dialog
31 dialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH)
32 dialog.setup(
33 title=translation(90162),
34 qr_code=qr_path or "",
35 url=url,
36 user_code="",
37 is_debrid=False,
38 url_label_id=90670,
39 )
40 dialog.doModal()
41 finally:
42 server.stop()
43 kodilog("Server stopped")
plugin.video.jacktook/lib/clients/stremio/torrentio.py +42 -0
……@@ -0,0 +1,42 @@
1import os
2from datetime import timedelta
3from lib.db.cached import cache
4from lib.utils.kodi.utils import (
5 ADDON_PATH,
6 translation,
7)
8from lib.clients.stremio.constants import (
9 TORRENTIO_PROVIDERS_KEY,
10 all_torrentio_providers,
11)
12import xbmcgui
13
14
15def torrentio_toggle_providers(params):
16 selected_ids = cache.get(TORRENTIO_PROVIDERS_KEY) or ""
17 selected_ids = selected_ids.split(",") if selected_ids else []
18
19 options = []
20 selected_indexes = []
21 for i, (key, name, logo) in enumerate(all_torrentio_providers):
22 item = xbmcgui.ListItem(label=name)
23 item.setArt(
24 {"icon": os.path.join(ADDON_PATH, "resources", "img", "torrentio.png")}
25 )
26 options.append(item)
27 if key in selected_ids:
28 selected_indexes.append(i)
29
30 dialog = xbmcgui.Dialog()
31
32 selected = dialog.multiselect(
33 translation(90117), options, preselect=selected_indexes, useDetails=True
34 )
35
36 if selected is None:
37 return
38
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 )
plugin.video.jacktook/lib/clients/subtitle/deepl.py +368 -0
……@@ -0,0 +1,368 @@
1import traceback
2import requests
3from lib.clients.subtitle.utils import get_deepl_language_code, slugify
4from lib.utils.kodi.utils import (
5 ADDON_PROFILE_PATH,
6 get_setting,
7 kodilog,
8 translation,
9)
10from os import path as ospath, stat
11from time import sleep
12
13import xbmcgui
14
15
16def show_dialog(title, message):
17 # Kodi Yes/No dialog
18 dialog = xbmcgui.Dialog()
19 return dialog.yesno(title, message)
20
21
22class DeepLTranslator:
23 def __init__(self, notification):
24 self.base_url = "https://api-free.deepl.com/v2"
25 self.api_key = get_setting("deepl_api_key")
26 self.target_lang = get_setting("deepl_target_language")
27 self.notification = notification
28
29 def _handle_deepl_response(self, response, context=""):
30 """
31 Handle DeepL API responses, showing a Kodi dialog and logging errors for non-200 codes.
32 Returns True if response is OK, otherwise False.
33 """
34 if response.status_code == 200:
35 return True
36
37 status_messages = {
38 400: "Bad request. The request was unacceptable, often due to missing a required parameter.",
39 403: "Authorization failed. Check your DeepL API key.",
40 404: "The requested resource could not be found.",
41 413: "The uploaded file is too large.",
42 429: "Too many requests. You have hit the rate limit.",
43 456: "Quota exceeded. You have used up your translation quota.",
44 503: "DeepL service is temporarily unavailable. Please try again later.",
45 }
46 user_message = status_messages.get(
47 response.status_code,
48 f"Unexpected error occurred (status code: {response.status_code}).",
49 )
50
51 msg = f"DeepL API error during {context}.\n" f"{user_message}\n\n"
52 self.notification(heading=translation(90652), message=msg)
53 return False
54
55 def download_and_save_translation(
56 self,
57 idx,
58 imdb_id,
59 season,
60 episode,
61 lang_name="en",
62 document_id=None,
63 document_key=None,
64 ):
65 """
66 Download the translated document from DeepL and save it as an .srt file.
67 The file path is determined based on whether season/episode info is provided.
68 """
69 if season and episode:
70 new_subtitle_file_path = (
71 f"{ADDON_PROFILE_PATH}Subtitles/{imdb_id}/{season}/{episode}/"
72 f"Translated_Subtitle No.{idx}.S{season}E{episode}.{lang_name}.srt"
73 )
74 elif season:
75 new_subtitle_file_path = (
76 f"{ADDON_PROFILE_PATH}Subtitles/{imdb_id}/{season}/"
77 f"Translated_Subtitle No.{idx}.S{season}.{lang_name}.srt"
78 )
79 else:
80 new_subtitle_file_path = f"{ADDON_PROFILE_PATH}Subtitles/{imdb_id}/Translated_Subtitle No.{idx}.{lang_name}.srt"
81
82 # Ensure the file path is safe
83 new_subtitle_file_path = slugify(new_subtitle_file_path)
84
85 kodilog(f"Saving translated subtitle to: {new_subtitle_file_path}")
86
87 url = f"{self.base_url}/document/{document_id}/result"
88 headers = {
89 "Authorization": f"DeepL-Auth-Key {self.api_key}",
90 "Content-Type": "application/json",
91 }
92
93 response = requests.post(
94 url, json={"document_key": document_key}, headers=headers
95 )
96 if not self._handle_deepl_response(response, "result download"):
97 raise Exception("DeepL API error during result download")
98
99 with open(new_subtitle_file_path, "wb") as f:
100 f.write(response.content)
101
102 kodilog(f"Translation saved to: {new_subtitle_file_path}")
103
104 return new_subtitle_file_path
105
106 def has_sufficient_api_characters(self, subtitle_paths, api_characters_remaining):
107 """
108 Check if the total character count across subtitle files is within API limits.
109 """
110 total_character_count = 0
111 for file_path in subtitle_paths:
112 try:
113 with open(file_path, "r", encoding="utf-8") as f:
114 content = f.read()
115
116 lines = content.split("\n")
117 iscount = True
118 istimecode = False
119 istext = False
120 characters = []
121 textcount = 0
122
123 for line in lines:
124 if line.strip() == "":
125 iscount = True
126 istimecode = False
127 istext = False
128 textcount = 0
129 elif iscount:
130 iscount = False
131 istimecode = True
132 elif istimecode:
133 istimecode = False
134 istext = True
135 elif istext:
136 if textcount == 0:
137 characters.append(line)
138 else:
139 characters[-1] += " \n" + line
140 textcount += 1
141
142 for text in characters:
143 total_character_count += len(text)
144 except Exception as error:
145 kodilog(f"Error processing subtitle file {file_path}: {error}")
146
147 kodilog(f"Total character count for translation: {total_character_count}")
148
149 if api_characters_remaining > total_character_count:
150 kodilog("Sufficient API characters remaining. Proceeding with translation.")
151 return True
152 else:
153 kodilog(
154 f"Insufficient API characters remaining. Required: {total_character_count}, Available: {api_characters_remaining}"
155 )
156 return False
157
158 def validate_file_limits(self, file_path):
159 """
160 Check if a file meets size and character count limits.
161 Raises Exception and notifies the user if not.
162 """
163 MAX_FILE_SIZE_KB = 150
164 MAX_CHARACTERS = 1000000
165
166 stats = stat(file_path)
167 file_size_kb = stats.st_size / 1024
168
169 if file_size_kb > MAX_FILE_SIZE_KB:
170 msg = f"File size ({file_size_kb:.2f} KB) exceeds the limit of {MAX_FILE_SIZE_KB} KB"
171 self.notification(msg)
172 raise Exception(msg)
173
174 with open(file_path, "r", encoding="utf-8") as f:
175 content = f.read()
176
177 character_count = len(content)
178
179 if character_count > MAX_CHARACTERS:
180 msg = f"Character count ({character_count}) exceeds the limit of {MAX_CHARACTERS}"
181 self.notification(msg)
182 raise Exception(msg)
183
184 kodilog(
185 f"File check passed: Size={file_size_kb:.2f}KB, Characters={character_count}"
186 )
187
188 def filter_files_within_limits(self, file_paths):
189 """
190 Returns a list of file paths that are within the allowed limits.
191 Notifies the user for each file that is skipped.
192 """
193 valid_files = []
194 for file_path in file_paths:
195 try:
196 self.validate_file_limits(file_path)
197 valid_files.append(file_path)
198 except Exception as e:
199 # Notification already shown in validate_file_limits
200 kodilog(f"File skipped due to limits: {file_path} ({e})")
201 return valid_files
202
203 def get_document_status(self, document_id, document_key):
204 """
205 Check the status of a document translation.
206 """
207 url = f"{self.base_url}/document/{document_id}"
208 headers = {
209 "Authorization": f"DeepL-Auth-Key {self.api_key}",
210 "Content-Type": "application/json",
211 }
212 response = requests.get(
213 url, params={"document_key": document_key}, headers=headers
214 )
215
216 kodilog("Check status response text: " + response.text)
217
218 if not self._handle_deepl_response(response, "status check"):
219 raise Exception("DeepL API error during status check")
220
221 data = response.json()
222
223 return data
224
225 def wait_until_translation_complete(self, document_id, document_key):
226 while True:
227 status = self.get_document_status(document_id, document_key)
228 if status.get("status") == "done":
229 kodilog(f"Translation completed for document {document_id}")
230 return status
231 elif status.get("status") == "error":
232 msg = f"Translation failed for document {document_id}: {status.get('message')}"
233 kodilog(msg)
234 raise Exception(f"Translation failed: {status.get('message')}")
235 sleep(5) # Corrected: sleep expects seconds, not ms
236
237 def calculate_translation_cost(self, subtitle_paths):
238 # DeepL API pricing (example, update with your actual pricing)
239 # Free: 500,000 chars/month, paid: $20 per 1,000,000 chars (as of 2024)
240 PRICE_PER_MILLION = 20.0 # USD per 1,000,000 chars
241
242 total_characters = 0
243 for file_path in subtitle_paths:
244 try:
245 with open(file_path, "r", encoding="utf-8") as f:
246 content = f.read()
247 total_characters += len(content)
248 except Exception as error:
249 kodilog(
250 f"Error reading file for cost calculation: {file_path}: {error}"
251 )
252
253 estimated_cost = (total_characters / 1_000_000) * PRICE_PER_MILLION
254 return total_characters, estimated_cost
255
256 def get_free_characters_left(self):
257 """
258 Query DeepL API for remaining free characters.
259 Returns the number of free characters left, or 0 if unavailable.
260 """
261 url = f"{self.base_url}/usage"
262 headers = {"Authorization": f"DeepL-Auth-Key {self.api_key}"}
263 try:
264 response = requests.get(url, headers=headers)
265 if response.status_code == 200:
266 data = response.json()
267 # For free accounts, 'character_limit' and 'character_count' are present
268 limit = data.get("character_limit", 0)
269 used = data.get("character_count", 0)
270 return max(0, limit - used)
271 except Exception as e:
272 kodilog(f"Error fetching DeepL usage: {e}")
273 return 0
274
275 def prompt_user_for_cost(self, subtitle_paths):
276 """
277 Show a dialog to the user with the estimated translation cost and ask for confirmation.
278 Only show cost if free usage is exhausted.
279 Returns True if user confirms, False otherwise.
280 """
281 free_chars_left = self.get_free_characters_left()
282 total_characters, estimated_cost = self.calculate_translation_cost(
283 subtitle_paths
284 )
285
286 if free_chars_left > total_characters:
287 message = translation(90666) % (free_chars_left, total_characters)
288 return show_dialog(translation(90667), message)
289
290 message = translation(90668) % (total_characters, estimated_cost)
291 return show_dialog(translation(90669), message)
292
293 def translate_file(self, filepath, idx, imdbid, season, episode):
294 """
295 Upload a document for translation, wait for completion, then download and save the result.
296 """
297 try:
298 self.notification(translation(90500))
299 url = f"{self.base_url}/document"
300 with open(filepath, "rb") as file_handle:
301 files = {
302 "file": (
303 ospath.basename(filepath),
304 file_handle,
305 "application/octet-stream",
306 )
307 }
308 data = {"target_lang": get_deepl_language_code(self.target_lang)}
309
310 headers = {"Authorization": f"DeepL-Auth-Key {self.api_key}"}
311
312 response = requests.post(url, files=files, data=data, headers=headers)
313 kodilog("Raw response content: " + response.text)
314
315 if not self._handle_deepl_response(response, "document upload"):
316 return None
317
318 upload_data = response.json()
319 if "document_id" not in upload_data or "document_key" not in upload_data:
320 raise Exception(f"Invalid response from document upload: {upload_data}")
321
322 document_id = upload_data["document_id"]
323 document_key = upload_data["document_key"]
324
325 self.wait_until_translation_complete(document_id, document_key)
326
327 self.notification(translation(90501))
328
329 return self.download_and_save_translation(
330 idx,
331 imdbid,
332 season,
333 episode,
334 lang_name=self.target_lang,
335 document_id=document_id,
336 document_key=document_key,
337 )
338 except Exception as error:
339 kodilog(f"Error in translate_file: {error}")
340 raise
341
342 def translate_multiple_subtitles(self, sub_paths, imdbid, season, episode):
343 """
344 Process multiple subtitle files by translating each one.
345 Only files within limits are processed.
346 """
347 # Filter files within limits and notify user for skipped files
348 valid_sub_paths = self.filter_files_within_limits(sub_paths)
349 if not valid_sub_paths:
350 self.notification("No subtitle files are within the allowed limits.")
351 return []
352
353 # Prompt user for cost before proceeding
354 if not self.prompt_user_for_cost(valid_sub_paths):
355 kodilog("User cancelled translation after cost prompt.")
356 return []
357 translated_subtitles = []
358
359 for idx, path in enumerate(valid_sub_paths):
360 try:
361 translated_sub = self.translate_file(path, idx, imdbid, season, episode)
362 if translated_sub:
363 translated_subtitles.append(translated_sub)
364 except Exception as error:
365 kodilog(traceback.format_exc())
366 kodilog(f"Subtitle translate error: {error}")
367
368 return translated_subtitles
plugin.video.jacktook/lib/clients/subtitle/opensubstremio.py +158 -0
……@@ -0,0 +1,158 @@
1import requests
2from typing import Callable, List, Optional, Dict, Any
3from lib.clients.subtitle.utils import (
4 get_language_code,
5 language_code_to_name,
6 slugify_title,
7)
8from lib.utils.general.utils import USER_AGENT_HEADER
9from lib.utils.kodi.utils import (
10 ADDON_PROFILE_PATH,
11 get_setting,
12 kodilog,
13 translation,
14)
15import xbmcgui
16import xbmc
17
18
19class OpenSubtitleStremioClient:
20 def __init__(self, notification: Callable[[str], None]):
21 self.notification = notification
22 self.base_url = get_setting("stremio_sub_addon_host")
23
24 def _build_url(
25 self, mode: str, imdb_id: str, season: Optional[int], episode: Optional[int]
26 ) -> str:
27 if mode == "tv":
28 return f"{self.base_url}subtitles/series/{imdb_id}:{season}:{episode}.json"
29 return f"{self.base_url}subtitles/movie/{imdb_id}.json"
30
31 def _fetch_subtitles_data(
32 self,
33 mode: str,
34 imdb_id: str,
35 season: Optional[int] = None,
36 episode: Optional[int] = None,
37 ) -> Optional[List[Dict[str, Any]]]:
38 url = self._build_url(mode, imdb_id, season, episode)
39 try:
40 response = requests.get(url)
41 if response.status_code != 200:
42 self.notification(
43 f"Failed to fetch subtitles, status code {response.status_code}"
44 )
45 return None
46 data = response.json()
47 kodilog(f"OpenSubtitles Subtitles Response: {data}", level=xbmc.LOGDEBUG)
48 return data.get("subtitles", [])
49 except Exception as e:
50 self.notification(f"Failed to fetch subtitles: {e}")
51 return None
52
53 def get_subtitles(
54 self,
55 mode: str,
56 imdb_id: str,
57 season: Optional[int] = None,
58 episode: Optional[int] = None,
59 ) -> Optional[List[Dict[str, Any]]]:
60 subtitles = self._fetch_subtitles_data(mode, imdb_id, season, episode)
61 if not subtitles:
62 return
63
64 sub_language = get_setting("subtitle_language")
65 auto_subtitle_download = get_setting("auto_subtitle_download")
66 if auto_subtitle_download:
67 filtered = [
68 s for s in subtitles if s.get("lang") == get_language_code(sub_language)
69 ]
70 if filtered:
71 return filtered
72
73 items = [
74 xbmcgui.ListItem(
75 label=translation(90665) % i, label2=language_code_to_name(s["lang"])
76 )
77 for i, s in enumerate(subtitles)
78 ]
79
80 dialog = xbmcgui.Dialog()
81 selected_indices = dialog.multiselect(
82 translation(90256),
83 items,
84 useDetails=True,
85 )
86
87 if selected_indices is None:
88 return []
89
90 return [subtitles[i] for i in selected_indices]
91
92 def download_subtitles_batch(
93 self,
94 subtitles: List[Dict[str, Any]],
95 imdb_id: str,
96 title: str,
97 season: Optional[int] = None,
98 episode: Optional[int] = None,
99 ) -> List[str]:
100 file_paths = []
101 for idx, subtitle in enumerate(subtitles):
102 try:
103 file_path = self.download_subtitle(
104 subtitle, idx, imdb_id, title, season, episode
105 )
106 if file_path:
107 file_paths.append(file_path)
108 except Exception as e:
109 kodilog(f"Failed to download subtitle: {e}")
110 continue
111 return file_paths
112
113 def download_subtitle(
114 self,
115 subtitle: Dict[str, Any],
116 index: int,
117 imdb_id: str,
118 title: str,
119 season: Optional[int] = None,
120 episode: Optional[int] = None,
121 ) -> Optional[str]:
122 url = subtitle.get("url", "")
123 lang = subtitle.get("lang")
124 lang_name = language_code_to_name(lang)
125
126 title = slugify_title(title)
127
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 )
138 else:
139 file_path = f"{ADDON_PROFILE_PATH}Subtitles/{imdb_id}/Subtitle No.{index}.{title}.{lang_name}.srt"
140
141 try:
142 response = requests.get(
143 url, stream=True, headers=USER_AGENT_HEADER, timeout=15
144 )
145 if response.status_code != 200:
146 self.notification(
147 f"Failed to download {url}, status code {response.status_code}"
148 )
149 raise Exception(f"HTTP {response.status_code}")
150 with open(file_path, "wb") as file:
151 for chunk in response.iter_content(chunk_size=8192):
152 if chunk:
153 file.write(chunk)
154 return file_path
155 except Exception as e:
156 kodilog(f"Subtitle download error for {url}: {e}")
157 self.notification(f"Subtitle download error for {url}: {e}")
158 raise
plugin.video.jacktook/lib/clients/subtitle/submanager.py +138 -0
……@@ -0,0 +1,138 @@
1import json
2import os
3
4from typing import Any, Dict, List, Optional
5from lib.clients.subtitle.deepl import DeepLTranslator
6from lib.clients.subtitle.opensubstremio import OpenSubtitleStremioClient
7from lib.utils.kodi.utils import (
8 ADDON_PROFILE_PATH,
9 get_setting,
10 kodilog,
11 translation,
12)
13
14import xbmc
15import xbmcgui
16
17
18class KodiJsonRpcClient:
19 def json_rpc(
20 self, method: str, params: Optional[Dict[str, Any]] = None
21 ) -> Dict[str, Any]:
22 """Send a JSON-RPC request to Kodi."""
23 request_data = {
24 "jsonrpc": "2.0",
25 "method": method,
26 "id": 1,
27 "params": params or {},
28 }
29 response = json.loads(xbmc.executeJSONRPC(json.dumps(request_data)))
30 if "error" in response:
31 # If you want logging here, you can add a log method or pass a logger
32 pass
33 return response.get("result", {})
34
35
36class SubtitleManager(KodiJsonRpcClient):
37 def __init__(self, data: Any, notification: Any):
38 self.data = data
39 self.notification = notification
40 self.opensub_client = OpenSubtitleStremioClient(notification)
41 self.translator = DeepLTranslator(notification)
42 self.last_fetch_status = None
43
44 def convert_language_iso(self, from_value: str) -> str:
45 """Convert language to ISO 639-1 format."""
46 return xbmc.convertLanguage(from_value, xbmc.ISO_639_1)
47
48 def get_kodi_preferred_subtitle_language(self, iso_format: bool = False) -> str:
49 """
50 Get the preferred subtitle language from Kodi settings.
51 Returns the language in ISO format if iso_format is True.
52 """
53 subtitle_language = self.json_rpc(
54 "Settings.GetSettingValue", {"setting": "locale.subtitlelanguage"}
55 )
56
57 value = subtitle_language.get("value", "")
58 if value in ["forced_only", "original", "default", "none"]:
59 return value
60 return self.convert_language_iso(value) if iso_format else value
61
62 def get_downloaded_subtitle_paths(self, folder_path: str) -> List[str]:
63 subtitle_files = []
64 for root, _, files in os.walk(folder_path):
65 for f in files:
66 if f.endswith(".srt"):
67 subtitle_files.append(os.path.join(root, f))
68 return subtitle_files
69
70 def fetch_subtitles(self, auto_select: bool = False) -> Optional[List[str]]:
71 self.last_fetch_status = None
72 title = self.data.get("title")
73 mode = self.data.get("mode")
74 imdb_id = self.data.get("ids", {}).get("imdb_id")
75 tv_data = self.data.get("tv_data", {})
76 episode = tv_data.get("episode")
77 season = tv_data.get("season")
78
79 if not imdb_id:
80 kodilog("No IMDb ID found for the current video, skipping subtitles")
81 return None
82
83 folder_path = (
84 os.path.join(
85 ADDON_PROFILE_PATH, "Subtitles", imdb_id, str(season), str(episode)
86 )
87 if mode == "tv"
88 else os.path.join(ADDON_PROFILE_PATH, "Subtitles", imdb_id)
89 )
90
91 if not os.path.exists(folder_path):
92 os.makedirs(folder_path)
93
94 subtitle_files = self.get_downloaded_subtitle_paths(folder_path)
95 if subtitle_files:
96 if auto_select:
97 return subtitle_files
98
99 dialog = xbmcgui.Dialog()
100 use_existing = dialog.yesno(
101 translation(90250),
102 translation(90251),
103 yeslabel=translation(90627),
104 nolabel=translation(90628),
105 )
106 if use_existing:
107 return subtitle_files
108
109 subtitles = self.opensub_client.get_subtitles(mode, imdb_id, season, episode)
110 if subtitles is None:
111 self.last_fetch_status = "not_found"
112 self.notification(translation(90252))
113 return None
114
115 if not subtitles:
116 self.last_fetch_status = "not_selected"
117 self.notification(translation(90253))
118 return None
119
120 subtitle_paths = self.opensub_client.download_subtitles_batch(
121 subtitles, imdb_id, title=title, season=season, episode=episode
122 )
123
124 if get_setting("deepl_enabled"):
125 dialog = xbmcgui.Dialog()
126 yes = dialog.yesno(
127 translation(90254),
128 translation(90255),
129 )
130 if yes:
131 translated_subtitles_paths = (
132 self.translator.translate_multiple_subtitles(
133 subtitle_paths, imdb_id, season, episode
134 )
135 )
136 return translated_subtitles_paths
137
138 return subtitle_paths
plugin.video.jacktook/lib/clients/subtitle/utils.py +140 -0
……@@ -0,0 +1,140 @@
1import re
2import unicodedata
3
4
5def slugify_title(title):
6 title = re.sub(r"\.[^.]+$", "", title) # Remove extension
7 title = re.sub(r"[^\w\s.-]", "", title) # Remove unwanted characters
8 return title.strip()
9
10
11def slugify(value):
12 # 1. Normaliza el texto para separar letras acentuadas (ñ → n, á → a, etc.)
13 value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode()
14
15 # 3. Reemplaza espacios y guiones múltiples con un solo guion bajo
16 return re.sub(r"[-\s]+", "_", value)
17
18
19def get_deepl_language_code(language_name):
20 language_mapping = {
21 "None": "None",
22 "العربية": "ar",
23 "Български": "bg",
24 "中文": "zh",
25 "繁體中文": "zh-Hant",
26 "简体中文": "zh-Hans",
27 "Čeština": "cs",
28 "Dansk": "da",
29 "Nederlands": "nl",
30 "English": "en",
31 "English (British)": "en-GB",
32 "English (American)": "en-US",
33 "Eesti": "et",
34 "Suomi": "fi",
35 "Français": "fr",
36 "Deutsch": "de",
37 "Ελληνικά": "el",
38 "Magyar": "hu",
39 "Indonesia": "id",
40 "Italiano": "it",
41 "日本語": "ja",
42 "한국어": "ko",
43 "Latviešu": "lv",
44 "Lietuvių": "lt",
45 "Norsk (Bokmål)": "nb",
46 "Polski": "pl",
47 "Português": "pt",
48 "Português (Brasil)": "pt-BR",
49 "Română": "ro",
50 "Русский": "ru",
51 "Slovenčina": "sk",
52 "Slovenščina": "sl",
53 "Español": "es",
54 "Svenska": "sv",
55 "Türkçe": "tr",
56 "Українська": "uk",
57 }
58 return language_mapping.get(language_name, None)
59
60
61def get_language_code(language_name):
62 """
63 Convert full language names to ISO 639-1 or 639-2 language codes.
64 """
65 language_map = {
66 "None": "None",
67 "Arabic": "ara",
68 "Bulgarian": "bul",
69 "Czech": "ces",
70 "Danish": "dan",
71 "German": "deu",
72 "Greek": "ell",
73 "English": "eng",
74 "Spanish": "spa",
75 "Estonian": "est",
76 "Finnish": "fin",
77 "French": "fra",
78 "Hungarian": "hun",
79 "Indonesian": "ind",
80 "Italian": "ita",
81 "Japanese": "jpn",
82 "Korean": "kor",
83 "Lithuanian": "lit",
84 "Latvian": "lav",
85 "Dutch": "nld",
86 "Polish": "pol",
87 "Portuguese": "por",
88 "Romanian": "ron",
89 "Russian": "rus",
90 "Slovak": "slk",
91 "Slovenian": "slv",
92 "Swedish": "swe",
93 "Turkish": "tur",
94 "Ukrainian": "ukr",
95 "Chinese": "zho",
96 }
97
98 return language_map.get(language_name, None)
99
100
101def language_code_to_name(code):
102 """
103 Convert ISO 639-1 or 639-2 language codes to full language names.
104 """
105 lang_map = {
106 "eng": "English",
107 "fre": "French",
108 "fra": "French",
109 "ger": "German",
110 "deu": "German",
111 "spa": "Spanish",
112 "spl": "Spanish (Latin)",
113 "srp": "Serbian",
114 "hrv": "Croatian",
115 "slo": "Slovak",
116 "slk": "Slovak",
117 "slv": "Slovenian",
118 "ell": "Greek",
119 "gre": "Greek",
120 "ara": "Arabic",
121 "jpn": "Japanese",
122 "vie": "Vietnamese",
123 "ron": "Romanian",
124 "rum": "Romanian",
125 "kur": "Kurdish",
126 "ind": "Indonesian",
127 "tur": "Turkish",
128 "mal": "Malayalam",
129 "per": "Persian",
130 "fas": "Persian",
131 "ita": "Italian",
132 "nld": "Dutch",
133 "dut": "Dutch",
134 "por": "Portuguese",
135 "zho": "Chinese",
136 "chi": "Chinese",
137 # Add more as needed
138 }
139
140 return lang_map.get(code.lower(), f"Unknown ({code})")
plugin.video.jacktook/lib/clients/tmdb/anime.py +106 -0
……@@ -0,0 +1,106 @@
1from lib.clients.tmdb.base import BaseTmdbClient
2from lib.clients.tmdb.utils.utils import (
3 get_tmdb_movie_details,
4 get_tmdb_show_details,
5)
6
7from lib.db.pickle_db import PickleDatabase
8from lib.utils.general.utils import (
9 Animation,
10 Anime,
11 Cartoons,
12 add_next_button,
13 execute_thread_pool,
14 set_media_infoTag,
15 set_pluging_category,
16)
17
18from lib.utils.kodi.utils import (
19 end_of_directory,
20 kodilog,
21 make_list_item,
22 show_keyboard,
23 notification,
24 translation,
25)
26
27
28class TmdbAnimeClient(BaseTmdbClient):
29 def handle_anime_search_query(self, page):
30 if page == 1:
31 query = show_keyboard(id=30241)
32 if query:
33 PickleDatabase().set_key("anime_query", query)
34 return query
35 return None
36 return PickleDatabase().get_key("anime_query")
37
38 def handle_anime_category_query(self, client, category, submode, page):
39 if category == Anime.AIRING:
40 set_pluging_category(translation(90039))
41 return client.anime_on_the_air(submode, page)
42 elif category == Anime.POPULAR:
43 set_pluging_category(translation(90064))
44 return client.anime_popular(submode, page)
45 elif category == Anime.POPULAR_RECENT:
46 set_pluging_category(translation(90038))
47 return client.anime_popular_recent(submode, page)
48 elif category == Anime.TOP_RATED:
49 set_pluging_category(translation(90042))
50 return client.anime_top_rated(submode, page)
51 else:
52 kodilog(f"Invalid category: {category}")
53 return None
54
55 def process_anime_results(self, data, submode, page, mode, category):
56 if data.total_results == 0:
57 notification("No results found")
58 return
59 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 )
63 end_of_directory()
64
65 @staticmethod
66 def handle_anime_years_or_genres(category, mode, page, submode):
67 if category == Anime.YEARS:
68 TmdbAnimeClient.show_years_items(mode, page, submode)
69 elif category == Anime.GENRES:
70 TmdbAnimeClient.show_genres_items(mode, page, submode)
71 else:
72 kodilog(f"Invalid category: {category}")
73 return None
74
75 @staticmethod
76 def handle_animation_or_cartoons_query(client, category, submode, page):
77 if category == Animation().POPULAR:
78 return client.animation_popular(submode, page)
79 elif category == Cartoons.POPULAR:
80 return client.cartoons_popular(submode, page)
81 else:
82 kodilog(f"Invalid category: {category}")
83 return None
84
85 @staticmethod
86 def show_anime_results(res, mode):
87 tmdb_id = res.get("id", "")
88 if mode == "movies":
89 title = res.title
90 movie_details = get_tmdb_movie_details(tmdb_id)
91 imdb_id = getattr(movie_details, "external_ids").get("imdb_id", "")
92 tvdb_id = ""
93 elif mode == "tv":
94 title = res.name
95 show_details = get_tmdb_show_details(tmdb_id)
96 external_ids = getattr(show_details, "external_ids")
97 imdb_id = external_ids.get("imdb_id", "")
98 tvdb_id = external_ids.get("tvdb_id", "")
99 else:
100 kodilog(f"Invalid mode: {mode}")
101 return None
102
103 ids = {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id}
104 list_item = make_list_item(label=title)
105 set_media_infoTag(list_item, data=res, mode=mode)
106 TmdbAnimeClient.add_media_directory_item(list_item, mode, title, ids)
plugin.video.jacktook/lib/clients/tmdb/base.py +146 -0
……@@ -0,0 +1,146 @@
1from datetime import datetime
2
3from lib.api.tmdbv3api.as_obj import AsObj
4from lib.utils.general.utils import set_pluging_category
5from lib.utils.kodi.utils import (
6 add_directory_items_batch,
7 apply_section_view,
8 build_url,
9 end_of_directory,
10 make_list_item,
11 notification,
12 translation,
13)
14from lib.clients.tmdb.utils.utils import (
15 add_kodi_dir_item,
16 add_tmdb_movie_context_menu,
17 add_tmdb_show_context_menu,
18 tmdb_get,
19)
20from lib.api.trakt.trakt_utils import (
21 add_trakt_collection_context_menu,
22 add_trakt_custom_list_context_menu,
23 add_trakt_favorites_context_menu,
24 add_trakt_watchlist_context_menu,
25 add_trakt_watched_context_menu,
26 is_trakt_auth,
27)
28
29
30class BaseTmdbClient:
31 @staticmethod
32 def add_media_directory_item(list_item, mode, title, ids, media_type="", batch=False):
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 )
37 if is_trakt_auth():
38 context_menu += (
39 add_trakt_watchlist_context_menu("movies", ids)
40 + add_trakt_watched_context_menu("movies", ids=ids)
41 + add_trakt_collection_context_menu("movies", ids)
42 + add_trakt_favorites_context_menu("movies", ids)
43 + add_trakt_custom_list_context_menu("movies", ids)
44 )
45 list_item.addContextMenuItems(context_menu)
46 list_item.setProperty("IsPlayable", "true")
47 is_folder = False
48 elif mode == "tv" or (mode == "multi" and media_type == "tv"):
49 context_menu = add_tmdb_show_context_menu(mode, ids=ids, title=title)
50 if is_trakt_auth():
51 context_menu += (
52 add_trakt_watchlist_context_menu("shows", ids)
53 + add_trakt_watched_context_menu("shows", ids=ids)
54 + add_trakt_collection_context_menu("shows", ids)
55 + add_trakt_favorites_context_menu("shows", ids)
56 + add_trakt_custom_list_context_menu("shows", ids)
57 )
58 list_item.addContextMenuItems(context_menu)
59 is_folder = True
60 else:
61 is_folder = True
62
63 url = build_url(
64 "show_tmdb_item",
65 mode=mode,
66 submode="",
67 id=ids.get("tmdb_id"),
68 title=title,
69 media_type=media_type,
70 )
71 if batch:
72 return (url, list_item, is_folder)
73 add_kodi_dir_item(
74 list_item=list_item,
75 url=url,
76 is_folder=is_folder,
77 )
78
79 @staticmethod
80 def show_years_items(mode, page, submode=None):
81 set_pluging_category(translation(90027))
82 directory_items = []
83 current_year = datetime.now().year
84 for year in range(current_year, 1899, -1):
85 list_item = make_list_item(label=str(year))
86 item = add_kodi_dir_item(
87 list_item=list_item,
88 url=build_url(
89 "search_tmdb_year",
90 mode=mode,
91 submode=submode,
92 year=year,
93 page=page,
94 ),
95 is_folder=True,
96 icon_path="status.png",
97 batch=True,
98 )
99 directory_items.append(item)
100 add_directory_items_batch(directory_items)
101 end_of_directory()
102 if mode == "tv" or (mode == "anime" and submode == "tv"):
103 apply_section_view("view.tvshows", content_type="tvshows")
104 else:
105 apply_section_view("view.movies", content_type="movies")
106
107 @staticmethod
108 def show_genres_items(mode, page, submode=None):
109 set_pluging_category(translation(90025))
110 path = (
111 "show_genres"
112 if mode == "tv" or (mode == "anime" and submode == "tv")
113 else "movie_genres"
114 )
115 genres = tmdb_get(path=path)
116 if genres is None or len(genres) == 0:
117 notification("No genres found")
118 end_of_directory()
119 return
120
121 directory_items = []
122 for genre in genres:
123 if isinstance(genre, AsObj):
124 if genre.get("name") == "TV Movie":
125 continue
126 list_item = make_list_item(label=genre["name"])
127 item = add_kodi_dir_item(
128 list_item=list_item,
129 url=build_url(
130 "search_tmdb_genres",
131 mode=mode,
132 submode=submode,
133 genre_id=genre["id"],
134 page=page,
135 ),
136 is_folder=True,
137 icon_path=None,
138 batch=True,
139 )
140 directory_items.append(item)
141 add_directory_items_batch(directory_items)
142 end_of_directory()
143 if mode == "tv" or (mode == "anime" and submode == "tv"):
144 apply_section_view("view.tvshows", content_type="tvshows")
145 else:
146 apply_section_view("view.movies", content_type="movies")
plugin.video.jacktook/lib/clients/tmdb/collections.py +197 -0
……@@ -0,0 +1,197 @@
1from lib.clients.tmdb.utils.collections_utils import (
2 POPULAR_COLLECTIONS,
3 TOP_RATED_COLLECTIONS,
4)
5from lib.clients.tmdb.base import BaseTmdbClient
6from lib.db.pickle_db import PickleDatabase
7from lib.utils.general.utils import (
8 add_next_button,
9 execute_thread_pool,
10 set_media_infoTag,
11 set_pluging_category,
12)
13
14from lib.utils.kodi.utils import (
15 add_directory_items_batch,
16 apply_section_view,
17 build_url,
18 end_of_directory,
19 make_list_item,
20 show_keyboard,
21 notification,
22 translation,
23)
24from lib.clients.tmdb.utils.utils import (
25 tmdb_get,
26 add_kodi_dir_item,
27 get_tmdb_movie_details,
28)
29
30
31
32
33class TmdbCollections(BaseTmdbClient):
34 PAGE_SIZE = 10
35
36 @staticmethod
37 def add_collection_details(params):
38 collection = tmdb_get("collection_details", params.get("collection_id"))
39 if not collection:
40 notification("Collection details not found.")
41 end_of_directory()
42 return
43
44 directory_items = []
45 for movie in collection.get("parts", []) or []:
46 movie_item = make_list_item(label=movie.get("title", "Untitled"))
47 movie_item.setProperty("IsPlayable", "true")
48 set_media_infoTag(movie_item, data=movie, mode="movie")
49
50 tmdb_id = movie.get("id")
51 imdb_id = ""
52 details = get_tmdb_movie_details(tmdb_id)
53 if details:
54 imdb_id = getattr(details, "external_ids").get("imdb_id", "")
55
56 add_kodi_dir_item(
57 list_item=movie_item,
58 url=build_url(
59 "search",
60 mode="movies",
61 media_type="movies",
62 query=movie.get("title", "Untitled"),
63 ids={"tmdb_id": tmdb_id, "imdb_id": imdb_id},
64 ),
65 is_folder=False,
66 batch=True,
67 directory_items=directory_items,
68 )
69
70 add_directory_items_batch(directory_items)
71 end_of_directory()
72 apply_section_view("view.movies", content_type="movies")
73
74 @staticmethod
75 def _add_collection_item(collection):
76 collection_id = collection.get("id")
77 images_data = tmdb_get("collection_images", collection_id)
78 if images_data:
79 posters = images_data.get("posters") or []
80 if posters:
81 file_path = posters[0].get("file_path")
82 collection["poster_path"] = file_path
83
84 list_item = make_list_item(label=collection.get("name"))
85 set_media_infoTag(list_item, data=collection, mode="movies")
86 add_kodi_dir_item(
87 list_item=list_item,
88 url=build_url("handle_collection_details", collection_id=collection_id),
89 is_folder=True,
90 )
91
92 @staticmethod
93 def fetch_and_add_collection(collection_data):
94 collection_id = collection_data["id"]
95 collection_details = tmdb_get("collection_details", collection_id)
96 if collection_details:
97 TmdbCollections._add_collection_item(collection_details)
98
99 @staticmethod
100 def get_popular_collections(mode, page):
101 set_pluging_category(translation(90064))
102
103 start_index = (page - 1) * TmdbCollections.PAGE_SIZE
104 end_index = start_index + TmdbCollections.PAGE_SIZE
105
106 current_page_collections = POPULAR_COLLECTIONS[start_index:end_index]
107
108 if not current_page_collections:
109 notification("No more popular collections to display.")
110 end_of_directory()
111 return
112
113 execute_thread_pool(
114 current_page_collections, TmdbCollections.fetch_and_add_collection
115 )
116
117 # Add "Next" button
118 if end_index < len(POPULAR_COLLECTIONS):
119 add_next_button(
120 "handle_collection_query", submode="popular", page=page + 1, mode=mode
121 )
122
123 end_of_directory()
124 apply_section_view("view.movies", content_type="movies")
125
126 @staticmethod
127 def get_top_rated_collections(mode, page):
128 set_pluging_category(translation(90042))
129
130 start_index = (page - 1) * TmdbCollections.PAGE_SIZE
131 end_index = start_index + TmdbCollections.PAGE_SIZE
132
133 current_page_collections = TOP_RATED_COLLECTIONS[start_index:end_index]
134
135 if not current_page_collections:
136 notification("No more collections to display.")
137 end_of_directory()
138 return
139
140 execute_thread_pool(
141 current_page_collections, TmdbCollections.fetch_and_add_collection
142 )
143
144 # Add "Next" button
145 if end_index < len(TOP_RATED_COLLECTIONS):
146 add_next_button(
147 "handle_collection_query", submode="top_rated", page=page + 1, mode=mode
148 )
149
150 end_of_directory()
151 apply_section_view("view.movies", content_type="movies")
152
153 @staticmethod
154 def search_collections(mode, page):
155 query = (
156 show_keyboard(id=90068)
157 if page == 1
158 else PickleDatabase().get_key("collection_search_query")
159 )
160 if not query:
161 end_of_directory(cache=False)
162 return
163
164 if page == 1:
165 PickleDatabase().set_key("collection_search_query", query)
166
167 results = tmdb_get("search_collections", params={"query": query, "page": page})
168 if not results or getattr(results, "total_results", 0) == 0:
169 notification("No results found for your search.")
170 end_of_directory()
171 return
172
173 execute_thread_pool(
174 getattr(results, "results"), TmdbCollections._add_collection_item
175 )
176
177 add_next_button(
178 "handle_collection_query",
179 submode="search",
180 query=query,
181 page=page + 1,
182 mode=mode,
183 )
184 end_of_directory()
185 apply_section_view("view.movies", content_type="movies")
186
187 @staticmethod
188 def _extract_collection_id(movie, collection_ids_set):
189 from lib.clients.tmdb.utils.utils import get_tmdb_movie_details
190
191 """
192 Helper function to extract collection ID from movie details.
193 Designed to be used with execute_thread_pool.
194 """
195 movie_details = get_tmdb_movie_details(getattr(movie, "id"))
196 if movie_details and getattr(movie_details, "belongs_to_collection"):
197 collection_ids_set.add(movie_details.get("belongs_to_collection").get("id"))
plugin.video.jacktook/lib/clients/tmdb/people_client.py +251 -0
……@@ -0,0 +1,251 @@
1import json
2from lib.clients.tmdb.base import BaseTmdbClient
3from lib.clients.tmdb.utils.utils import add_kodi_dir_item, tmdb_get
4from lib.utils.general.utils import (
5 add_next_button,
6 execute_thread_pool,
7 set_content_type,
8 set_media_infoTag,
9 set_pluging_category,
10)
11from lib.utils.kodi.utils import (
12 build_url,
13 end_of_directory,
14 kodilog,
15 make_list_item,
16 show_keyboard,
17 notification,
18 translation,
19)
20
21
22class PeopleClient(BaseTmdbClient):
23 def get_image_url(self, path):
24 if not path:
25 return ""
26 base_url = "https://image.tmdb.org/t/p/w500"
27 return f"{base_url}{path}"
28
29 def search_people(self, mode, page=1):
30 set_pluging_category(translation(90081))
31 set_content_type(mode)
32
33 query = show_keyboard(id=30241) if page == 1 else None
34 if not query:
35 end_of_directory(cache=False)
36 return
37
38 data = tmdb_get("search_people", params={"query": query, "page": page})
39 if not data or getattr(data, "total_results", 0) == 0:
40 notification(translation(90389))
41 end_of_directory(cache=False)
42 return
43
44 execute_thread_pool(
45 getattr(data, "results"), PeopleClient.show_people_details, mode
46 )
47
48 add_next_button(
49 "handle_tmdb_query",
50 query="tmdb_people",
51 subquery="search_people",
52 page=page + 1,
53 mode=mode,
54 )
55 end_of_directory()
56
57 @staticmethod
58 def search_people_by_id(params):
59 set_pluging_category(translation(90078))
60 mode = params.get("mode", "tv")
61 media_type = params.get("media_type", "")
62 set_content_type(mode)
63
64 ids = json.loads(params.get("ids", "{}"))
65 tmdb_id = ids.get("tmdb_id")
66 if not tmdb_id:
67 notification(translation(90400))
68 return
69
70 if mode == "movies" or media_type == "movie":
71 credits = tmdb_get("movie_credits", params=tmdb_id)
72 else:
73 credits = tmdb_get("tv_credits", params=tmdb_id)
74
75 if not credits:
76 notification(translation(90418))
77 return
78
79 def get_media_credits(person):
80 person_id = person.get("id")
81 if not person_id:
82 return
83 PeopleClient.show_people_details(person, mode)
84
85 execute_thread_pool(getattr(credits, "cast"), get_media_credits)
86
87 end_of_directory()
88
89 def show_popular_people(self, mode, page=1):
90 set_pluging_category(translation(90079))
91 set_content_type(mode)
92
93 data = tmdb_get("popular_people", params=page)
94 if not data or getattr(data, "total_results", 0) == 0:
95 notification(translation(90389))
96 return
97
98 execute_thread_pool(
99 getattr(data, "results"), PeopleClient.show_people_details, mode
100 )
101
102 add_next_button(
103 "handle_tmdb_query",
104 query="tmdb_people",
105 subquery="popular_people",
106 page=page + 1,
107 mode=mode,
108 )
109 end_of_directory()
110
111 def show_trending_people(self, mode, page=1):
112 set_pluging_category(translation(90080))
113 set_content_type(mode)
114
115 data = tmdb_get("trending_people", params=page)
116 if not data or getattr(data, "total_results", 0) == 0:
117 notification(translation(90389))
118 return
119
120 execute_thread_pool(
121 getattr(data, "results"), PeopleClient.show_people_details, mode
122 )
123
124 add_next_button(
125 "handle_tmdb_query",
126 query="tmdb_people",
127 subquery="latest_people",
128 page=page + 1,
129 mode=mode,
130 )
131
132 end_of_directory()
133
134 @staticmethod
135 def show_credited_people(credit, mode, media_type):
136 set_pluging_category(translation(90419))
137 title = credit.get("title") or credit.get("name") or "Unknown"
138
139 # Extract year
140 year = ""
141 if credit.get("release_date"):
142 year = credit["release_date"][:4]
143 elif credit.get("first_air_date"):
144 year = credit["first_air_date"][:4]
145
146 # Build label with role
147 label = f"{title} ({year})" if year else title
148 if credit.get("character"):
149 label += f" as {credit['character']}"
150
151 tmdb_id = credit.get("id")
152 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")
155
156 ids = {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id}
157
158 list_item = make_list_item(label=label)
159 set_media_infoTag(list_item, data=credit, mode=mode)
160
161 if media_type == "movie":
162 list_item.setProperty("IsPlayable", "true")
163 add_kodi_dir_item(
164 list_item=list_item,
165 url=build_url(
166 "search",
167 mode="movies",
168 media_type="movies",
169 query=title,
170 ids=ids,
171 ),
172 is_folder=False,
173 )
174 else:
175 add_kodi_dir_item(
176 list_item=list_item,
177 url=build_url(
178 "show_seasons_details",
179 mode="tv",
180 ids=ids,
181 ),
182 is_folder=True,
183 )
184
185 @staticmethod
186 def show_people_details(person, mode):
187 details = tmdb_get("person_details", params=person.get("id"))
188 list_item = make_list_item(label=person.get("name", "Unknown"))
189 set_media_infoTag(list_item, data=details, mode=mode)
190 add_kodi_dir_item(
191 list_item=list_item,
192 url=build_url(
193 "handle_tmdb_person_info", mode=mode, person_id=person.get("id")
194 ),
195 is_folder=False,
196 )
197
198 @staticmethod
199 def handle_tmdb_person_info(params):
200 from lib.gui.actor_info_window import ActorInfoWindow
201 from lib.utils.kodi.utils import ADDON_PATH
202
203 mode = params.get("mode")
204 person_id = params.get("person_id")
205 window = ActorInfoWindow(
206 "actor_info.xml",
207 ADDON_PATH,
208 person_id=person_id,
209 )
210 window.doModal()
211 del window
212
213 @staticmethod
214 def handle_tmdb_person_details(params):
215 mode = params.get("mode")
216 set_pluging_category(translation(90420))
217 set_content_type(mode)
218
219 if mode == "movies":
220 media_type = "movie"
221 person_credits = tmdb_get(
222 "person_movie_credits", params=params.get("person_id")
223 )
224 if not person_credits:
225 notification(translation(90421))
226 return
227 elif mode == "tv":
228 media_type = "tv"
229 person_credits = tmdb_get(
230 "person_tv_credits", params=params.get("person_id")
231 )
232 if not person_credits:
233 notification(translation(90421))
234 return
235 else:
236 notification(translation(90390))
237 return
238
239 # Sort credits by release date (newest first)
240 def get_date(c):
241 return c.get("release_date") or c.get("first_air_date") or ""
242
243 credits = sorted(
244 getattr(person_credits, "cast", []), key=get_date, reverse=True
245 )
246
247 execute_thread_pool(
248 credits, PeopleClient.show_credited_people, mode, media_type
249 )
250
251 end_of_directory()
plugin.video.jacktook/lib/clients/tmdb/tmdb.py +1526 -0
……@@ -0,0 +1,1526 @@
1from concurrent.futures import ThreadPoolExecutor
2from datetime import datetime, timedelta
3import json
4import os
5
6from lib.api.tmdbv3api.as_obj import AsObj
7from lib.api.tmdbv3api.objs.anime import TmdbAnime
8from lib.clients.tmdb.anime import TmdbAnimeClient
9from lib.clients.tmdb.base import BaseTmdbClient
10from lib.clients.tmdb.people_client import PeopleClient
11from lib.api.tmdbv3api.objs.search import Search
12from lib.clients.tmdb.utils.utils import add_kodi_dir_item, tmdb_get
13from lib.db.cached import cache
14from lib.utils.general.utils import (
15 add_next_button,
16 execute_thread_pool,
17 set_content_type,
18 set_media_infoTag,
19 set_pluging_category,
20 translate_weekday,
21)
22
23from lib.db.pickle_db import PickleDatabase
24from lib.utils.kodi.utils import (
25 ADDON_PATH,
26 add_directory_items_batch,
27 apply_section_view,
28 build_url,
29 close_busy_dialog,
30 end_of_directory,
31 kodilog,
32 make_list_item,
33 show_busy_dialog,
34 show_keyboard,
35 notification,
36 translation,
37)
38
39from lib.utils.views.shows import show_episode_info, show_season_info
40from lib.utils.views.weekly_calendar import is_this_week, parse_date_str
41from lib.utils.views.weekly_calendar import get_episodes_for_show
42
43from lib.utils.general.utils import Anime
44
45
46def _apply_tmdb_view(mode, content_type=""):
47 if mode == "movies":
48 return apply_section_view("view.movies", content_type or "movies")
49 if mode == "tv":
50 return apply_section_view("view.tvshows", content_type or "tvshows")
51 if mode == "season":
52 return apply_section_view("view.seasons", content_type or "seasons")
53 if mode == "episode":
54 return apply_section_view("view.episodes", content_type or "episodes")
55 return apply_section_view("view.main", content_type=content_type)
56
57import xbmcgui
58import xbmc
59
60
61class TmdbClient(BaseTmdbClient):
62 @staticmethod
63 def _get_result_value(item, key, default=None):
64 if isinstance(item, dict):
65 return item.get(key, default)
66 return getattr(item, key, default)
67
68 @staticmethod
69 def _result_to_metadata(item):
70 keys = (
71 "id",
72 "title",
73 "name",
74 "original_title",
75 "original_name",
76 "overview",
77 "poster_path",
78 "backdrop_path",
79 "still_path",
80 "profile_path",
81 "release_date",
82 "first_air_date",
83 "vote_average",
84 "vote_count",
85 "popularity",
86 "genre_ids",
87 "genres",
88 "origin_country",
89 "media_type",
90 "imdb_id",
91 "tvdb_id",
92 )
93 metadata = {
94 key: TmdbClient._get_result_value(item, key)
95 for key in keys
96 if TmdbClient._get_result_value(item, key) is not None
97 }
98 images = TmdbClient._get_result_value(item, "images")
99 if images:
100 metadata["images"] = images
101 return metadata
102
103 @staticmethod
104 def _get_cached_tmdb_item_metadata(item, mode):
105 tmdb_id = TmdbClient._get_result_value(item, "id")
106 if not tmdb_id:
107 return TmdbClient._result_to_metadata(item)
108
109 media_type = TmdbClient._get_result_value(item, "media_type", "") or mode
110 normalized_mode = "movies" if media_type in ("movie", "movies") else "tv"
111 cache_key = f"tmdb_ui_meta|{normalized_mode}|{tmdb_id}"
112
113 cached_metadata = cache.get(cache_key)
114 if cached_metadata:
115 merged = TmdbClient._result_to_metadata(item)
116 merged.update({k: v for k, v in cached_metadata.items() if v not in (None, "", [], {})})
117 return merged
118
119 metadata = TmdbClient._result_to_metadata(item)
120 path = "movie_images" if normalized_mode == "movies" else "tv_images"
121 images = tmdb_get(path, {"id": tmdb_id})
122 if images:
123 metadata["images"] = images
124
125 cache.set(cache_key, metadata, timedelta(hours=24))
126 return metadata
127
128 @staticmethod
129 def handle_tmdb_query(params):
130 mode = params.get("mode")
131 submode = params.get("submode")
132 category = params.get("category")
133 page = int(params.get("page", 1))
134
135 handlers = {
136 "movies": lambda: TmdbClient.handle_tmdb_movie_query(params),
137 "tv": lambda: TmdbClient.handle_tmdb_show_query(params),
138 }
139
140 anime_modes = {"anime", "cartoon", "animation"}
141 if mode in anime_modes:
142 handlers[mode] = lambda: TmdbClient.handle_tmdb_anime_query(
143 category, mode, submode, page
144 )
145
146 handler = handlers.get(mode)
147 if handler:
148 return handler()
149 else:
150 notification(translation(90518))
151
152 @staticmethod
153 def handle_tmdb_movie_query(params):
154 query = params.get("query", "")
155 subquery = params.get("subquery", "")
156 mode = params.get("mode")
157 page = int(params.get("page", 1))
158
159 query_handlers = {
160 "tmdb_trending": lambda: TmdbClient.show_trending_movies(mode, page),
161 "tmdb_genres": lambda: TmdbClient.show_genres_items(mode, page),
162 "tmdb_popular": lambda: TmdbClient.show_popular_items(mode, page),
163 "tmdb_years": lambda: BaseTmdbClient.show_years_items(mode, page),
164 "tmdb_lang": lambda: TmdbClient.show_languages(mode, page),
165 "tmdb_collections": lambda: TmdbClient.show_collections_menu(mode),
166 "tmdb_keywords": lambda: TmdbClient.show_keywords_items(query, page, mode),
167 "tmdb_people": lambda: TmdbClient.handle_tmdb_people(subquery, mode, page),
168 }
169
170 handler = query_handlers.get(query)
171 if handler:
172 handler()
173 else:
174 notification(translation(90390))
175
176 @staticmethod
177 def handle_tmdb_people(subquery="", mode="", page=1):
178 set_pluging_category("People")
179 query_handlers = {
180 "search_people": lambda: PeopleClient().search_people(mode, page),
181 "popular_people": lambda: PeopleClient().show_popular_people(mode, page),
182 "latest_people": lambda: PeopleClient().show_trending_people(mode, page),
183 }
184
185 handler = query_handlers.get(subquery)
186 if handler:
187 handler()
188 else:
189 notification("Invalid query")
190
191 @staticmethod
192 def handle_tmdb_show_query(params):
193 query = params.get("query", "")
194 subquery = params.get("subquery", "")
195 mode = params.get("mode")
196 page = int(params.get("page", 1))
197
198 query_handlers = {
199 "tmdb_trending": lambda: TmdbClient.show_trending_shows(query, mode, page),
200 "tmdb_popular": lambda: TmdbClient.show_popular_items(mode, page),
201 "tmdb_airing_today": lambda: TmdbClient.show_airing_today_items(mode, page),
202 "tmdb_lang": lambda: TmdbClient.show_languages(mode, page),
203 "tmdb_genres": lambda: TmdbClient.show_genres_items(mode, page),
204 "tmdb_calendar": lambda: TmdbClient.show_calendar_items(query, page, mode),
205 "tmdb_years": lambda: TmdbClient.show_years_items(mode, page),
206 "tmdb_networks": lambda: TmdbClient.show_networks(mode, page),
207 "tmdb_people": lambda: TmdbClient.handle_tmdb_people(subquery, mode, page),
208 }
209
210 handler = query_handlers.get(query)
211 if handler:
212 return handler()
213 else:
214 notification("Invalid query")
215
216 @staticmethod
217 def handle_tmdb_anime_query(category, mode, submode, page):
218 set_content_type(mode)
219
220 def handle_search():
221 return TmdbAnime().anime_search(
222 TmdbAnimeClient().handle_anime_search_query(page), submode, page
223 )
224
225 def handle_category():
226 return TmdbAnimeClient().handle_anime_category_query(
227 TmdbAnime(), category, submode, page
228 )
229
230 def handle_years_or_genres():
231 return TmdbAnimeClient().handle_anime_years_or_genres(
232 category, mode, page, submode
233 )
234
235 handlers = {
236 Anime.SEARCH: handle_search,
237 Anime.AIRING: handle_category,
238 Anime.POPULAR: handle_category,
239 Anime.POPULAR_RECENT: handle_category,
240 Anime.TOP_RATED: handle_category,
241 Anime.YEARS: handle_years_or_genres,
242 Anime.GENRES: handle_years_or_genres,
243 }
244
245 handler = handlers.get(category)
246 if handler:
247 data = handler()
248 if data:
249 TmdbAnimeClient().process_anime_results(
250 data, submode, page, mode, category
251 )
252
253 @staticmethod
254 def handle_tmdb_search(params):
255 set_pluging_category(translation(90006))
256 mode = params.get("mode")
257 set_content_type(mode)
258
259 page = int(params.get("page", 1))
260 query = params.get("query")
261 if not query:
262 query = (
263 show_keyboard(id=30241)
264 if page == 1
265 else PickleDatabase().get_key("search_query")
266 )
267 if not query:
268 end_of_directory(cache=False)
269 return
270
271 show_busy_dialog()
272
273 if page == 1:
274 PickleDatabase().set_key("search_query", query)
275 from datetime import timedelta
276 from lib.db.cached import cache
277 from lib.utils.kodi.settings import get_cache_expiration
278
279 cache.add_to_list(
280 key="multi",
281 item=("multi", query),
282 expires=timedelta(hours=get_cache_expiration()),
283 )
284
285 data = tmdb_get("search_multi", {"query": query, "page": page})
286 if not data or getattr(data, "total_results", 0) == 0:
287 notification(translation(90389))
288 close_busy_dialog()
289 end_of_directory(cache=False)
290 return
291
292 results = getattr(data, "results", [])
293 if results:
294 directory_items = []
295 for item in results:
296 tmdb_id = item.get("id", "")
297 title = item.get("title", "") or item.get("name", "")
298 media_type = item.get("media_type", "")
299
300 if media_type == "movie":
301 label_title = f"[B]{translation(90008)} -[/B] {title}"
302 elif media_type == "tv":
303 label_title = f"[B]{translation(90007)} -[/B] {title}"
304 else:
305 continue
306
307 metadata = TmdbClient._get_cached_tmdb_item_metadata(item, media_type)
308 list_item = make_list_item(label=label_title)
309 # Use item directly as tmdb_obj for basic info
310 set_media_infoTag(list_item, data=metadata, mode=media_type)
311 item_tuple = BaseTmdbClient.add_media_directory_item(
312 list_item=list_item,
313 mode=mode,
314 title=title,
315 ids={"tmdb_id": tmdb_id},
316 media_type=media_type,
317 batch=True,
318 )
319 directory_items.append(item_tuple)
320 add_directory_items_batch(directory_items)
321 add_next_button("handle_tmdb_search", page=page + 1, mode=mode)
322 end_of_directory()
323 _apply_tmdb_view("main")
324
325 close_busy_dialog()
326
327 @staticmethod
328 def tmdb_search_genres(mode, genre_id, page, submode=None):
329 path_map = {
330 "movies": "discover_movie",
331 "tv": "discover_tv",
332 "anime": "anime_genres",
333 }
334 params_map = {
335 "movies": {
336 "with_genres": genre_id,
337 "append_to_response": "external_ids",
338 "page": page,
339 },
340 "tv": {"with_genres": genre_id, "page": page},
341 "anime": {
342 "mode": submode,
343 "genre_id": genre_id,
344 "page": page,
345 },
346 }
347
348 path = path_map.get(mode)
349 params = params_map.get(mode)
350
351 if not path or not params:
352 notification(translation(90390))
353 return
354
355 data = tmdb_get(path=path, params=params)
356 if not data or getattr(data, "total_results", 0) == 0:
357 notification(translation(90389))
358 return
359
360 results = getattr(data, "results", [])
361 if results:
362 display_mode = submode if mode == "anime" and submode else mode
363 directory_items = []
364 for item in results:
365 tmdb_id = item.get("id", "")
366 title = item.get("title", "") or item.get("name", "")
367 media_type = item.get("media_type", "")
368 list_item = make_list_item(label=title)
369 display_mode = submode if mode == "anime" and submode else mode
370 metadata = TmdbClient._get_cached_tmdb_item_metadata(item, display_mode)
371 set_media_infoTag(list_item, data=metadata, mode=display_mode)
372 item_tuple = BaseTmdbClient.add_media_directory_item(
373 list_item=list_item,
374 mode=display_mode,
375 title=title,
376 ids={"tmdb_id": tmdb_id},
377 media_type=media_type,
378 batch=True,
379 )
380 directory_items.append(item_tuple)
381 add_directory_items_batch(directory_items)
382 add_next_button(
383 "search_tmdb_genres",
384 mode=mode,
385 submode=submode,
386 genre_id=genre_id,
387 page=page,
388 )
389 end_of_directory()
390 _apply_tmdb_view(display_mode)
391
392 @staticmethod
393 def tmdb_search_year(mode, submode, year, page):
394 set_pluging_category(str(year))
395 path_map = {
396 "movies": "discover_movie",
397 "tv": "discover_tv",
398 "anime": "anime_year",
399 }
400 params_map = {
401 "movies": {"primary_release_year": year, "page": page},
402 "tv": {"first_air_date_year": year, "page": page},
403 "anime": {"mode": submode, "year": year, "page": page},
404 }
405
406 path = path_map.get(mode)
407 params = params_map.get(mode)
408
409 if not path or not params:
410 notification("Invalid mode")
411 return
412
413 data = tmdb_get(path=path, params=params)
414 if not data:
415 return
416
417 if getattr(data, "total_results", 0) == 0:
418 notification("No results found")
419 return
420
421 results = getattr(data, "results", [])
422 if results:
423 display_mode = submode if mode == "anime" and submode else mode
424 directory_items = []
425 for item in results:
426 tmdb_id = item.get("id", "")
427 title = item.get("title", "") or item.get("name", "")
428 media_type = item.get("media_type", "")
429 list_item = make_list_item(label=title)
430 display_mode = submode if mode == "anime" and submode else mode
431 metadata = TmdbClient._get_cached_tmdb_item_metadata(item, display_mode)
432 set_media_infoTag(list_item, data=metadata, mode=display_mode)
433 item_tuple = BaseTmdbClient.add_media_directory_item(
434 list_item=list_item,
435 mode=display_mode,
436 title=title,
437 ids={"tmdb_id": tmdb_id},
438 media_type=media_type,
439 batch=True,
440 )
441 directory_items.append(item_tuple)
442 add_directory_items_batch(directory_items)
443 add_next_button(
444 "search_tmdb_year", page=page, mode=mode, submode=submode, year=year
445 )
446 end_of_directory()
447 _apply_tmdb_view(display_mode)
448
449 @staticmethod
450 def _get_tmdb_metadata(mode, media_type, tmdb_id):
451 from lib.clients.tmdb.utils.utils import (
452 get_tmdb_movie_details,
453 get_tmdb_show_details,
454 )
455
456 tmdb_obj = None
457 if mode == "movies":
458 tmdb_obj = get_tmdb_movie_details(tmdb_id)
459 elif mode == "tv":
460 tmdb_obj = get_tmdb_show_details(tmdb_id)
461 elif mode == "multi":
462 if media_type == "movie":
463 tmdb_obj = get_tmdb_movie_details(tmdb_id)
464 elif media_type == "tv":
465 tmdb_obj = get_tmdb_show_details(tmdb_id)
466 else:
467 kodilog(f"Invalid media type: {media_type}", level=xbmc.LOGERROR)
468 return None
469 return tmdb_obj
470
471 @staticmethod
472 def show_trending_shows(query, mode, page):
473 set_pluging_category(translation(90028))
474 set_content_type(mode)
475 data = tmdb_get("trending_tv", page)
476 if not data or getattr(data, "total_results", 0) == 0:
477 notification("No results found")
478 return
479
480 results = getattr(data, "results", [])
481
482 directory_items = []
483 for res in results:
484 tmdb_id = getattr(res, "id", "")
485 title = getattr(res, "title", "") or getattr(res, "name", "")
486 result_media_type = getattr(res, "media_type", "") or ""
487 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
488 list_item = make_list_item(label=title)
489 set_media_infoTag(list_item, data=metadata, mode=mode)
490 item_tuple = BaseTmdbClient.add_media_directory_item(
491 list_item=list_item,
492 mode=mode,
493 title=title,
494 ids={"tmdb_id": tmdb_id},
495 media_type=result_media_type,
496 batch=True,
497 )
498 directory_items.append(item_tuple)
499 add_directory_items_batch(directory_items)
500 add_next_button("handle_tmdb_query", query=query, page=page, mode=mode)
501 end_of_directory()
502 _apply_tmdb_view("tv")
503
504 @staticmethod
505 def show_trending_movies(mode, page):
506 set_pluging_category(translation(90028))
507 set_content_type(mode)
508 data = tmdb_get("trending_movie", page)
509 if not data or getattr(data, "total_results", 0) == 0:
510 notification("No results found")
511 return
512
513 results = getattr(data, "results", [])
514
515 directory_items = []
516 for res in results:
517 tmdb_id = getattr(res, "id", "")
518 title = getattr(res, "title", "") or getattr(res, "name", "")
519 result_media_type = getattr(res, "media_type", "") or ""
520 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
521 list_item = make_list_item(label=title)
522 set_media_infoTag(list_item, data=metadata, mode=mode)
523 item_tuple = BaseTmdbClient.add_media_directory_item(
524 list_item=list_item,
525 mode=mode,
526 title=title,
527 ids={"tmdb_id": tmdb_id},
528 media_type=result_media_type,
529 batch=True,
530 )
531 directory_items.append(item_tuple)
532 add_directory_items_batch(directory_items)
533 add_next_button(
534 "handle_tmdb_query", query="tmdb_trending", page=page, mode=mode
535 )
536 end_of_directory()
537 _apply_tmdb_view("movies")
538
539 @staticmethod
540 def show_tmdb_item(params):
541 mode = params.get("mode", "")
542 submode = params.get("submode", "")
543 tmdb_id = params.get("id", "")
544 media_type = params.get("media_type", "")
545 title = params.get("title", "") or params.get("name", "")
546
547 # Adjust mode for anime
548 if mode == "anime":
549 mode = submode
550
551 tmdb_obj = TmdbClient._get_tmdb_metadata(mode, media_type, tmdb_id)
552 if not tmdb_obj:
553 return
554
555 if mode == "movies" or (mode == "multi" and media_type == "movie"):
556 TmdbClient._show_tmdb_movie(tmdb_obj, mode, title, tmdb_id)
557 elif mode == "tv" or (mode == "multi" and media_type == "tv"):
558 TmdbClient._show_tmdb_shows(tmdb_obj, mode, title, tmdb_id, media_type)
559 else:
560 notification(translation(90401))
561
562 @staticmethod
563 def _show_tmdb_movie(tmdb_obj, mode, title, tmdb_id):
564 mode = "movies" if mode == "multi" else mode
565 ids = {"tmdb_id": tmdb_id}
566 ids["imdb_id"] = tmdb_obj.get("external_ids", {}).get("imdb_id", "")
567
568 from lib.search import run_search_entry
569
570 run_search_entry({"query": title, "mode": mode, "ids": json.dumps(ids)})
571
572 @staticmethod
573 def _show_tmdb_shows(tmdb_obj, mode, title, tmdb_id, media_type):
574 mode = "tv" if mode == "multi" else mode
575 ids = {"tmdb_id": tmdb_id}
576 ids["imdb_id"] = tmdb_obj.get("external_ids", {}).get("imdb_id", "")
577 tvdb_id = tmdb_obj.get("external_ids", {}).get("tvdb_id", "") or ""
578 ids["tvdb_id"] = tvdb_id
579 number_of_seasons = tmdb_obj.get("number_of_seasons", 1) or 1
580 if number_of_seasons == 1:
581 TmdbClient.show_episodes_details(
582 tv_name=title,
583 ids=ids,
584 mode=mode,
585 media_type=media_type,
586 season=number_of_seasons,
587 )
588 else:
589 TmdbClient.show_seasons_details(ids=ids, mode=mode, media_type=media_type)
590
591 @staticmethod
592 def show_seasons_details(ids, mode, media_type):
593 set_content_type("season")
594 show_season_info(ids, mode, media_type)
595 end_of_directory()
596 _apply_tmdb_view("season")
597
598 @staticmethod
599 def show_episodes_details(tv_name, ids, mode, media_type, season):
600 set_content_type("episode")
601 show_episode_info(tv_name, season, ids, mode, media_type)
602 end_of_directory()
603 _apply_tmdb_view("episode")
604
605 @staticmethod
606 def show_popular_items(mode, page):
607 set_pluging_category(translation(90037))
608 set_content_type(mode)
609 path = "popular_shows" if mode == "tv" else "popular_movie"
610 data = tmdb_get(path, page)
611 if not data or getattr(data, "total_results", 0) == 0:
612 notification(translation(90389))
613 return
614
615 results = getattr(data, "results", [])
616 directory_items = []
617 for res in results:
618 tmdb_id = getattr(res, "id", "")
619 title = getattr(res, "title", "") or getattr(res, "name", "")
620 result_media_type = getattr(res, "media_type", "") or ""
621 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
622 list_item = make_list_item(label=title)
623 set_media_infoTag(list_item, data=metadata, mode=mode)
624 item_tuple = BaseTmdbClient.add_media_directory_item(
625 list_item=list_item,
626 mode=mode,
627 title=title,
628 ids={"tmdb_id": tmdb_id},
629 media_type=result_media_type,
630 batch=True,
631 )
632 directory_items.append(item_tuple)
633 add_directory_items_batch(directory_items)
634 add_next_button("handle_tmdb_query", query="tmdb_popular", page=page, mode=mode)
635 end_of_directory()
636 _apply_tmdb_view(mode)
637
638 @staticmethod
639 def show_airing_today_items(mode, page):
640 set_pluging_category(translation(90086))
641 set_content_type(mode)
642
643 today = datetime.now().strftime("%Y-%m-%d")
644 params = {
645 "air_date.gte": today,
646 "air_date.lte": today,
647 "without_genres": "10767,10763,10764",
648 "sort_by": "popularity.desc",
649 "page": page,
650 }
651
652 data = tmdb_get("discover_tv", params)
653 if not data or getattr(data, "total_results", 0) == 0:
654 notification(translation(90395))
655 return
656
657 results = getattr(data, "results", [])
658 directory_items = []
659 for res in results:
660 tmdb_id = getattr(res, "id", "")
661 title = getattr(res, "name", "")
662 media_type = getattr(res, "media_type", "") or ""
663 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
664 list_item = make_list_item(label=title)
665 set_media_infoTag(list_item, data=metadata, mode=mode)
666 item_tuple = BaseTmdbClient.add_media_directory_item(
667 list_item=list_item,
668 mode=mode,
669 title=title,
670 ids={"tmdb_id": tmdb_id},
671 media_type=media_type,
672 batch=True,
673 )
674 directory_items.append(item_tuple)
675 add_directory_items_batch(directory_items)
676 add_next_button(
677 "handle_tmdb_query", query="tmdb_airing_today", page=page, mode=mode
678 )
679 end_of_directory()
680 _apply_tmdb_view(mode)
681
682 @staticmethod
683 def show_languages(mode, page):
684 from lib.clients.tmdb.utils.utils import FULL_NAME_LANGUAGES
685
686 set_pluging_category(translation(90065))
687 directory_items = []
688 for lang in FULL_NAME_LANGUAGES:
689 list_item = make_list_item(label=lang["name"])
690 list_item.setArt(
691 {
692 "icon": os.path.join(ADDON_PATH, "resources", "img", "lang.png"),
693 }
694 )
695 item = add_kodi_dir_item(
696 list_item=list_item,
697 url=build_url(
698 "search_tmdb_lang",
699 mode=mode,
700 lang=lang["id"],
701 page=page,
702 ),
703 batch=True,
704 )
705 directory_items.append(item)
706 add_directory_items_batch(directory_items)
707 end_of_directory()
708 _apply_tmdb_view("main")
709
710 @staticmethod
711 def show_lang_items(params):
712 lang = params.get("lang")
713 set_pluging_category(lang)
714
715 mode = params.get("mode")
716 set_content_type(mode)
717 page = int(params.get("page", 1))
718
719 route_map = {
720 "movies": "discover_movie",
721 "tv": "discover_tv",
722 }
723
724 path = route_map.get(mode)
725 if not path:
726 notification(translation(90390))
727 return
728
729 route_params = {"with_original_language": lang, "page": page}
730
731 data = tmdb_get(path=path, params=route_params)
732 if not data or getattr(data, "total_results", 0) == 0:
733 notification(translation(90389))
734 return
735
736 results = getattr(data, "results", [])
737 directory_items = []
738 for res in results:
739 tmdb_id = getattr(res, "id", "")
740 title = getattr(res, "title", "") or getattr(res, "name", "")
741 result_media_type = getattr(res, "media_type", "") or ""
742 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
743 list_item = make_list_item(label=title)
744 set_media_infoTag(list_item, data=metadata, mode=mode)
745 item_tuple = BaseTmdbClient.add_media_directory_item(
746 list_item=list_item,
747 mode=mode,
748 title=title,
749 ids={"tmdb_id": tmdb_id},
750 media_type=result_media_type,
751 batch=True,
752 )
753 directory_items.append(item_tuple)
754 add_directory_items_batch(directory_items)
755
756 add_next_button(
757 "search_tmdb_lang",
758 mode=mode,
759 lang=lang,
760 page=page,
761 )
762
763 end_of_directory()
764 _apply_tmdb_view(mode)
765
766 @staticmethod
767 def show_networks(mode, page):
768 from lib.clients.tmdb.utils.utils import NETWORKS
769
770 set_pluging_category(translation(90066))
771 directory_items = []
772 for network in NETWORKS:
773 list_item = make_list_item(label=network["name"])
774 list_item.setArt(
775 {
776 "icon": network["icon"],
777 "thumb": network["icon"],
778 "poster": network["icon"],
779 }
780 )
781 item = add_kodi_dir_item(
782 list_item=list_item,
783 url=build_url(
784 "search_tmbd_network",
785 mode=mode,
786 id=network["id"],
787 page=page,
788 ),
789 batch=True,
790 )
791 directory_items.append(item)
792 add_directory_items_batch(directory_items)
793 end_of_directory()
794 _apply_tmdb_view("main")
795
796 @staticmethod
797 def show_network_items(params):
798 from lib.clients.tmdb.utils.utils import NETWORKS
799
800 network_id = int(params.get("id"))
801 network_name = next(
802 (net["name"] for net in NETWORKS if net["id"] == network_id), ""
803 )
804 set_pluging_category(network_name)
805
806 mode = params.get("mode")
807 set_content_type(mode)
808
809 page = int(params.get("page", 1))
810
811 route_map = {
812 "movies": "discover_movie",
813 "tv": "discover_tv",
814 }
815
816 path = route_map.get(mode)
817 if not path:
818 notification(translation(90390))
819 return
820
821 route_params = {"page": page}
822 if mode == "tv":
823 route_params["with_networks"] = network_id
824 elif mode == "movies":
825 route_params["with_companies"] = network_id
826
827 data = tmdb_get(path=path, params=route_params)
828 if not data or getattr(data, "total_results", 0) == 0:
829 notification(translation(90389))
830 return
831
832 results = getattr(data, "results", [])
833 directory_items = []
834 for res in results:
835 tmdb_id = getattr(res, "id", "")
836 title = getattr(res, "title", "") or getattr(res, "name", "")
837 result_media_type = getattr(res, "media_type", "") or ""
838 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
839 list_item = make_list_item(label=title)
840 set_media_infoTag(list_item, data=metadata, mode=mode)
841 item_tuple = BaseTmdbClient.add_media_directory_item(
842 list_item=list_item,
843 mode=mode,
844 title=title,
845 ids={"tmdb_id": tmdb_id},
846 media_type=result_media_type,
847 batch=True,
848 )
849 directory_items.append(item_tuple)
850 add_directory_items_batch(directory_items)
851
852 add_next_button(
853 "search_tmbd_network",
854 mode=mode,
855 id=network_id,
856 page=page,
857 )
858 end_of_directory()
859 _apply_tmdb_view(mode)
860
861 @staticmethod
862 def show_calendar_items(query, page, mode):
863 set_pluging_category(translation(90021))
864 trending_data = tmdb_get("tv_week", page)
865 if not trending_data or getattr(trending_data, "total_results") == 0:
866 notification(translation(90394))
867 end_of_directory()
868 return
869
870 results = []
871
872 def fetch_episodes_for_trending_show(show):
873 tmdb_id = getattr(show, "id", None)
874 if not tmdb_id:
875 return
876 ids = {"tmdb_id": tmdb_id}
877 episodes, details = get_episodes_for_show(ids)
878 for ep in episodes:
879 air_date = ep.get("air_date")
880 if air_date and is_this_week(air_date):
881 results.append((getattr(show, "name", ""), show, ep, details))
882
883 execute_thread_pool(
884 getattr(trending_data, "results"), fetch_episodes_for_trending_show
885 )
886
887 directory_items = []
888
889 # Add fixed item showing current date at the top
890 current_date = datetime.now().strftime("%A, %d %B %Y")
891 date_item = make_list_item(
892 label=f"[UPPERCASE][COLOR=orange]Today: {current_date}[/COLOR][/UPPERCASE]"
893 )
894 date_item.setArt(
895 {"icon": os.path.join(ADDON_PATH, "resources", "img", "history.png")}
896 )
897 item = add_kodi_dir_item(date_item, "", is_folder=False, batch=True)
898 directory_items.append(item)
899
900 today_str = datetime.now().strftime("%Y-%m-%d")
901
902 results_today = [r for r in results if r[2].get("air_date") == today_str]
903 results_other = [r for r in results if r[2].get("air_date") != today_str]
904
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", ""))
908
909 for title, show, ep, details in results:
910 tv_data = {"name": title, "episode": ep["number"], "season": ep["season"]}
911
912 air_date_obj = parse_date_str(ep["air_date"])
913 weekday_name = air_date_obj.strftime("%A")
914 weekday_name_translated = translate_weekday(weekday_name)
915
916 # Mark if episode is released today
917 is_today = ep["air_date"] == today_str
918 mark = (
919 f"[UPPERCASE][COLOR=orange]TODAY- [/COLOR][/UPPERCASE]"
920 if is_today
921 else ""
922 )
923
924 ep_title = f"{mark}{weekday_name_translated} - ({ep['air_date']}) - {title} - S{ep['season']:02}E{ep['number']:02}"
925
926 tmdb_id = getattr(show, "id")
927 if details is None:
928 kodilog(f"Show details not found for TMDB ID: {tmdb_id}")
929 continue
930
931 external_ids = getattr(details, "external_ids")
932 imdb_id = external_ids.get("imdb_id", "")
933 tvdb_id = external_ids.get("tvdb_id", "")
934
935 ids = {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id}
936
937 list_item = make_list_item(label=ep_title)
938 list_item.setProperty("IsPlayable", "true")
939
940 set_media_infoTag(list_item, data=details, mode="tv")
941
942 item = add_kodi_dir_item(
943 list_item=list_item,
944 url=build_url(
945 "search",
946 mode="tv",
947 media_type="tv",
948 query=title,
949 ids=ids,
950 tv_data=tv_data,
951 ),
952 is_folder=False,
953 batch=True,
954 )
955 directory_items.append(item)
956
957 add_directory_items_batch(directory_items)
958
959 if getattr(trending_data, "total_pages", 0) > page:
960 add_next_button(
961 "handle_tmdb_query",
962 query=query,
963 page=page + 1,
964 mode=mode,
965 )
966 end_of_directory()
967 _apply_tmdb_view("tv")
968
969 @staticmethod
970 def show_collections_menu(mode):
971 set_pluging_category(translation(90067))
972 collections_menu = [
973 (translation(90376), "search", "search.png"),
974 (translation(90377), "popular", "tmdb.png"),
975 (translation(90378), "top_rated", "tmdb.png"),
976 ]
977
978 directory_items = []
979 for label, submode, icon_path in collections_menu:
980 list_item = make_list_item(label=label)
981 item = add_kodi_dir_item(
982 list_item=list_item,
983 url=build_url(
984 "handle_collection_query",
985 mode=mode,
986 submode=submode,
987 page=1,
988 ),
989 is_folder=True,
990 icon_path=icon_path,
991 batch=True,
992 )
993 directory_items.append(item)
994 add_directory_items_batch(directory_items)
995 end_of_directory()
996 _apply_tmdb_view("movies")
997
998 @staticmethod
999 def handle_collection_query(params):
1000 mode = params.get("mode")
1001 submode = params.get("submode")
1002 page = int(params.get("page", 1))
1003
1004 set_content_type(mode)
1005 from lib.clients.tmdb.collections import TmdbCollections
1006
1007 if submode == "popular":
1008 TmdbCollections.get_popular_collections(mode, page)
1009 elif submode == "top_rated":
1010 TmdbCollections.get_top_rated_collections(mode, page)
1011 elif submode == "search":
1012 TmdbCollections.search_collections(mode, page)
1013 else:
1014 notification(translation(90396))
1015
1016 @staticmethod
1017 def show_keywords_items(query, page, mode):
1018 keywords_data = Search().keywords(query, page=page)
1019 if not keywords_data or len(keywords_data) == 0:
1020 notification(translation(90397))
1021 end_of_directory()
1022 return
1023
1024 directory_items = []
1025 for keyword in keywords_data:
1026 if isinstance(keyword, AsObj):
1027 keyword_id = keyword.get("id")
1028 keyword_name = keyword.get("name")
1029 if not keyword_id or not keyword_name:
1030 continue
1031
1032 list_item = make_list_item(label=keyword_name)
1033 item = add_kodi_dir_item(
1034 list_item=list_item,
1035 url=build_url(
1036 "show_keyword_results",
1037 mode=mode,
1038 keyword_id=keyword_id,
1039 keyword_name=keyword_name,
1040 page=1,
1041 ),
1042 is_folder=True,
1043 icon_path="tmdb.png",
1044 batch=True,
1045 )
1046 directory_items.append(item)
1047 add_directory_items_batch(directory_items)
1048 add_next_button("handle_keyword_search", query=query, page=page + 1, mode=mode)
1049 end_of_directory()
1050 _apply_tmdb_view("main")
1051
1052 @staticmethod
1053 def handle_keyword_search(params):
1054 mode = params.get("mode", "multi")
1055 query = params.get("query", "")
1056 page = int(params.get("page", 1))
1057
1058 if not query:
1059 query = show_keyboard(id=30243, default="")
1060
1061 if not query:
1062 end_of_directory(cache=False)
1063 return
1064
1065 TmdbClient.show_keywords_items(query, page, mode)
1066
1067 @staticmethod
1068 def show_keyword_results(params):
1069 from lib.clients.tmdb.utils.utils import tmdb_get
1070
1071 keyword_id = params.get("keyword_id")
1072 keyword_name = params.get("keyword_name", "")
1073 page = int(params.get("page", 1))
1074
1075 if not keyword_id:
1076 notification(translation(90398))
1077 return
1078
1079 set_pluging_category(translation(90381) % keyword_name)
1080 set_content_type(
1081 "movies"
1082 ) # Using movies content type ensures better poster rendering in many skins
1083
1084 def fetch_movies():
1085 return tmdb_get(
1086 "discover_movie", {"with_keywords": keyword_id, "page": page}
1087 )
1088
1089 def fetch_tv():
1090 return tmdb_get("discover_tv", {"with_keywords": keyword_id, "page": page})
1091
1092 results = []
1093 total_pages = 0
1094
1095 with ThreadPoolExecutor(max_workers=2) as executor:
1096 future_movies = executor.submit(fetch_movies)
1097 future_tv = executor.submit(fetch_tv)
1098
1099 movie_data = future_movies.result()
1100 if movie_data and getattr(movie_data, "results", None):
1101 for item in movie_data.results:
1102 if isinstance(item, dict):
1103 item["media_type"] = "movie"
1104 else:
1105 setattr(item, "media_type", "movie")
1106 results.extend(movie_data.results)
1107 total_pages = max(total_pages, getattr(movie_data, "total_pages", 0))
1108
1109 tv_data = future_tv.result()
1110 if tv_data and getattr(tv_data, "results", None):
1111 for item in tv_data.results:
1112 if isinstance(item, dict):
1113 item["media_type"] = "tv"
1114 else:
1115 setattr(item, "media_type", "tv")
1116 results.extend(tv_data.results)
1117 total_pages = max(total_pages, getattr(tv_data, "total_pages", 0))
1118
1119 if not results:
1120 notification(translation(90399))
1121 end_of_directory()
1122 return
1123
1124 # Sort combined results by popularity
1125 results.sort(
1126 key=lambda x: (
1127 getattr(x, "popularity", x.get("popularity", 0))
1128 if isinstance(x, dict)
1129 else getattr(x, "popularity", 0)
1130 ),
1131 reverse=True,
1132 )
1133
1134 directory_items = []
1135 for res in results:
1136 tmdb_id = (
1137 getattr(res, "id", "")
1138 if not isinstance(res, dict)
1139 else res.get("id", "")
1140 )
1141 title = (
1142 getattr(res, "title", "") or getattr(res, "name", "")
1143 if not isinstance(res, dict)
1144 else res.get("title", "") or res.get("name", "")
1145 )
1146 media_type = (
1147 getattr(res, "media_type", "")
1148 if not isinstance(res, dict)
1149 else res.get("media_type", "")
1150 )
1151
1152 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, media_type or "multi")
1153 list_item = make_list_item(label=title)
1154 set_media_infoTag(list_item, data=metadata, mode="multi")
1155 item_tuple = BaseTmdbClient.add_media_directory_item(
1156 list_item=list_item,
1157 mode="multi",
1158 title=title,
1159 ids={"tmdb_id": tmdb_id},
1160 media_type=media_type,
1161 batch=True,
1162 )
1163 directory_items.append(item_tuple)
1164 add_directory_items_batch(directory_items)
1165
1166 if total_pages > page:
1167 add_next_button(
1168 "show_keyword_results",
1169 keyword_id=keyword_id,
1170 keyword_name=keyword_name,
1171 page=page + 1,
1172 )
1173 end_of_directory()
1174 _apply_tmdb_view("main", content_type="movies")
1175
1176 @staticmethod
1177 def search_tmdb_recommendations(params):
1178 ids = json.loads(params.get("ids", "{}"))
1179 mode = params.get("mode", "tv")
1180 media_type = params.get("media_type", "")
1181 tmdb_id = ids.get("tmdb_id")
1182 page = int(params.get("page", 1))
1183
1184 if not tmdb_id:
1185 notification(translation(90400))
1186 return
1187
1188 effective_mode = mode
1189 if mode == "multi":
1190 if media_type == "movie":
1191 effective_mode = "movies"
1192 elif media_type == "tv":
1193 effective_mode = "tv"
1194
1195 set_pluging_category(translation(90379))
1196 set_content_type(effective_mode)
1197
1198 if effective_mode == "tv":
1199 data = tmdb_get("tv_recommendations", {"id": tmdb_id, "page": page})
1200 elif effective_mode == "movies":
1201 data = tmdb_get("movie_recommendations", {"id": tmdb_id, "page": page})
1202 else:
1203 notification(translation(90390))
1204 return
1205
1206 if not data or getattr(data, "total_results", 0) == 0:
1207 notification(translation(90391))
1208 end_of_directory()
1209 return
1210
1211 results = getattr(data, "results", [])
1212
1213 directory_items = []
1214 for res in results:
1215 tmdb_id = getattr(res, "id", "")
1216 title = getattr(res, "title", "") or getattr(res, "name", "")
1217 result_media_type = getattr(res, "media_type", "") or ""
1218 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
1219 list_item = make_list_item(label=title)
1220 set_media_infoTag(list_item, data=metadata, mode=mode)
1221 item_tuple = BaseTmdbClient.add_media_directory_item(
1222 list_item=list_item,
1223 mode=mode,
1224 title=title,
1225 ids={"tmdb_id": tmdb_id},
1226 media_type=result_media_type,
1227 batch=True,
1228 )
1229 directory_items.append(item_tuple)
1230 add_directory_items_batch(directory_items)
1231
1232 if getattr(data, "total_pages", 0) > page:
1233 add_next_button(
1234 "search_tmdb_recommendations",
1235 ids=ids,
1236 mode=mode,
1237 media_type=media_type,
1238 page=page + 1,
1239 )
1240 end_of_directory()
1241 _apply_tmdb_view(effective_mode)
1242
1243 @staticmethod
1244 def search_tmdb_similar(params):
1245 ids = json.loads(params.get("ids", "{}"))
1246 mode = params.get("mode", "tv")
1247 media_type = params.get("media_type", "")
1248 tmdb_id = ids.get("tmdb_id")
1249 page = int(params.get("page", 1))
1250
1251 if not tmdb_id:
1252 notification(translation(90400))
1253 return
1254
1255 effective_mode = mode
1256 if mode == "multi":
1257 if media_type == "movie":
1258 effective_mode = "movies"
1259 elif media_type == "tv":
1260 effective_mode = "tv"
1261
1262 set_pluging_category(translation(90380))
1263 set_content_type(effective_mode)
1264
1265 if effective_mode == "tv":
1266 data = tmdb_get("tv_similar", {"id": tmdb_id, "page": page})
1267 elif effective_mode == "movies":
1268 data = tmdb_get("movie_similar", {"id": tmdb_id, "page": page})
1269 else:
1270 notification(translation(90390))
1271 return
1272
1273 if not data or getattr(data, "total_results", 0) == 0:
1274 notification(translation(90392))
1275 end_of_directory()
1276 return
1277
1278 results = getattr(data, "results", [])
1279
1280 directory_items = []
1281 for res in results:
1282 tmdb_id = getattr(res, "id", "")
1283 title = getattr(res, "title", "") or getattr(res, "name", "")
1284 result_media_type = getattr(res, "media_type", "") or ""
1285 metadata = TmdbClient._get_cached_tmdb_item_metadata(res, mode)
1286 list_item = make_list_item(label=title)
1287 set_media_infoTag(list_item, data=metadata, mode=mode)
1288 item_tuple = BaseTmdbClient.add_media_directory_item(
1289 list_item=list_item,
1290 mode=mode,
1291 title=title,
1292 ids={"tmdb_id": tmdb_id},
1293 media_type=result_media_type,
1294 batch=True,
1295 )
1296 directory_items.append(item_tuple)
1297 add_directory_items_batch(directory_items)
1298
1299 if getattr(data, "total_pages", 0) > page:
1300 add_next_button(
1301 "search_tmdb_similar",
1302 ids=ids,
1303 mode=mode,
1304 media_type=media_type,
1305 page=page + 1,
1306 )
1307 end_of_directory()
1308 _apply_tmdb_view(effective_mode)
1309
1310 @staticmethod
1311 def rescrape_tmdb_media(params):
1312 mode = params.get("mode", "")
1313 media_type = params.get("media_type", "")
1314 tmdb_id = params.get("tmdb_id", "")
1315 query = params.get("query", "")
1316
1317 if mode == "anime":
1318 mode = params.get("submode", mode)
1319 if mode == "multi":
1320 if media_type == "movie":
1321 mode = "movies"
1322 elif media_type == "tv":
1323 mode = "tv"
1324
1325 tmdb_obj = TmdbClient._get_tmdb_metadata(mode, media_type, tmdb_id)
1326 if not tmdb_obj:
1327 notification(translation(90393))
1328 return
1329
1330 external_ids = tmdb_obj.get("external_ids") or {}
1331 ids = {
1332 "tmdb_id": tmdb_id,
1333 "imdb_id": external_ids.get("imdb_id", ""),
1334 "tvdb_id": external_ids.get("tvdb_id", ""),
1335 }
1336
1337 from lib.search import run_search_entry
1338
1339 run_search_entry(
1340 {
1341 "query": query,
1342 "mode": mode,
1343 "ids": json.dumps(ids),
1344 "rescrape": True,
1345 "force_select": params.get("force_select", False),
1346 }
1347 )
1348
1349 @staticmethod
1350 def tmdb_search_modes(params):
1351 mode = params.get("mode", "")
1352 media_type = params.get("media_type", "")
1353 tmdb_id = params.get("tmdb_id", "")
1354 query = params.get("query", "")
1355
1356 if mode == "anime":
1357 mode = params.get("submode", mode)
1358 if mode == "multi":
1359 if media_type == "movie":
1360 mode = "movies"
1361 elif media_type == "tv":
1362 mode = "tv"
1363
1364 if mode != "movies":
1365 return
1366
1367 tmdb_obj = TmdbClient._get_tmdb_metadata(mode, media_type, tmdb_id)
1368 if not tmdb_obj:
1369 notification(translation(90393))
1370 return
1371
1372 selected = xbmcgui.Dialog().select(
1373 translation(90733),
1374 [translation(90730), translation(90731), translation(90732)],
1375 )
1376 if selected < 0:
1377 return
1378
1379 variant_map = {
1380 0: "title_year",
1381 1: "original_title",
1382 2: "original_title_year",
1383 }
1384 variant = variant_map.get(selected)
1385 if not variant:
1386 return
1387
1388 edited_query = show_keyboard(id=90736, default=query)
1389 if not edited_query:
1390 return
1391 query = str(edited_query).strip()
1392 if not query:
1393 return
1394
1395 year = ""
1396 if variant in ("title_year", "original_title_year"):
1397 year_input = xbmcgui.Dialog().numeric(0, translation(90734), "")
1398 if not year_input:
1399 return
1400
1401 year_input = str(year_input).strip()
1402 if not (year_input.isdigit() and len(year_input) == 4):
1403 notification(translation(90735))
1404 return
1405
1406 year = year_input
1407
1408 external_ids = tmdb_obj.get("external_ids") or {}
1409 ids = {
1410 "tmdb_id": tmdb_id,
1411 "imdb_id": external_ids.get("imdb_id", ""),
1412 "tvdb_id": external_ids.get("tvdb_id", ""),
1413 }
1414
1415 kodilog(
1416 f"TMDB search mode selected: query={query}, tmdb_id={tmdb_id}, variant={variant}, year={year}"
1417 )
1418
1419 from lib.search import run_search_entry
1420
1421 run_search_entry(
1422 {
1423 "query": query,
1424 "mode": mode,
1425 "ids": json.dumps(ids),
1426 "rescrape": True,
1427 "force_select": True,
1428 "search_variant": variant,
1429 "year": year,
1430 }
1431 )
1432
1433 @staticmethod
1434 def tmdb_episode_search_modes(params):
1435 mode = params.get("mode", "")
1436 media_type = params.get("media_type", "")
1437 query = params.get("query", "")
1438 ids = json.loads(params.get("ids", "{}"))
1439 tv_data = json.loads(params.get("tv_data", "{}"))
1440 tmdb_id = ids.get("tmdb_id", "")
1441
1442 if mode == "anime":
1443 mode = params.get("submode", mode)
1444 if mode == "multi":
1445 if media_type == "movie":
1446 mode = "movies"
1447 elif media_type == "tv":
1448 mode = "tv"
1449
1450 if mode != "tv":
1451 return
1452
1453 tmdb_obj = TmdbClient._get_tmdb_metadata(mode, media_type, tmdb_id)
1454 if not tmdb_obj:
1455 notification(translation(90393))
1456 return
1457
1458 original_name = getattr(tmdb_obj, "original_name", "") or ""
1459
1460 selected = xbmcgui.Dialog().select(
1461 translation(90733),
1462 [translation(90740), translation(90741)],
1463 )
1464 if selected < 0:
1465 return
1466
1467 if selected == 0:
1468 edited_query = show_keyboard(id=90736, default=query)
1469 if not edited_query:
1470 return
1471 query = str(edited_query).strip()
1472 elif selected == 1:
1473 if original_name:
1474 query = original_name
1475 edited_query = show_keyboard(id=90736, default=query)
1476 if not edited_query:
1477 return
1478 query = str(edited_query).strip()
1479 else:
1480 return
1481
1482 if not query:
1483 return
1484
1485 season_default = str(tv_data.get("season", ""))
1486 season_input = xbmcgui.Dialog().numeric(0, translation(90512), season_default)
1487 if not season_input:
1488 return
1489 season_input = str(season_input).strip()
1490 if not season_input.isdigit():
1491 notification(translation(90737))
1492 return
1493
1494 episode_default = str(tv_data.get("episode", ""))
1495 episode_input = xbmcgui.Dialog().numeric(
1496 0, translation(90738), episode_default
1497 )
1498 if not episode_input:
1499 return
1500 episode_input = str(episode_input).strip()
1501 if not episode_input.isdigit():
1502 notification(translation(90739))
1503 return
1504
1505 updated_tv_data = {
1506 "name": tv_data.get("name", ""),
1507 "season": int(season_input),
1508 "episode": int(episode_input),
1509 }
1510
1511 kodilog(
1512 f"TMDB episode search mode selected: query={query}, season={season_input}, episode={episode_input}, ids={ids}"
1513 )
1514
1515 from lib.search import run_search_entry
1516
1517 run_search_entry(
1518 {
1519 "query": query,
1520 "mode": mode,
1521 "ids": json.dumps(ids),
1522 "tv_data": json.dumps(updated_tv_data),
1523 "rescrape": True,
1524 "force_select": True,
1525 }
1526 )
plugin.video.jacktook/lib/clients/tmdb/trailers.py +140 -0
……@@ -0,0 +1,140 @@
1import os
2from typing import Iterable, Optional
3
4from lib.api.tmdbv3api.objs.movie import Movie
5from lib.api.tmdbv3api.objs.tv import TV
6from lib.utils.kodi.utils import kodilog
7
8
9YOUTUBE_WATCH_URL = "https://www.youtube.com/watch?v={}"
10
11
12def _normalize_media_type(media_type: Optional[str]) -> Optional[str]:
13 if not media_type:
14 return None
15
16 normalized = str(media_type).strip().lower()
17 if normalized in ("movie", "movies"):
18 return "movie"
19 if normalized in ("tv", "show", "shows", "series"):
20 return "tv"
21 return None
22
23
24def choose_tmdb_trailer(videos: Optional[Iterable[dict]]) -> Optional[dict]:
25 candidates = []
26 for index, video in enumerate(videos or []):
27 if (video.get("site") or "") != "YouTube":
28 continue
29
30 trailer_type = (video.get("type") or "").strip().lower()
31 if trailer_type == "trailer":
32 type_rank = 0
33 elif trailer_type == "teaser":
34 type_rank = 1
35 elif trailer_type == "clip":
36 type_rank = 2
37 else:
38 continue
39
40 yt_id = video.get("key")
41 if not yt_id:
42 continue
43
44 candidates.append(
45 (
46 type_rank,
47 0 if bool(video.get("official")) else 1,
48 index,
49 yt_id,
50 )
51 )
52
53 if not candidates:
54 return None
55
56 _, _, _, yt_id = min(candidates)
57 return {
58 "yt_id": yt_id,
59 "youtube_url": YOUTUBE_WATCH_URL.format(yt_id),
60 }
61
62
63def _normalize_videos(videos):
64 if videos is None:
65 return []
66 if isinstance(videos, dict):
67 return videos.get("results") or []
68
69 results = getattr(videos, "results", None)
70 if results is not None:
71 return list(results)
72
73 return list(videos)
74
75
76def _tmdb_language_attempts():
77 current = os.environ.get("TMDB_LANGUAGE") or "en-US"
78 attempts = []
79
80 def add(language, include_video_language=None):
81 candidate = (language, include_video_language)
82 if candidate not in attempts:
83 attempts.append(candidate)
84
85 add(current, f"{current},null")
86 add("en-US", "en-US,null")
87 add(current, None)
88 add("en-US", None)
89 return attempts
90
91
92def resolve_tmdb_trailer(tmdb_id, media_type: Optional[str]) -> Optional[dict]:
93 normalized_media_type = _normalize_media_type(media_type)
94 if not tmdb_id or not normalized_media_type:
95 kodilog(
96 f"Trailer/TMDB: skipped lookup tmdb_id={tmdb_id!r} media_type={media_type!r} normalized={normalized_media_type!r}"
97 )
98 return None
99
100 trailer = None
101 last_error = None
102
103 for language, include_video_language in _tmdb_language_attempts():
104 try:
105 if normalized_media_type == "movie":
106 client = Movie()
107 client.language = language
108 videos = client.videos(tmdb_id, page=1)
109 else:
110 client = TV()
111 client.language = language
112 videos = client.videos(
113 tmdb_id,
114 include_video_language=include_video_language,
115 page=1,
116 )
117 except Exception as exc:
118 last_error = exc
119 continue
120
121 videos = _normalize_videos(videos)
122 trailer = choose_tmdb_trailer(videos)
123 if trailer:
124 break
125
126 if last_error and not trailer:
127 kodilog(
128 f"Trailer/TMDB: lookup failed tmdb_id={tmdb_id!r} media_type={normalized_media_type!r} error={last_error}"
129 )
130
131 if not trailer:
132 kodilog(
133 f"Trailer/TMDB: no youtube trailer found tmdb_id={tmdb_id!r} media_type={normalized_media_type!r}"
134 )
135 return None
136
137 kodilog(
138 f"Trailer/TMDB: selected yt_id={trailer.get('yt_id')!r} tmdb_id={tmdb_id!r} media_type={normalized_media_type!r}"
139 )
140 return trailer
plugin.video.jacktook/lib/clients/tmdb/utils/collections_utils.py +157 -0
……@@ -0,0 +1,157 @@
1TOP_RATED_COLLECTIONS = [
2 {"id": "119", "type": "movie", "name": "The Lord of the Rings Collection"},
3 {"id": "230", "type": "movie", "name": "The Godfather Collection"},
4 {"id": "263", "type": "movie", "name": "The Dark Knight Collection"},
5 {"id": "573436", "type": "movie", "name": "Spider-Man: Spider-Verse Collection"},
6 {"id": "726871", "type": "movie", "name": "Dune Collection"},
7 {"id": "2883", "type": "movie", "name": "Kill Bill Collection"},
8 {"id": "422837", "type": "movie", "name": "Blade Runner Collection"},
9 {"id": "86311", "type": "movie", "name": "The Avengers Collection"},
10 {"id": "10194", "type": "movie", "name": "Toy Story Collection"},
11 {"id": "264", "type": "movie", "name": "Back to the Future Collection"},
12 {"id": "530064", "type": "movie", "name": "The Shining Collection"},
13 {"id": "1022790", "type": "movie", "name": "Inside Out Collection"},
14 {"id": "284433", "type": "movie", "name": "Guardians of the Galaxy Collection"},
15 {"id": "89137", "type": "movie", "name": "How to Train Your Dragon Collection"},
16 {"id": "468222", "type": "movie", "name": "The Incredibles Collection"},
17 {"id": "1241", "type": "movie", "name": "Harry Potter Collection"},
18 {"id": "531241", "type": "movie", "name": "Spider-Man (MCU) Collection"},
19 {"id": "137696", "type": "movie", "name": "Monsters, Inc. Collection"},
20 {"id": "137697", "type": "movie", "name": "Finding Nemo Collection"},
21 {"id": "448150", "type": "movie", "name": "Deadpool Collection"},
22 {"id": "87096", "type": "movie", "name": "Avatar Collection"},
23 {"id": "121938", "type": "movie", "name": "The Hobbit Collection"},
24 {"id": "10", "type": "movie", "name": "Star Wars Collection"},
25 {"id": "9743", "type": "movie", "name": "The Hannibal Lecter Collection"},
26 {"id": "1069584", "type": "movie", "name": "Gladiator Collection"},
27 {"id": "722971", "type": "movie", "name": "Knives Out Collection"},
28 {"id": "404609", "type": "movie", "name": "John Wick Collection"},
29 {"id": "102322", "type": "movie", "name": "Sherlock Holmes Collection"},
30 {"id": "8091", "type": "movie", "name": "Alien Collection"},
31 {"id": "84", "type": "movie", "name": "Indiana Jones Collection"},
32 {"id": "131292", "type": "movie", "name": "Iron Man Collection"},
33 {"id": "521226", "type": "movie", "name": "A Quiet Place Collection"},
34 {"id": "94032", "type": "movie", "name": "The Lion King Collection"},
35 {"id": "8945", "type": "movie", "name": "Mad Max Collection"},
36 {"id": "618529", "type": "movie", "name": "Doctor Strange Collection"},
37 {"id": "453993", "type": "movie", "name": "The Wolverine Collection"},
38 {"id": "295", "type": "movie", "name": "Pirates of the Caribbean Collection"},
39 {"id": "1241984", "type": "movie", "name": "Moana Collection"},
40 {"id": "748", "type": "movie", "name": "X-Men Collection"},
41 {"id": "556", "type": "movie", "name": "Spider-Man Collection"},
42 {"id": "386382", "type": "movie", "name": "Frozen Collection"},
43 {"id": "2344", "type": "movie", "name": "The Matrix Collection"},
44 {"id": "528", "type": "movie", "name": "The Terminator Collection"},
45 {"id": "131295", "type": "movie", "name": "Captain America Collection"},
46 {"id": "131296", "type": "movie", "name": "Thor Collection"},
47 {"id": "2150", "type": "movie", "name": "Shrek Collection"},
48 {"id": "391860", "type": "movie", "name": "Kingsman Collection"},
49 {"id": "131635", "type": "movie", "name": "The Hunger Games Collection"},
50 {"id": "529892", "type": "movie", "name": "Black Panther Collection"},
51 {"id": "477962", "type": "movie", "name": "It Collection"},
52 {"id": "125570", "type": "movie", "name": "300 Collection"},
53 {"id": "987044", "type": "movie", "name": "Joker Collection"},
54 {"id": "382685", "type": "movie", "name": "Now You See Me Collection"},
55 {"id": "645", "type": "movie", "name": "James Bond Collection"},
56 {"id": "86066", "type": "movie", "name": "Despicable Me Collection"},
57 {"id": "86055", "type": "movie", "name": "Men In Black"},
58]
59
60
61POPULAR_COLLECTIONS = [
62 {"id": 1449595, "name": "Ne Zha Collection"},
63 {"id": 1211317, "name": "Creation of the Gods Trilogy"},
64 {"id": 328, "name": "Jurassic Park Collection"},
65 {"id": 1071588, "name": "M3GAN Collection"},
66 {"id": 1565, "name": "28 Days/Weeks Later Collection"},
67 {"id": 1231053, "name": "The Bad Guys Collection"},
68 {"id": 1129084, "name": "Nobody Collection"},
69 {"id": 1156666, "name": "Fault Collection"},
70 {"id": 8580, "name": "The Karate Kid Collection"},
71 {"id": 870339, "name": "The Accountant Collection"},
72 {"id": 1022790, "name": "Inside Out Collection"},
73 {"id": 925155, "name": "Demon Slayer: Kimetsu no Yaiba Collection"},
74 {"id": 8864, "name": "Final Destination Collection"},
75 {"id": 87096, "name": "Avatar Collection"},
76 {"id": 1241984, "name": "Moana Collection"},
77 {"id": 531241, "name": "Spider-Man (MCU) Collection"},
78 {"id": 86311, "name": "The Avengers Collection"},
79 {"id": 87359, "name": "Mission: Impossible Collection"},
80 {"id": 1241, "name": "Harry Potter Collection"},
81 {"id": 762512, "name": "The Lion King (Reboot) Collection"},
82 {"id": 448150, "name": "Deadpool Collection"},
83 {"id": 720879, "name": "Sonic the Hedgehog Collection"},
84 {"id": 119, "name": "The Lord of the Rings Collection"},
85 {"id": 313086, "name": "The Conjuring Collection"},
86 {"id": 89137, "name": "How to Train Your Dragon Collection"},
87 {"id": 573436, "name": "Spider-Man: Spider-Verse Collection"},
88 {"id": 131292, "name": "Iron Man Collection"},
89 {"id": 390636, "name": "Fate/stay night: Heaven's Feel Collection"},
90 {"id": 131295, "name": "Captain America Collection"},
91 {"id": 263, "name": "The Dark Knight Collection"},
92 {"id": 1069584, "name": "Gladiator Collection"},
93 {"id": 230, "name": "The Godfather Collection"},
94 {"id": 726871, "name": "Dune Collection"},
95 {"id": 531330, "name": "Top Gun Collection"},
96 {"id": 468552, "name": "Wonder Woman Collection"},
97 {"id": 468222, "name": "The Incredibles Collection"},
98 {"id": 295, "name": "Pirates of the Caribbean Collection"},
99 {"id": 2150, "name": "Shrek Collection"},
100 {"id": 556, "name": "Spider-Man Collection"},
101 {"id": 86066, "name": "Despicable Me Collection"},
102 {"id": 404609, "name": "John Wick Collection"},
103 {"id": 295130, "name": "The Maze Runner Collection"},
104 {"id": 77816, "name": "Kung Fu Panda Collection"},
105 {"id": 10194, "name": "Toy Story Collection"},
106 {"id": 121938, "name": "The Hobbit Collection"},
107 {"id": 618529, "name": "Doctor Strange Collection"},
108 {"id": 523855, "name": "The Equalizer Collection"},
109 {"id": 284433, "name": "Guardians of the Galaxy Collection"},
110 {"id": 386382, "name": "Frozen Collection"},
111 {"id": 420, "name": "The Chronicles of Narnia Collection"},
112 {"id": 11716, "name": "Addams Family Collection"},
113 {"id": 987044, "name": "Joker Collection"},
114 {"id": 86119, "name": "The Hangover Collection"},
115 {"id": 531242, "name": "Suicide Squad Collection"},
116 {"id": 529892, "name": "Black Panther Collection"},
117 {"id": 422837, "name": "Blade Runner Collection"},
118 {"id": 8091, "name": "Alien Collection"},
119 {"id": 135483, "name": "Taken Collection"},
120 {"id": 131296, "name": "Thor Collection"},
121 {"id": 137696, "name": "Monsters, Inc. Collection"},
122 {"id": 173710, "name": "Planet of the Apes (Reboot) Collection"},
123 {"id": 477962, "name": "It Collection"},
124 {"id": 94602, "name": "Puss in Boots Collection"},
125 {"id": 37139, "name": "Naked Gun Collection"},
126 {"id": 2344, "name": "The Matrix Collection"},
127 {"id": 553717, "name": "Creed Collection"},
128 {"id": 702624, "name": "After Collection"},
129 {"id": 528, "name": "The Terminator Collection"},
130 {"id": 619537, "name": "Train to Busan Collection"},
131 {"id": 94032, "name": "The Lion King Collection"},
132 {"id": 14890, "name": "Bad Boys Collection"},
133 {"id": 399, "name": "Predator Collection"},
134 {"id": 619802, "name": "The Roundup Collection"},
135 {"id": 137697, "name": "Finding Nemo Collection"},
136 {"id": 10, "name": "Star Wars Collection"},
137 {"id": 544670, "name": "Sing Collection"},
138 {"id": 264, "name": "Back to the Future Collection"},
139 {"id": 185103, "name": "Hotel Transylvania Collection"},
140 {"id": 87236, "name": "Mulan Collection"},
141 {"id": 8354, "name": "Ice Age Collection"},
142 {"id": 8945, "name": "Mad Max Collection"},
143 {"id": 1268789, "name": "Tangled Collection"},
144 {"id": 822306, "name": "Miraculous World"},
145 {"id": 117777, "name": "Undisputed Collection"},
146 {"id": 683698, "name": "Z-O-M-B-I-E-S Collection"},
147 {"id": 530064, "name": "The Shining Collection"},
148 {"id": 99606, "name": "Mean Girls Collection"},
149 {"id": 9485, "name": "The Fast and the Furious Collection"},
150 {"id": 153010, "name": "Beauty and the Beast Collection"},
151 {"id": 8537, "name": "Superman Collection"},
152 {"id": 55419, "name": "Cinderella Collection"},
153 {"id": 97461, "name": "Lilo & Stitch Collection"},
154 {"id": 1506082, "name": "The Vengeance Trilogy"},
155 {"id": 2366, "name": "Jaws Collection"},
156 {"id": 748, "name": "X-Men Collection"},
157]
plugin.video.jacktook/lib/clients/tmdb/utils/utils.py +693 -0
……@@ -0,0 +1,693 @@
1import json
2from datetime import datetime, timedelta
3import os
4import threading
5from typing import List, Optional
6
7from lib.api.mdblist.mdblist import MDblistAPI
8from lib.api.tmdbv3api.as_obj import AsObj
9from lib.api.tmdbv3api.objs.anime import TmdbAnime
10from lib.api.tmdbv3api.objs.collection import Collection
11from lib.api.tmdbv3api.objs.episode import Episode
12from lib.api.tmdbv3api.objs.find import Find
13from lib.api.tmdbv3api.objs.genre import Genre
14from lib.api.tmdbv3api.objs.movie import Movie
15from lib.api.tmdbv3api.objs.person import Person
16from lib.api.tmdbv3api.objs.search import Search
17from lib.api.tmdbv3api.objs.season import Season
18from lib.api.tmdbv3api.objs.discover import Discover
19from lib.api.tmdbv3api.objs.trending import Trending
20from lib.api.tmdbv3api.objs.tv import TV
21
22from lib.utils.kodi.utils import (
23 ADDON_HANDLE,
24 ADDON_PATH,
25 action_url_run,
26 container_update,
27 execute_builtin,
28 kodilog,
29 kodi_play_media,
30 translation,
31)
32from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled
33from lib.utils.general.utils import execute_thread_pool
34from urllib import parse
35
36from lib.db.cached import cache
37from xbmcplugin import addDirectoryItem
38
39
40LANGUAGES = [
41 "ar-AE",
42 "ar-SA",
43 "be-BY",
44 "bg-BG",
45 "bn-BD",
46 "ca-ES",
47 "ch-GU",
48 "cs-CZ",
49 "da-DK",
50 "de-AT",
51 "de-CH",
52 "de-DE",
53 "el-GR",
54 "en-AU",
55 "en-CA",
56 "en-GB",
57 "en-IE",
58 "en-NZ",
59 "en-US",
60 "eo-EO",
61 "es-ES",
62 "es-MX",
63 "et-EE",
64 "eu-ES",
65 "fa-IR",
66 "fi-FI",
67 "fr-CA",
68 "fr-FR",
69 "gl-ES",
70 "he-IL",
71 "hi-IN",
72 "hu-HU",
73 "id-ID",
74 "it-IT",
75 "ja-JP",
76 "ka-GE",
77 "kk-KZ",
78 "kn-IN",
79 "ko-KR",
80 "lt-LT",
81 "lv-LV",
82 "ml-IN",
83 "ms-MY",
84 "ms-SG",
85 "nb-NO",
86 "nl-NL",
87 "no-NO",
88 "pl-PL",
89 "pt-BR",
90 "pt-PT",
91 "ro-RO",
92 "ru-RU",
93 "si-LK",
94 "sk-SK",
95 "sl-SI",
96 "sr-RS",
97 "sv-SE",
98 "ta-IN",
99 "te-IN",
100 "th-TH",
101 "tl-PH",
102 "tr-TR",
103 "uk-UA",
104 "vi-VN",
105 "zh-CN",
106 "zh-HK",
107 "zh-TW",
108 "zu-ZA",
109]
110
111FULL_NAME_LANGUAGES = [
112 {"name": "Arabic", "id": "ar"},
113 {"name": "Bosnian", "id": "bs"},
114 {"name": "Bulgarian", "id": "bg"},
115 {"name": "Chinese", "id": "zh"},
116 {"name": "Croatian", "id": "hr"},
117 {"name": "Dutch", "id": "nl"},
118 {"name": "English", "id": "en"},
119 {"name": "Finnish", "id": "fi"},
120 {"name": "French", "id": "fr"},
121 {"name": "German", "id": "de"},
122 {"name": "Greek", "id": "el"},
123 {"name": "Hebrew", "id": "he"},
124 {"name": "Hindi", "id": "hi"},
125 {"name": "Hungarian", "id": "hu"},
126 {"name": "Icelandic", "id": "is"},
127 {"name": "Italian", "id": "it"},
128 {"name": "Japanese", "id": "ja"},
129 {"name": "Korean", "id": "ko"},
130 {"name": "Macedonian", "id": "mk"},
131 {"name": "Norwegian", "id": "no"},
132 {"name": "Persian", "id": "fa"},
133 {"name": "Polish", "id": "pl"},
134 {"name": "Portuguese", "id": "pt"},
135 {"name": "Punjabi", "id": "pa"},
136 {"name": "Romanian", "id": "ro"},
137 {"name": "Russian", "id": "ru"},
138 {"name": "Serbian", "id": "sr"},
139 {"name": "Slovenian", "id": "sl"},
140 {"name": "Spanish", "id": "es"},
141 {"name": "Swedish", "id": "sv"},
142 {"name": "Turkish", "id": "tr"},
143 {"name": "Ukrainian", "id": "uk"},
144]
145
146
147NETWORKS = [
148 {"id": 129, "name": "A&E", "icon": "https://i.imgur.com/xLDfHjH.png"},
149 {"id": 2, "name": "ABC", "icon": "https://i.imgur.com/qePLxos.png"},
150 {"id": 174, "name": "AMC", "icon": "https://i.imgur.com/ndorJxi.png"},
151 {"id": 2697, "name": "Acorn TV", "icon": "https://i.imgur.com/fSWB5gB.png"},
152 {"id": 80, "name": "Adult Swim", "icon": "https://i.imgur.com/jCqbRcS.png"},
153 {"id": 1024, "name": "Amazon", "icon": "https://i.imgur.com/ru9DDlL.png"},
154 {"id": 91, "name": "Animal Planet", "icon": "https://i.imgur.com/olKc4RP.png"},
155 {"id": 2552, "name": "Apple TV +", "icon": "https://i.imgur.com/fAQMVNp.png"},
156 {"id": 251, "name": "Audience", "icon": "https://i.imgur.com/5Q3mo5A.png"},
157 {"id": 4, "name": "BBC 1", "icon": "https://i.imgur.com/u8x26te.png"},
158 {"id": 332, "name": "BBC 2", "icon": "https://i.imgur.com/SKeGH1a.png"},
159 {"id": 3, "name": "BBC 3", "icon": "https://i.imgur.com/SDLeLcn.png"},
160 {"id": 100, "name": "BBC 4", "icon": "https://i.imgur.com/PNDalgw.png"},
161 {"id": 493, "name": "BBC America", "icon": "https://i.imgur.com/TUHDjfl.png"},
162 {"id": 24, "name": "BET", "icon": "https://i.imgur.com/ZpGJ5UQ.png"},
163 {"id": 74, "name": "Bravo", "icon": "https://i.imgur.com/TmEO3Tn.png"},
164 {"id": 23, "name": "CBC", "icon": "https://i.imgur.com/unQ7WCZ.png"},
165 {"id": 16, "name": "CBS", "icon": "https://i.imgur.com/8OT8igR.png"},
166 {"id": 1709, "name": "CBS All Access", "icon": "https://i.imgur.com/ZvaWMuU.png"},
167 {"id": 110, "name": "CTV", "icon": "https://i.imgur.com/qUlyVHz.png"},
168 {"id": 56, "name": "Cartoon Network", "icon": "https://i.imgur.com/zmOLbbI.png"},
169 {"id": 26, "name": "Channel 4", "icon": "https://i.imgur.com/6ZA9UHR.png"},
170 {"id": 99, "name": "Channel 5", "icon": "https://i.imgur.com/5ubnvOh.png"},
171 {"id": 359, "name": "Cinemax", "icon": "https://i.imgur.com/zWypFNI.png"},
172 {"id": 47, "name": "Comedy Central", "icon": "https://i.imgur.com/ko6XN77.png"},
173 {"id": 928, "name": "Crackle", "icon": "https://i.imgur.com/53kqZSY.png"},
174 {"id": 2243, "name": "DC Universe", "icon": "https://i.imgur.com/bhWIubn.png"},
175 {"id": 64, "name": "Discovery Channel", "icon": "https://i.imgur.com/8UrXnAB.png"},
176 {"id": 244, "name": "Discovery ID", "icon": "https://i.imgur.com/07w7BER.png"},
177 {"id": 4353, "name": "Discovery+", "icon": "https://i.imgur.com/ukz1nOG.png"},
178 {"id": 54, "name": "Disney Channel", "icon": "https://i.imgur.com/ZCgEkp6.png"},
179 {"id": 44, "name": "Disney XD", "icon": "https://i.imgur.com/PAJJoqQ.png"},
180 {"id": 2739, "name": "Disney+", "icon": "https://i.imgur.com/DVrPgbM.png"},
181 {"id": 76, "name": "E!", "icon": "https://i.imgur.com/3Delf9f.png"},
182 {"id": 136, "name": "E4", "icon": "https://i.imgur.com/frpunK8.png"},
183 {"id": 19, "name": "FOX", "icon": "https://i.imgur.com/6vc0Iov.png"},
184 {"id": 88, "name": "FX", "icon": "https://i.imgur.com/aQc1AIZ.png"},
185 {"id": 1267, "name": "Freeform", "icon": "https://i.imgur.com/f9AqoHE.png"},
186 {"id": 49, "name": "HBO", "icon": "https://i.imgur.com/Hyu8ZGq.png"},
187 {"id": 3186, "name": "HBO Max", "icon": "https://i.imgur.com/mmRMG75.png"},
188 {"id": 210, "name": "HGTV", "icon": "https://i.imgur.com/INnmgLT.png"},
189 {"id": 384, "name": "Hallmark Channel", "icon": "https://i.imgur.com/zXS64I8.png"},
190 {"id": 65, "name": "History Channel", "icon": "https://i.imgur.com/LEMgy6n.png"},
191 {"id": 453, "name": "Hulu", "icon": "https://i.imgur.com/uSD2Cdw.png"},
192 {"id": 9, "name": "ITV", "icon": "https://i.imgur.com/5Hxp5eA.png"},
193 {"id": 34, "name": "Lifetime", "icon": "https://i.imgur.com/tvYbhen.png"},
194 {"id": 33, "name": "MTV", "icon": "https://i.imgur.com/QM6DpNW.png"},
195 {"id": 6, "name": "NBC", "icon": "https://i.imgur.com/yPRirQZ.png"},
196 {
197 "id": 43,
198 "name": "National Geographic",
199 "icon": "https://i.imgur.com/XCGNKVQ.png",
200 },
201 {"id": 213, "name": "Netflix", "icon": "https://i.imgur.com/jI5c3bw.png"},
202 {"id": 35, "name": "Nick Junior", "icon": "https://i.imgur.com/leuCWYt.png"},
203 {"id": 13, "name": "Nickelodeon", "icon": "https://i.imgur.com/OUVoqYc.png"},
204 {"id": 132, "name": "Oxygen", "icon": "https://i.imgur.com/uFCQvbR.png"},
205 {"id": 14, "name": "PBS", "icon": "https://i.imgur.com/r9qeDJY.png"},
206 {
207 "id": 2076,
208 "name": "Paramount Network",
209 "icon": "https://i.imgur.com/ez3U6NV.png",
210 },
211 {"id": 4330, "name": "Paramount+", "icon": "https://i.imgur.com/dmUjWmU.png"},
212 {"id": 3353, "name": "Peacock", "icon": "https://i.imgur.com/1JXFkSM.png"},
213 {"id": 67, "name": "Showtime", "icon": "https://i.imgur.com/SawAYkO.png"},
214 {"id": 214, "name": "Sky 1", "icon": "https://i.imgur.com/xbgzhPU.png"},
215 {"id": 55, "name": "Spike", "icon": "https://i.imgur.com/BhXYytR.png"},
216 {"id": 318, "name": "Starz", "icon": "https://i.imgur.com/Z0ep2Ru.png"},
217 {"id": 270, "name": "SundanceTV", "icon": "https://i.imgur.com/qldG5p2.png"},
218 {"id": 77, "name": "Syfy", "icon": "https://i.imgur.com/9yCq37i.png"},
219 {"id": 68, "name": "TBS", "icon": "https://i.imgur.com/RVCtt4Z.png"},
220 {"id": 84, "name": "TLC", "icon": "https://i.imgur.com/c24MxaB.png"},
221 {"id": 41, "name": "TNT", "icon": "https://i.imgur.com/WnzpAGj.png"},
222 {"id": 397, "name": "TV Land", "icon": "https://i.imgur.com/1nIeDA5.png"},
223 {"id": 71, "name": "The CW", "icon": "https://i.imgur.com/Q8tooeM.png"},
224 {"id": 21, "name": "The WB", "icon": "https://i.imgur.com/rzfVME6.png"},
225 {"id": 209, "name": "Travel Channel", "icon": "https://i.imgur.com/mWXv7SF.png"},
226 {"id": 30, "name": "USA Network", "icon": "https://i.imgur.com/Doccw9E.png"},
227 {"id": 158, "name": "VH1", "icon": "https://i.imgur.com/IUtHYzA.png"},
228 {"id": 202, "name": "WGN America", "icon": "https://i.imgur.com/TL6MzgO.png"},
229 {"id": 1436, "name": "YouTube Red", "icon": "https://i.imgur.com/ZfewP1Y.png"},
230 {"id": 364, "name": "truTV", "icon": "https://i.imgur.com/HnB3zfc.png"},
231]
232
233
234def add_kodi_dir_item(
235 list_item, url, is_folder=True, icon_path=None, set_playable=False, batch=False
236):
237 if icon_path:
238 add_icon_tmdb(list_item, icon_path=icon_path)
239 if set_playable:
240 list_item.setProperty("IsPlayable", "true")
241 if batch:
242 return (url, list_item, is_folder)
243 addDirectoryItem(ADDON_HANDLE, url, list_item, isFolder=is_folder)
244
245
246def add_icon_genre(item, icon_path="tmdb.png"):
247 item.setArt(
248 {
249 "icon": os.path.join(ADDON_PATH, "resources", "img", icon_path),
250 "thumb": os.path.join(ADDON_PATH, "resources", "img", icon_path),
251 }
252 )
253
254
255def add_icon_tmdb(item, icon_path="tmdb.png"):
256 item.setArt(
257 {
258 "icon": os.path.join(ADDON_PATH, "resources", "img", icon_path),
259 "thumb": os.path.join(ADDON_PATH, "resources", "img", icon_path),
260 }
261 )
262
263
264def tmdb_get(path, params=None) -> Optional[AsObj]:
265 identifier = f"{path}|{params}"
266 data = cache.get(key=identifier)
267 if data:
268 return data
269
270 handlers = {
271 "search_tv": lambda p: Search().tv_shows(
272 term=p["query"], page=p.get("page", 1), release_year=p.get("year")
273 ),
274 "search_movie": lambda p: Search().movies(
275 term=p["query"], page=p.get("page", 1), year=p.get("year")
276 ),
277 "search_multi": lambda p: Search().multi(p["query"], page=p["page"]),
278 "search_collections": lambda p: Search().collections(p["query"], p["page"]),
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 ),
283 "tv_details": lambda p: TV().details(p["id"] if isinstance(p, dict) else p),
284 "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 ),
288 "movie_genres": lambda _: Genre().movie_list(),
289 "show_genres": lambda _: Genre().tv_list(),
290 "discover_movie": lambda p: Discover().discover_movies(p),
291 "discover_tv": lambda p: Discover().discover_tv_shows(p),
292 "tv_calendar": lambda p: Discover().discover_tv_calendar(page=p),
293 "tv_week": lambda p: Trending().tv_week(page=p),
294 "trending_movie": lambda p: Trending().movie_week(page=p),
295 "popular_movie": lambda p: Movie().popular(page=p),
296 "trending_tv": lambda p: Trending().tv_week(page=p),
297 "popular_shows": lambda p: TV().popular(page=p),
298 "airing_today": lambda p: TV().airing_today(page=p),
299 "popular_people": lambda p: Person().popular(page=p),
300 "trending_people": lambda p: Trending().person_week(page=p),
301 "latest_people": lambda p: Person().latest(),
302 "person_details": lambda p: Person().details(
303 p["id"] if isinstance(p, dict) else p
304 ),
305 "person_credits": lambda p: Person().combined_credits(
306 p["id"] if isinstance(p, dict) else p
307 ),
308 "person_tv_credits": lambda p: Person().tv_credits(
309 p["id"] if isinstance(p, dict) else p
310 ),
311 "person_movie_credits": lambda p: Person().movie_credits(
312 p["id"] if isinstance(p, dict) else p
313 ),
314 "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 ),
327 "anime_year": lambda p: TmdbAnime().anime_year(p),
328 "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 ),
338 "movie_recommendations": lambda p: Movie().recommendations(
339 movie_id=p["id"], page=p["page"]
340 ),
341 "tv_similar": lambda p: TV().similar(tv_id=p["id"], page=p["page"]),
342 "movie_similar": lambda p: Movie().similar(movie_id=p["id"], page=p["page"]),
343 "movie_images": lambda p: Movie().images(movie_id=p["id"]),
344 "tv_images": lambda p: TV().images(tv_id=p["id"]),
345 "movie_reviews": lambda p: Movie().reviews(movie_id=p["id"], page=p["page"]),
346 "tv_reviews": lambda p: TV().reviews(tv_id=p["id"], page=p["page"]),
347 }
348
349 try:
350 data = handlers.get(path, lambda _: None)(params)
351 except Exception as e:
352 kodilog(f"Error in tmdb_get for {path} with params {params}: {e}")
353 return None
354
355 if data is not None:
356 cache.set(
357 key=identifier,
358 data=data,
359 expires=timedelta(
360 hours=get_cache_expiration() if is_cache_enabled() else 0
361 ),
362 )
363
364 return data
365
366
367def mdblist_get(path, params=None) -> Optional[List]:
368 identifier = f"{path}|{params}"
369 data = cache.get(key=identifier)
370 if data:
371 return data
372
373 handlers = {
374 "get_user_lists": lambda _: MDblistAPI().get_user_lists(),
375 "get_list_items": lambda p: MDblistAPI().get_list_items(
376 list_id=p.get("list_id"),
377 limit=p.get("limit"),
378 offset=p.get("offset"),
379 append_to_response="genre,poster",
380 unified=True,
381 ),
382 "top_mdbd_lists": lambda _: MDblistAPI().get_top_lists(),
383 "search_lists": lambda p: MDblistAPI().search_lists(p["query"]),
384 }
385
386 try:
387 data = handlers.get(path, lambda _: None)(params)
388 except Exception as e:
389 kodilog(f"Error in mdblist_get for {path} with params {params}: {e}")
390 return None
391
392 if data is not None:
393 cache.set(
394 key=identifier,
395 data=data,
396 expires=timedelta(
397 hours=get_cache_expiration() if is_cache_enabled() else 0
398 ),
399 )
400
401 return data
402
403
404def get_tmdb_media_details(tmdb_id, mode):
405 if mode == "tv":
406 return tmdb_get("tv_details", tmdb_id)
407 elif mode == "movies":
408 return tmdb_get("movie_details", tmdb_id)
409
410
411def get_tmdb_movie_details(id: str) -> Optional[AsObj]:
412 return tmdb_get("movie_details", id)
413
414
415def get_tmdb_show_details(id: str) -> Optional[AsObj]:
416 return tmdb_get("tv_details", id)
417
418
419def filter_anime_by_keyword(results, mode):
420 filtered_anime = []
421 anime_api = TmdbAnime()
422 list_lock = threading.Lock()
423
424 def check_and_append(item, filtered_anime, list_lock):
425 keywords_data = anime_api.tmdb_keywords(mode, item["id"])
426 keywords = (
427 keywords_data["results"] if mode == "tv" else keywords_data["keywords"]
428 )
429 for keyword in keywords:
430 if keyword["id"] == 210024:
431 with list_lock:
432 filtered_anime.append(item)
433 break
434
435 execute_thread_pool(results["results"], check_and_append, filtered_anime, list_lock)
436 results["results"] = filtered_anime
437 results["total_results"] = len(filtered_anime)
438 return results
439
440
441def _build_trailer_ids(ids, title=None, title_key="title"):
442 trailer_ids = dict(ids or {})
443 if title and not trailer_ids.get(title_key):
444 trailer_ids[title_key] = title
445 return trailer_ids
446
447
448def build_play_trailer_context_menu_item(ids, media_type, title=None, title_key="title"):
449 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 ):
453 return None
454
455 params = {
456 "name": "play_trailer",
457 "media_type": media_type,
458 "tmdb_id": trailer_ids.get("tmdb_id"),
459 "title": trailer_ids.get(title_key),
460 }
461 if trailer_ids.get("trailer_yt_id"):
462 params["yt_id"] = trailer_ids.get("trailer_yt_id")
463 if trailer_ids.get("trailer_url"):
464 params["youtube_url"] = trailer_ids.get("trailer_url")
465
466 return (
467 translation(90672),
468 kodi_play_media(**params),
469 )
470
471
472def add_tmdb_movie_context_menu(mode, media_type, title=None, ids={}):
473 context_menu = [
474 (
475 translation(90049),
476 kodi_play_media(
477 name="rescrape_tmdb_media",
478 mode=mode,
479 media_type=media_type,
480 query=title,
481 tmdb_id=ids.get("tmdb_id"),
482 rescrape=True,
483 ),
484 ),
485 (
486 translation(90115),
487 kodi_play_media(
488 name="rescrape_tmdb_media",
489 mode=mode,
490 media_type=media_type,
491 query=title,
492 tmdb_id=ids.get("tmdb_id"),
493 force_select=True,
494 ),
495 ),
496 (
497 "Extras",
498 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 '')})",
499 ),
500 (
501 translation(90733),
502 action_url_run(
503 "tmdb_search_modes",
504 mode=mode,
505 media_type=media_type,
506 query=title,
507 tmdb_id=ids.get("tmdb_id"),
508 ),
509 ),
510 ]
511 trailer_item = build_play_trailer_context_menu_item(
512 ids=ids,
513 media_type="tv" if media_type == "tv" else "movie",
514 title=title,
515 )
516 if trailer_item:
517 context_menu.append(trailer_item)
518 context_menu += [
519 (
520 translation(90050),
521 container_update(
522 name="search_tmdb_recommendations",
523 mode=mode,
524 ids=ids,
525 media_type=media_type,
526 ),
527 ),
528 (
529 translation(90051),
530 container_update(
531 name="search_tmdb_similar",
532 mode=mode,
533 ids=ids,
534 media_type=media_type,
535 ),
536 ),
537 (
538 translation(90081),
539 container_update(
540 name="search_people_by_id",
541 mode=mode,
542 ids=ids,
543 media_type=media_type,
544 ),
545 ),
546 (
547 translation(90205),
548 kodi_play_media(
549 name="add_to_library",
550 data=json.dumps(
551 {
552 "title": title,
553 "ids": ids,
554 "mode": media_type,
555 "timestamp": datetime.now().strftime("%a, %d %b %Y %I:%M %p"),
556 }
557 ),
558 ),
559 ),
560 (
561 translation(90116),
562 container_update(name="settings"),
563 ),
564 ]
565 return context_menu
566
567
568def add_tmdb_show_context_menu(mode, ids={}, title=None):
569 context_menu = [
570 (
571 "Extras",
572 f"RunPlugin(plugin://plugin.video.jacktook/?action=extras&id={ids.get('tmdb_id')}&imdb_id={ids.get('imdb_id', '')}&media_type=tv&title={parse.quote(title or ids.get('name') or ids.get('title') or '')})",
573 ),
574 ]
575 trailer_item = build_play_trailer_context_menu_item(
576 ids=ids,
577 media_type="tv",
578 title=title or ids.get("name") or ids.get("title"),
579 title_key="name",
580 )
581 if trailer_item:
582 context_menu.append(trailer_item)
583 context_menu += [
584 (
585 translation(90050),
586 container_update(
587 name="search_tmdb_recommendations",
588 mode=mode,
589 ids=ids,
590 ),
591 ),
592 (
593 translation(90051),
594 container_update(
595 name="search_tmdb_similar",
596 mode=mode,
597 ids=ids,
598 ),
599 ),
600 (
601 translation(90081),
602 container_update(
603 name="search_people_by_id",
604 mode=mode,
605 ids=ids,
606 ),
607 ),
608 (
609 translation(90205),
610 kodi_play_media(
611 name="add_to_library",
612 data=json.dumps(
613 {
614 "title": ids.get("name") or ids.get("title"),
615 "ids": ids,
616 "mode": mode,
617 "timestamp": datetime.now().strftime("%a, %d %b %Y %I:%M %p"),
618 }
619 ),
620 ),
621 ),
622 (
623 translation(90116),
624 container_update(name="settings"),
625 ),
626 ]
627 return context_menu
628
629
630def add_tmdb_episode_context_menu(mode, tv_name=None, tv_data=None, ids={}):
631 return [
632 (
633 translation(90049),
634 kodi_play_media(
635 name="search",
636 mode=mode,
637 query=tv_name,
638 ids=ids,
639 tv_data=tv_data,
640 rescrape=True,
641 ),
642 ),
643 (
644 translation(90115),
645 kodi_play_media(
646 name="search",
647 mode=mode,
648 query=tv_name,
649 ids=ids,
650 tv_data=tv_data,
651 force_select=True,
652 ),
653 ),
654 (
655 translation(90733),
656 action_url_run(
657 "tmdb_episode_search_modes",
658 mode=mode,
659 media_type="tv",
660 query=tv_name,
661 ids=ids,
662 tv_data=tv_data,
663 ),
664 ),
665 (
666 translation(90050),
667 container_update(
668 name="search_tmdb_recommendations",
669 mode=mode,
670 ids=ids,
671 ),
672 ),
673 (
674 translation(90051),
675 container_update(
676 name="search_tmdb_similar",
677 mode=mode,
678 ids=ids,
679 ),
680 ),
681 (
682 translation(90081),
683 container_update(
684 name="search_people_by_id",
685 mode=mode,
686 ids=ids,
687 ),
688 ),
689 (
690 translation(90116),
691 container_update(name="settings"),
692 ),
693 ]
plugin.video.jacktook/lib/clients/torrentio.py +99 -0
……@@ -0,0 +1,99 @@
1import re
2from typing import List, Dict, Tuple, Optional, Any, Callable
3
4from lib.clients.base import BaseClient, TorrentStream
5from lib.utils.localization.countries import find_language_by_unicode
6from lib.utils.kodi.utils import convert_size_to_bytes, kodilog, translation
7from lib.utils.general.utils import USER_AGENT_HEADER, unicode_flag_to_country_code
8
9
10class Torrentio(BaseClient):
11 def __init__(self, host: str, notification: Callable) -> None:
12 super().__init__(host, notification)
13
14 def search(
15 self,
16 imdb_id: str,
17 mode: str,
18 media_type: str,
19 season: Optional[int],
20 episode: Optional[int],
21 ) -> Optional[List[TorrentStream]]:
22 try:
23 if mode == "tv" or media_type == "tv":
24 url = f"{self.host}/stream/series/{imdb_id}:{season}:{episode}.json"
25 elif mode == "movies" or media_type == "movies":
26 url = f"{self.host}/stream/{mode}/{imdb_id}.json"
27 else:
28 self.handle_exception(translation(30228))
29 return None
30
31 res = self.session.get(url, headers=USER_AGENT_HEADER, timeout=10)
32 if res.status_code != 200:
33 return
34 return self.parse_response(res)
35 except Exception as e:
36 self.handle_exception(f"{translation(30228)}: {str(e)}")
37
38 def parse_response(self, res: Any) -> List[TorrentStream]:
39 res = res.json()
40 results = []
41 for item in res["streams"]:
42 parsed_item = self.parse_stream_title(item["title"])
43 results.append(
44 TorrentStream(
45 title=parsed_item["title"],
46 type="Torrent",
47 indexer="Torrentio",
48 guid=item["infoHash"],
49 infoHash=item["infoHash"],
50 size=parsed_item["size"],
51 seeders=parsed_item["seeders"],
52 languages=parsed_item["languages"],
53 fullLanguages=parsed_item["full_languages"],
54 provider=parsed_item["provider"],
55 publishDate="",
56 peers=0,
57 )
58 )
59 return results
60
61 def parse_stream_title(self, title: str) -> Dict[str, Any]:
62 name = title.splitlines()[0]
63
64 size_match = re.search(r"💾 (\d+(?:\.\d+)?\s*(GB|MB))", title, re.IGNORECASE)
65 size = size_match.group(1) if size_match else ""
66 size = convert_size_to_bytes(size)
67
68 seeders_match = re.search(r"👤 (\d+)", title)
69 seeders = int(seeders_match.group(1)) if seeders_match else None
70
71 languages, full_languages = self.extract_languages(title)
72
73 provider = self.extract_provider(title)
74
75 return {
76 "title": name,
77 "size": size,
78 "seeders": seeders,
79 "languages": languages,
80 "full_languages": full_languages,
81 "provider": provider,
82 }
83
84 def extract_languages(self, title: str) -> Tuple[List[str], List[str]]:
85 languages = []
86 full_languages = []
87 # Regex to match unicode country flag emojis
88 flag_emojis = re.findall(r"[\U0001F1E6-\U0001F1FF]{2}", title)
89 if flag_emojis:
90 for flag in flag_emojis:
91 languages.append(unicode_flag_to_country_code(flag).upper())
92 full_lang = find_language_by_unicode(flag)
93 if (full_lang != None) and (full_lang not in full_languages):
94 full_languages.append(full_lang)
95 return languages, full_languages
96
97 def extract_provider(self, title: str) -> str:
98 match = re.search(r"⚙.* ([^ \n]+)", title)
99 return match.group(1) if match else ""
plugin.video.jacktook/lib/clients/trakt/paginator.py +77 -0
……@@ -0,0 +1,77 @@
1import json
2from lib.api.trakt.base_cache import connect_database
3
4
5class Paginator:
6 def __init__(self, page_size=10):
7 self.page_size = page_size
8 self.total_pages = 0
9 self.current_page = 0
10
11 def _connect(self):
12 return connect_database(database_name="paginator_db")
13
14 def _store_page(self, page_number, total_pages, data):
15 conn = self._connect()
16 cursor = conn.cursor()
17 cursor.execute('''
18 INSERT OR REPLACE INTO paginated_data (id, page_number, data, total_pages)
19 VALUES (?, ?, ?, ?)
20 ''', (f'page_{page_number}', page_number, json.dumps(data), total_pages))
21 conn.commit()
22 conn.close()
23
24 def _retrieve_page(self, page_number):
25 conn = self._connect()
26 cursor = conn.cursor()
27 cursor.execute('''
28 SELECT data FROM paginated_data WHERE page_number = ?
29 ''', (page_number,))
30 result = cursor.fetchone()
31 conn.close()
32 if result and result[0] is not None:
33 return json.loads(result[0])
34 return None
35
36 def _clear_table(self):
37 conn = self._connect()
38 cursor = conn.cursor()
39 cursor.execute('DELETE FROM paginated_data')
40 conn.commit()
41 conn.close()
42
43 def initialize(self, data):
44 self._clear_table()
45 self.total_pages = (len(data) + self.page_size - 1) // self.page_size
46 for i in range(self.total_pages):
47 start = i * self.page_size
48 end = start + self.page_size
49 page_data = data[start:end]
50 self._store_page(i + 1, self.total_pages, page_data)
51
52 def get_page(self, page_number):
53 self.current_page = page_number - 1
54 data = self._retrieve_page(page_number)
55 if data is None:
56 raise IndexError("Page number not found in database: Requested page {}".format(page_number))
57 return data
58
59 def next_page(self):
60 """Move to the next page."""
61 if self.current_page < self.total_pages - 1:
62 self.current_page += 1
63 return self.get_page(self.current_page + 1)
64
65 def previous_page(self):
66 """Move to the previous page."""
67 if self.current_page > 0:
68 self.current_page -= 1
69 return self.get_page(self.current_page + 1)
70
71 def current_page_data(self):
72 """Get the data for the current page."""
73 return self.get_page(self.current_page + 1)
74
75
76
77paginator_db = Paginator()
plugin.video.jacktook/lib/clients/trakt/trakt.py +1633 -0
……@@ -0,0 +1,1633 @@
1import json
2from datetime import datetime, timedelta
3from lib.clients.tmdb.utils.utils import build_play_trailer_context_menu_item, tmdb_get
4from lib.api.trakt.trakt import TraktAPI, TraktLists, TraktMovies, TraktTV
5from lib.api.trakt.trakt_utils import (
6 add_trakt_custom_list_context_menu,
7 add_trakt_favorites_context_menu,
8 add_trakt_watched_context_menu,
9 add_trakt_watchlist_context_menu,
10 is_trakt_auth,
11)
12from lib.clients.trakt.utils import add_kodi_dir_item, extract_ids
13from lib.api.trakt.trakt_utils import add_trakt_collection_context_menu
14from lib.utils.general.utils import (
15 Anime,
16 Enum,
17 add_next_button,
18 execute_thread_pool,
19 set_content_type,
20 set_media_infoTag,
21 truncate_text,
22)
23from lib.utils.kodi.utils import (
24 action_url_run,
25 add_directory_items_batch,
26 build_url,
27 container_update,
28 dialog_select,
29 dialog_text,
30 dialogyesno,
31 end_of_directory,
32 execute_builtin,
33 get_datetime,
34 get_setting,
35 kodilog,
36 make_list_item,
37 notification,
38 kodi_play_media,
39 refresh,
40 show_keyboard,
41 translation,
42)
43from .paginator import paginator_db
44
45
46def _normalize_user_slug(value):
47 if value in (None, "", "None"):
48 return ""
49 return str(value)
50
51
52class Trakt(Enum):
53 TRENDING = "trakt_trending"
54 TRENDING_RECENT = "trakt_trending_recent"
55 TOP10 = "trakt_top10"
56 WATCHED = "trakt_watched"
57 FAVORITED = "trakt_favorited"
58 RECOMENDATIONS = "trakt_recommendations"
59 TRENDING_LISTS = "trakt_trending_lists"
60 POPULAR_LISTS = "trakt_popular_lists"
61 WATCHED_HISTORY = "trakt_watched_history"
62 WATCHLIST = "trakt_watchlist"
63 CALENDAR = "trakt_calendar"
64 UP_NEXT = "trakt_up_next"
65 MOVIES_PROGRESS = "trakt_movies_progress"
66 COLLECTION = "trakt_collection"
67 FAVORITES = "trakt_favorites"
68 MY_LISTS = "trakt_my_lists"
69 LIKED_LISTS = "trakt_liked_lists"
70 SEARCH_LISTS = "trakt_search_lists"
71 ACCOUNT_INFO = "trakt_account_info"
72 CREATE_LIST = "trakt_create_list"
73
74
75class BaseTraktClient:
76 @staticmethod
77 def _exclusive_context_flags(remove_action):
78 flags = {
79 "watchlist_add": False,
80 "watchlist_remove": False,
81 "watched_add": False,
82 "watched_remove": False,
83 "collection_add": False,
84 "collection_remove": False,
85 "favorites_add": False,
86 "favorites_remove": False,
87 "custom_list_add": False,
88 "custom_list_remove": False,
89 }
90 if remove_action == "watchlist":
91 flags["watchlist_remove"] = True
92 elif remove_action == "watched":
93 flags["watched_remove"] = True
94 elif remove_action == "collection":
95 flags["collection_remove"] = True
96 elif remove_action == "favorites":
97 flags["favorites_remove"] = True
98 elif remove_action == "custom_list":
99 flags["custom_list_remove"] = True
100 return flags
101
102 @staticmethod
103 def _trakt_context_menu(mode, ids, context_flags=None):
104 context_flags = context_flags or {}
105 media_type = "movies" if mode == "movies" else "shows"
106 return (
107 add_trakt_watchlist_context_menu(
108 media_type,
109 ids,
110 include_add=context_flags.get("watchlist_add", True),
111 include_remove=context_flags.get("watchlist_remove", True),
112 )
113 + add_trakt_watched_context_menu(
114 media_type,
115 ids=ids,
116 include_add=context_flags.get("watched_add", True),
117 include_remove=context_flags.get("watched_remove", True),
118 )
119 + add_trakt_collection_context_menu(
120 media_type,
121 ids,
122 include_add=context_flags.get("collection_add", True),
123 include_remove=context_flags.get("collection_remove", True),
124 )
125 + add_trakt_favorites_context_menu(
126 media_type,
127 ids,
128 include_add=context_flags.get("favorites_add", True),
129 include_remove=context_flags.get("favorites_remove", True),
130 )
131 + add_trakt_custom_list_context_menu(
132 media_type,
133 ids,
134 include_add=context_flags.get("custom_list_add", True),
135 include_remove=context_flags.get("custom_list_remove", True),
136 )
137 )
138
139 @staticmethod
140 def _add_media_directory_item(
141 list_item, mode, title, ids, media_type=None, context_flags=None
142 ):
143 trailer_item = build_play_trailer_context_menu_item(
144 ids=ids,
145 media_type="movie" if mode == "movies" else "tv",
146 title=title,
147 title_key="title",
148 )
149 if mode == "movies":
150 context_menu = [
151 (
152 translation(90049),
153 kodi_play_media(
154 name="search",
155 mode=mode,
156 query=title,
157 ids=ids,
158 rescrape=True,
159 ),
160 ),
161 ]
162 if trailer_item:
163 context_menu.append(trailer_item)
164 context_menu += (
165 BaseTraktClient._trakt_context_menu(
166 "movies", ids, context_flags=context_flags
167 )
168 if is_trakt_auth()
169 else []
170 )
171 list_item.addContextMenuItems(context_menu)
172 add_kodi_dir_item(
173 list_item=list_item,
174 url=build_url(
175 "search",
176 mode=mode,
177 query=title,
178 ids=ids,
179 ),
180 is_folder=False,
181 is_playable=True,
182 )
183 else:
184 context_menu = []
185 if trailer_item:
186 context_menu.append(trailer_item)
187 if is_trakt_auth():
188 context_menu += BaseTraktClient._trakt_context_menu(
189 "tv", ids, context_flags=context_flags
190 )
191 if context_menu:
192 list_item.addContextMenuItems(context_menu)
193 add_kodi_dir_item(
194 list_item=list_item,
195 url=build_url(
196 "show_seasons_details",
197 ids=ids,
198 mode=mode,
199 media_type=media_type,
200 ),
201 is_folder=True,
202 )
203
204
205class TraktClient:
206 @staticmethod
207 def _refresh_after_list_action(params):
208 if params.get("query") == Trakt.SEARCH_LISTS and params.get("search_term"):
209 execute_builtin(
210 container_update(
211 "search_item",
212 query=params.get("query"),
213 category=params.get("category"),
214 mode=params.get("mode"),
215 submode=params.get("submode", ""),
216 api=params.get("api", "trakt"),
217 page=params.get("page", 1),
218 search_term=params.get("search_term"),
219 )
220 )
221 return
222 refresh()
223
224 @staticmethod
225 def _trakt_sync_result_count(result, key, media_type):
226 if not isinstance(result, dict):
227 return 0
228 section = result.get(key, {})
229 if not isinstance(section, dict):
230 return 0
231 media_keys = []
232 if media_type in ("movie", "movies"):
233 media_keys = ["movies", "movie"]
234 else:
235 media_keys = ["shows", "show"]
236 for media_key in media_keys:
237 value = section.get(media_key)
238 if isinstance(value, int):
239 return value
240 return 0
241
242 @staticmethod
243 def _normalize_trakt_media_type(media_type, ids):
244 if media_type in ("tv", "show", "shows", "tvshow", "tvshows", "anime"):
245 return "shows"
246 if media_type in ("movie", "movies"):
247 if ids.get("tvdb") or ids.get("tvdb_id"):
248 return "shows"
249 if ids.get("tvdb") or ids.get("tvdb_id"):
250 return "shows"
251
252 tmdb_id = ids.get("tmdb") or ids.get("tmdb_id")
253 if tmdb_id:
254 tv_details = tmdb_get("tv_details", tmdb_id)
255 tv_name = getattr(tv_details, "name", None)
256 if tv_name is None and isinstance(tv_details, dict):
257 tv_name = tv_details.get("name")
258 if tv_name:
259 return "shows"
260
261 movie_details = tmdb_get("movie_details", tmdb_id)
262 movie_title = getattr(movie_details, "title", None)
263 if movie_title is None and isinstance(movie_details, dict):
264 movie_title = movie_details.get("title")
265 if movie_title:
266 return "movies"
267
268 imdb_id = ids.get("imdb") or ids.get("imdb_id")
269 if imdb_id:
270 found = tmdb_get("find_by_imdb_id", imdb_id)
271 if getattr(found, "tv_results", []):
272 return "shows"
273 if getattr(found, "movie_results", []):
274 return "movies"
275
276 return "movies"
277
278 @staticmethod
279 def _build_show_collection_payload(ids):
280 resolved_ids = TraktPresentation._resolve_media_ids("tv", ids)
281 tmdb_id = resolved_ids.get("tmdb_id")
282 show_details = tmdb_get("tv_details", tmdb_id) if tmdb_id else None
283 trakt_object = (
284 TraktAPI().lists.get_trakt_object_by_tmdb(tmdb_id, media_type="show")
285 if tmdb_id
286 else None
287 )
288 normalized_ids = {}
289 if resolved_ids.get("tmdb_id"):
290 normalized_ids["tmdb"] = int(resolved_ids["tmdb_id"])
291 trakt_id = trakt_object.get("ids", {}).get("trakt") if trakt_object else None
292 if trakt_id:
293 normalized_ids["trakt"] = trakt_id
294 if resolved_ids.get("tvdb_id"):
295 normalized_ids["tvdb"] = int(resolved_ids["tvdb_id"])
296 if resolved_ids.get("imdb_id"):
297 normalized_ids["imdb"] = resolved_ids["imdb_id"]
298 slug = trakt_object.get("ids", {}).get("slug") if trakt_object else None
299 if slug:
300 normalized_ids["slug"] = slug
301 if not normalized_ids:
302 return None
303
304 show_item = {"ids": normalized_ids}
305 title = trakt_object.get("title") if trakt_object else None
306 if title is None:
307 title = getattr(show_details, "name", None)
308 if title is None and isinstance(show_details, dict):
309 title = show_details.get("name")
310 if title:
311 show_item["title"] = title
312 first_air_date = trakt_object.get("year") if trakt_object else None
313 if isinstance(first_air_date, int):
314 show_item["year"] = first_air_date
315 return {"shows": [show_item]}
316
317 first_air_date = getattr(show_details, "first_air_date", None)
318 if first_air_date is None and isinstance(show_details, dict):
319 first_air_date = show_details.get("first_air_date")
320 if first_air_date:
321 try:
322 show_item["year"] = int(str(first_air_date)[:4])
323 except (TypeError, ValueError):
324 pass
325
326 return {"shows": [show_item]}
327
328 @staticmethod
329 def _build_show_collection_episode_payload(ids):
330 resolved_ids = TraktPresentation._resolve_media_ids("tv", ids)
331 tmdb_id = resolved_ids.get("tmdb_id")
332 if not tmdb_id:
333 return None
334
335 show_details = tmdb_get("tv_details", tmdb_id)
336 total_seasons = getattr(show_details, "number_of_seasons", 0) or show_details.get(
337 "number_of_seasons", 0
338 )
339 today = datetime.utcnow().date()
340 seasons_payload = []
341
342 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 )
346 episodes = getattr(season_details, "episodes", None)
347 if episodes is None and isinstance(season_details, dict):
348 episodes = season_details.get("episodes", [])
349 if not episodes:
350 continue
351
352 episode_entries = []
353 for episode in episodes:
354 air_date = getattr(episode, "air_date", None)
355 if air_date is None and isinstance(episode, dict):
356 air_date = episode.get("air_date")
357 if air_date:
358 try:
359 if datetime.strptime(air_date, "%Y-%m-%d").date() > today:
360 continue
361 except ValueError:
362 pass
363
364 episode_number = getattr(episode, "episode_number", None)
365 if episode_number is None and isinstance(episode, dict):
366 episode_number = episode.get("episode_number")
367 if not episode_number:
368 continue
369 episode_entries.append({"number": int(episode_number)})
370
371 if episode_entries:
372 seasons_payload.append(
373 {"number": int(season_number), "episodes": episode_entries}
374 )
375
376 if not seasons_payload:
377 return None
378
379 trakt_ids = {k: v for k, v in resolved_ids.items() if v}
380 trakt_object = TraktAPI().lists.get_trakt_object_by_tmdb(tmdb_id, media_type="show")
381 normalized_ids = {}
382 if trakt_ids.get("tmdb_id"):
383 normalized_ids["tmdb"] = int(trakt_ids["tmdb_id"])
384 trakt_id = trakt_object.get("ids", {}).get("trakt") if trakt_object else None
385 if trakt_id:
386 normalized_ids["trakt"] = trakt_id
387 if trakt_ids.get("tvdb_id"):
388 normalized_ids["tvdb"] = int(trakt_ids["tvdb_id"])
389 if trakt_ids.get("imdb_id"):
390 normalized_ids["imdb"] = trakt_ids["imdb_id"]
391 slug = trakt_object.get("ids", {}).get("slug") if trakt_object else None
392 if slug:
393 normalized_ids["slug"] = slug
394
395 show_item = {"ids": normalized_ids, "seasons": seasons_payload}
396 if trakt_object and trakt_object.get("title"):
397 show_item["title"] = trakt_object.get("title")
398 if trakt_object and trakt_object.get("year"):
399 show_item["year"] = trakt_object.get("year")
400 return {"shows": [show_item]}
401
402 @staticmethod
403 def _build_show_collection_season_payload(ids):
404 episode_payload = TraktClient._build_show_collection_episode_payload(ids)
405 if not episode_payload:
406 return None
407
408 show_item = episode_payload["shows"][0]
409 seasons = show_item.get("seasons", [])
410 if not seasons:
411 return None
412
413 season_payload = {
414 "shows": [
415 {
416 "ids": show_item.get("ids", {}),
417 "seasons": [{"number": season.get("number")} for season in seasons if season.get("number")],
418 }
419 ]
420 }
421 if show_item.get("title"):
422 season_payload["shows"][0]["title"] = show_item.get("title")
423 if show_item.get("year"):
424 season_payload["shows"][0]["year"] = show_item.get("year")
425 return season_payload
426
427 @staticmethod
428 def _result_has_collection_change(result, action):
429 if action == "add":
430 relevant_keys = ("added", "updated", "existing")
431 else:
432 relevant_keys = ("deleted",)
433
434 for key in relevant_keys:
435 section = result.get(key, {}) if isinstance(result, dict) else {}
436 if not isinstance(section, dict):
437 continue
438 if any(int(section.get(count_key, 0) or 0) > 0 for count_key in ("movies", "episodes")):
439 return True
440 return False
441
442 @staticmethod
443 def handle_trakt_query(query, category, mode, page, submode, api, params=None):
444 set_content_type(mode)
445 params = params or {}
446 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 ),
456 }
457 handler = handlers.get(mode)
458 if handler:
459 return handler(query, mode, page)
460
461 @staticmethod
462 def handle_trakt_movie_query(query, mode, page, params=None):
463 params = params or {}
464 query_handlers = {
465 Trakt.TRENDING: lambda: TraktMovies().trakt_movies_trending(page),
466 Trakt.TOP10: lambda: TraktMovies().trakt_movies_top10_boxoffice(),
467 Trakt.WATCHED: lambda: TraktMovies().trakt_movies_most_watched(page),
468 Trakt.WATCHED_HISTORY: lambda: TraktLists().trakt_watched_history(
469 mode, page
470 ),
471 Trakt.FAVORITED: lambda: TraktMovies().trakt_movies_most_favorited(page),
472 Trakt.RECOMENDATIONS: lambda: TraktMovies().trakt_recommendations("movies"),
473 Trakt.FAVORITES: lambda: TraktLists().trakt_favorites(mode),
474 Trakt.MY_LISTS: lambda: TraktLists().trakt_get_lists("my_lists"),
475 Trakt.LIKED_LISTS: lambda: TraktLists().trakt_get_lists("liked_lists"),
476 Trakt.SEARCH_LISTS: lambda: TraktClient._search_trakt_lists(params, page),
477 Trakt.ACCOUNT_INFO: TraktClient.get_account_info,
478 }
479
480 list_handlers = {
481 Trakt.TRENDING_LISTS: lambda: TraktLists().trakt_trending_popular_lists(
482 list_type="trending", page_no=page
483 ),
484 Trakt.POPULAR_LISTS: lambda: TraktLists().trakt_trending_popular_lists(
485 list_type="popular", page_no=page
486 ),
487 Trakt.WATCHLIST: lambda: TraktLists().trakt_watchlist(mode),
488 Trakt.COLLECTION: lambda: TraktMovies().trakt_collection(page),
489 Trakt.MOVIES_PROGRESS: lambda: TraktMovies().trakt_movies_progress(),
490 }
491
492 if query in query_handlers:
493 return query_handlers[query]()
494 elif query in list_handlers:
495 return list_handlers[query]()
496
497 @staticmethod
498 def handle_trakt_show_query(query, mode, page, params=None):
499 params = params or {}
500 query_handlers = {
501 Trakt.TRENDING: lambda: TraktTV().trakt_tv_trending(page),
502 Trakt.WATCHED: lambda: TraktTV().trakt_tv_most_watched(page),
503 Trakt.WATCHED_HISTORY: lambda: TraktLists().trakt_watched_history(
504 mode, page
505 ),
506 Trakt.FAVORITED: lambda: TraktTV().trakt_tv_most_favorited(page),
507 Trakt.RECOMENDATIONS: lambda: TraktTV().trakt_recommendations("shows"),
508 Trakt.FAVORITES: lambda: TraktLists().trakt_favorites(mode),
509 Trakt.MY_LISTS: lambda: TraktLists().trakt_get_lists("my_lists"),
510 Trakt.LIKED_LISTS: lambda: TraktLists().trakt_get_lists("liked_lists"),
511 Trakt.SEARCH_LISTS: lambda: TraktClient._search_trakt_lists(params, page),
512 Trakt.ACCOUNT_INFO: TraktClient.get_account_info,
513 }
514
515 list_handlers = {
516 Trakt.TRENDING_LISTS: lambda: TraktLists().trakt_trending_popular_lists(
517 list_type="trending", page_no=page
518 ),
519 Trakt.POPULAR_LISTS: lambda: TraktLists().trakt_trending_popular_lists(
520 list_type="popular", page_no=page
521 ),
522 Trakt.WATCHLIST: lambda: TraktLists().trakt_watchlist(mode),
523 Trakt.COLLECTION: lambda: TraktTV().trakt_collection(page),
524 Trakt.CALENDAR: lambda: TraktClient.handle_calendar_request(),
525 Trakt.UP_NEXT: lambda: TraktTV().trakt_up_next(),
526 }
527
528 if query in query_handlers:
529 return query_handlers[query]()
530 elif query in list_handlers:
531 return list_handlers[query]()
532
533 @staticmethod
534 def handle_trakt_anime_query(query, page, submode="tv"):
535 if query == Anime.TRENDING:
536 return TraktAPI().anime.trakt_anime_trending(page, submode)
537 elif query == Anime.TRENDING_RECENT:
538 return TraktAPI().anime.trakt_anime_trending_recent(page, submode)
539 elif query == Anime.MOST_WATCHED:
540 return TraktAPI().anime.trakt_anime_most_watched(page, submode)
541 elif query == Anime.FAVORITED:
542 return TraktAPI().anime.trakt_anime_most_favorited(page, submode)
543
544 @staticmethod
545 def _search_trakt_lists(params, page):
546 search_term = params.get("search_term", "")
547 if not search_term:
548 search_term = show_keyboard(id=30243, default="")
549 if not search_term:
550 params["_trakt_search_cancelled"] = True
551 return []
552 params.pop("_trakt_search_cancelled", None)
553 params["search_term"] = search_term
554 return TraktLists().trakt_search_lists(search_term, page)
555
556 @staticmethod
557 def get_account_info():
558 auth_api = TraktAPI().auth
559 settings = auth_api.get_user_settings() or {}
560 stats = auth_api.get_account_stats() or {}
561 return {"settings": settings, "stats": stats}
562
563 @staticmethod
564 def trakt_add_to_watchlist(params):
565 ids = json.loads(params.get("ids", "{}"))
566 media_type = TraktClient._normalize_trakt_media_type(
567 params.get("media_type"), ids
568 )
569 try:
570 TraktAPI().lists.add_to_watchlist(media_type, ids)
571 notification(translation(90434), time=3000)
572 refresh()
573 except Exception as e:
574 kodilog(f"Error adding to Trakt watchlist: {e}")
575 notification(translation(90435), time=3000)
576
577 @staticmethod
578 def trakt_remove_from_watchlist(params):
579 ids = json.loads(params.get("ids", "{}"))
580 media_type = TraktClient._normalize_trakt_media_type(
581 params.get("media_type"), ids
582 )
583 try:
584 TraktAPI().lists.remove_from_watchlist(media_type, ids)
585 notification(translation(90436), time=3000)
586 refresh()
587 except Exception as e:
588 kodilog(f"Error removing from Trakt watchlist: {e}")
589 notification(translation(90437), time=3000)
590
591 @staticmethod
592 def trakt_mark_as_watched(params):
593 media_type = params.get("media_type")
594 season = json.loads(params.get("season"))
595 episode = json.loads(params.get("episode"))
596 ids = json.loads(params.get("ids", "{}"))
597 try:
598 TraktAPI().lists.mark_as_watched(media_type, season, episode, ids)
599 except Exception as e:
600 kodilog(f"Error marking as watched on Trakt: {e}")
601 notification(translation(90438), time=3000)
602
603 @staticmethod
604 def trakt_mark_as_unwatched(params):
605 media_type = params.get("media_type")
606 season = json.loads(params.get("season"))
607 episode = json.loads(params.get("episode"))
608 ids = json.loads(params.get("ids", "{}"))
609 try:
610 TraktAPI().lists.mark_as_unwatched(media_type, season, episode, ids)
611 notification(translation(90439), time=3000)
612 except Exception as e:
613 kodilog(f"Error marking as unwatched on Trakt: {e}")
614 notification(translation(90440), time=3000)
615
616 @staticmethod
617 def trakt_add_to_collection(params):
618 ids = json.loads(params.get("ids", "{}"))
619 media_type = TraktClient._normalize_trakt_media_type(
620 params.get("media_type"), ids
621 )
622 try:
623 payload = None
624 if media_type not in ("movie", "movies"):
625 payload = TraktClient._build_show_collection_payload(ids)
626 if payload is None:
627 notification(translation(90465), time=3500)
628 return
629 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"):
631 payload = TraktClient._build_show_collection_season_payload(ids)
632 if payload is not None:
633 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"):
635 payload = TraktClient._build_show_collection_episode_payload(ids)
636 if payload is not None:
637 result = TraktAPI().lists.add_to_collection(media_type, ids, payload=payload)
638 added_count = TraktClient._trakt_sync_result_count(result, "added", media_type)
639 existing_count = TraktClient._trakt_sync_result_count(result, "existing", media_type)
640 not_found_count = TraktClient._trakt_sync_result_count(result, "not_found", media_type)
641 if added_count > 0 or existing_count > 0:
642 notification(translation(90441), time=3000)
643 refresh()
644 elif not_found_count > 0:
645 notification(translation(90463), time=3500)
646 else:
647 notification(translation(90464), time=3500)
648 except Exception as e:
649 kodilog(f"Error adding to Trakt collection: {e}")
650 notification(translation(90443), time=3000)
651
652 @staticmethod
653 def trakt_remove_from_collection(params):
654 ids = json.loads(params.get("ids", "{}"))
655 media_type = TraktClient._normalize_trakt_media_type(
656 params.get("media_type"), ids
657 )
658 try:
659 payload = None
660 if media_type not in ("movie", "movies"):
661 payload = TraktAPI().lists.build_collection_remove_payload(media_type, ids)
662 if payload is None:
663 payload = TraktClient._build_show_collection_payload(ids)
664 if payload is None:
665 notification(translation(90465), time=3500)
666 return
667 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"):
669 payload = TraktClient._build_show_collection_season_payload(ids)
670 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"):
673 payload = TraktClient._build_show_collection_episode_payload(ids)
674 if payload is not None:
675 result = TraktAPI().lists.remove_from_collection(media_type, ids, payload=payload)
676 removed_count = TraktClient._trakt_sync_result_count(result, "deleted", media_type)
677 not_found_count = TraktClient._trakt_sync_result_count(result, "not_found", media_type)
678 if removed_count > 0:
679 notification(translation(90442), time=3000)
680 refresh()
681 elif not_found_count > 0:
682 notification(translation(90462), time=3500)
683 else:
684 notification(translation(90464), time=3500)
685 except Exception as e:
686 kodilog(f"Error removing from Trakt collection: {e}")
687 notification(translation(90444), time=3000)
688
689 @staticmethod
690 def trakt_add_to_favorites(params):
691 ids = json.loads(params.get("ids", "{}"))
692 media_type = TraktClient._normalize_trakt_media_type(
693 params.get("media_type"), ids
694 )
695 try:
696 TraktAPI().lists.add_to_favorites(media_type, ids)
697 notification(translation(90445), time=3000)
698 refresh()
699 except Exception as e:
700 kodilog(f"Error adding to Trakt favorites: {e}")
701 notification(translation(90447), time=3000)
702
703 @staticmethod
704 def trakt_remove_from_favorites(params):
705 ids = json.loads(params.get("ids", "{}"))
706 media_type = TraktClient._normalize_trakt_media_type(
707 params.get("media_type"), ids
708 )
709 try:
710 TraktAPI().lists.remove_from_favorites(media_type, ids)
711 notification(translation(90446), time=3000)
712 refresh()
713 except Exception as e:
714 kodilog(f"Error removing from Trakt favorites: {e}")
715 notification(translation(90448), time=3000)
716
717 @staticmethod
718 def trakt_create_list(params):
719 name = show_keyboard(id=30924, default="")
720 if not name:
721 return
722
723 description = show_keyboard(id=30925, default="") or ""
724 try:
725 TraktAPI().lists.create_list(name=name, description=description)
726 notification(translation(90449), time=3000)
727 refresh()
728 except Exception as e:
729 kodilog(f"Error creating Trakt list: {e}")
730 notification(translation(90450), time=3000)
731
732 @staticmethod
733 def trakt_delete_list(params):
734 trakt_id = params.get("trakt_id")
735 if not trakt_id:
736 return
737 if not dialogyesno(translation(90645), translation(90466)):
738 return
739 try:
740 TraktAPI().lists.delete_list(trakt_id)
741 notification(translation(90451), time=3000)
742 refresh()
743 except Exception as e:
744 kodilog(f"Error deleting Trakt list: {e}")
745 notification(translation(90452), time=3000)
746
747 @staticmethod
748 def trakt_like_list(params):
749 trakt_id = params.get("trakt_id")
750 user_slug = _normalize_user_slug(params.get("user"))
751 if not trakt_id:
752 return
753 try:
754 TraktAPI().lists.like_list(user_slug, trakt_id)
755 notification(translation(90453), time=3000)
756 TraktClient._refresh_after_list_action(params)
757 except Exception as e:
758 kodilog(f"Error liking Trakt list: {e}")
759 notification(translation(90454), time=3000)
760
761 @staticmethod
762 def trakt_unlike_list(params):
763 trakt_id = params.get("trakt_id")
764 user_slug = _normalize_user_slug(params.get("user"))
765 if not trakt_id:
766 return
767 try:
768 TraktAPI().lists.unlike_list(user_slug, trakt_id)
769 notification(translation(90455), time=3000)
770 TraktClient._refresh_after_list_action(params)
771 except Exception as e:
772 kodilog(f"Error unliking Trakt list: {e}")
773 notification(translation(90456), time=3000)
774
775 @staticmethod
776 def _select_trakt_list():
777 my_lists = TraktAPI().lists.trakt_get_lists("my_lists") or []
778 if not my_lists:
779 notification(translation(90461), time=3000)
780 return None
781
782 options = [item.get("name", translation(90535)) for item in my_lists]
783 choice = dialog_select(translation(30927), options)
784 if choice < 0:
785 return None
786 return my_lists[choice]
787
788 @staticmethod
789 def trakt_add_item_to_list(params):
790 media_type = params.get("media_type")
791 ids = json.loads(params.get("ids", "{}"))
792 selected_list = TraktClient._select_trakt_list()
793 if not selected_list:
794 return
795 try:
796 TraktAPI().lists.add_item_to_list(
797 selected_list.get("trakt_id"), media_type, ids
798 )
799 notification(translation(90457), time=3000)
800 except Exception as e:
801 kodilog(f"Error adding item to Trakt list: {e}")
802 notification(translation(90459), time=3000)
803
804 @staticmethod
805 def trakt_remove_item_from_list(params):
806 media_type = params.get("media_type")
807 ids = json.loads(params.get("ids", "{}"))
808 selected_list = TraktClient._select_trakt_list()
809 if not selected_list:
810 return
811 try:
812 TraktAPI().lists.remove_item_from_list(
813 selected_list.get("trakt_id"), media_type, ids
814 )
815 notification(translation(90536), time=3000)
816 except Exception as e:
817 kodilog(f"Error removing item from Trakt list: {e}")
818 notification(translation(90537), time=3000)
819
820 @staticmethod
821 def handle_calendar_request():
822 previous_days = int(get_setting("trakt_calendar_previous_days", 0))
823 future_days = int(get_setting("trakt_calendar_future_days", 14))
824
825 # Calculate start date
826 start_date_obj = get_datetime(string=False) - timedelta(days=previous_days)
827 start_date = start_date_obj.strftime("%Y-%m-%d")
828
829 # Calculate total days (past + future + today)
830 days = previous_days + future_days + 1
831
832 # Log user identity logic
833 try:
834 user_settings = TraktAPI().auth.get_user_settings()
835 if user_settings and "user" in user_settings:
836 username = user_settings.get("user", {}).get("username")
837 kodilog(f"Trakt Calendar Request for user: {username}")
838 except Exception as e:
839 kodilog(f"Error fetching Trakt user profile: {e}")
840
841 if get_setting("trakt_calendar_show_all"):
842 return TraktAPI().calendar.trakt_all_shows_calendar(
843 start_date=start_date, days=days
844 )
845
846 return TraktAPI().calendar.trakt_my_calendar(start_date=start_date, days=days)
847
848 @staticmethod
849 def process_trakt_result(
850 results, query, category, mode, submode, api, page, search_term=""
851 ):
852 if query == Trakt.ACCOUNT_INFO:
853 TraktPresentation.show_account_info(results)
854 end_of_directory(cache=False)
855 return
856
857 if not results:
858 if query == Trakt.SEARCH_LISTS and not search_term:
859 end_of_directory(cache=False)
860 return
861 if query in (Trakt.CALENDAR, Trakt.UP_NEXT, Trakt.SEARCH_LISTS):
862 notification("No results found", time=3000)
863
864 query_handlers = {
865 Trakt.TRENDING: lambda: execute_thread_pool(
866 results, TraktPresentation.show_common_categories, mode
867 ),
868 Trakt.WATCHED: lambda: execute_thread_pool(
869 results, TraktPresentation.show_common_categories, mode
870 ),
871 Trakt.FAVORITED: lambda: execute_thread_pool(
872 results, TraktPresentation.show_common_categories, mode
873 ),
874 Trakt.TOP10: lambda: execute_thread_pool(
875 results, TraktPresentation.show_common_categories, mode
876 ),
877 Trakt.RECOMENDATIONS: lambda: execute_thread_pool(
878 results, TraktPresentation.show_recommendations, mode
879 ),
880 Trakt.TRENDING_LISTS: lambda: execute_thread_pool(
881 results, TraktPresentation.show_trending_lists, mode
882 ),
883 Trakt.POPULAR_LISTS: lambda: execute_thread_pool(
884 results, TraktPresentation.show_trending_lists, mode
885 ),
886 Trakt.WATCHLIST: lambda: execute_thread_pool(
887 results, TraktPresentation.show_watchlist, mode
888 ),
889 Trakt.WATCHED_HISTORY: lambda: execute_thread_pool(
890 results, TraktPresentation.show_watched_history
891 ),
892 Trakt.COLLECTION: lambda: execute_thread_pool(
893 results, TraktPresentation.show_collection, mode
894 ),
895 Trakt.CALENDAR: lambda: execute_thread_pool(
896 results, TraktPresentation.show_calendar_items
897 ),
898 Trakt.UP_NEXT: lambda: execute_thread_pool(
899 results, TraktPresentation.show_up_next_items
900 ),
901 Trakt.MOVIES_PROGRESS: lambda: execute_thread_pool(
902 results, TraktPresentation.show_movies_progress_items
903 ),
904 Trakt.FAVORITES: lambda: execute_thread_pool(
905 results, TraktPresentation.show_favorites, mode
906 ),
907 Trakt.MY_LISTS: lambda: execute_thread_pool(
908 results,
909 TraktPresentation.show_user_lists,
910 mode,
911 query=query,
912 page=page,
913 search_term=search_term,
914 api=api,
915 category=category,
916 submode=submode,
917 ),
918 Trakt.LIKED_LISTS: lambda: execute_thread_pool(
919 results,
920 TraktPresentation.show_user_lists,
921 mode,
922 query=query,
923 page=page,
924 search_term=search_term,
925 api=api,
926 category=category,
927 submode=submode,
928 ),
929 Trakt.SEARCH_LISTS: lambda: execute_thread_pool(
930 results,
931 TraktPresentation.show_user_lists,
932 mode,
933 query=query,
934 page=page,
935 search_term=search_term,
936 api=api,
937 category=category,
938 submode=submode,
939 ),
940 }
941
942 anime_handlers = {
943 Anime.TRENDING: lambda: execute_thread_pool(
944 results, TraktPresentation.show_anime_common, submode
945 ),
946 Anime.TRENDING_RECENT: lambda: execute_thread_pool(
947 results, TraktPresentation.show_anime_common, submode
948 ),
949 Anime.MOST_WATCHED: lambda: execute_thread_pool(
950 results, TraktPresentation.show_anime_common, submode
951 ),
952 Anime.FAVORITED: lambda: execute_thread_pool(
953 results, TraktPresentation.show_anime_common, submode
954 ),
955 }
956
957 if query in query_handlers:
958 query_handlers[query]()
959 if query == Trakt.MY_LISTS:
960 TraktPresentation.show_create_list_entry(mode)
961 if category in anime_handlers:
962 anime_handlers[category]()
963
964 if TraktClient._should_add_next_button(query, category):
965 next_kwargs = {
966 "query": query,
967 "category": category,
968 "mode": mode,
969 "submode": submode,
970 "api": api,
971 }
972 if search_term:
973 next_kwargs["search_term"] = search_term
974 add_next_button("search_item", page=page, **next_kwargs)
975 end_of_directory(cache=TraktClient._should_cache_directory(query))
976
977 @staticmethod
978 def _should_add_next_button(query, category):
979 paginated_queries = {
980 Trakt.TRENDING,
981 Trakt.WATCHED,
982 Trakt.FAVORITED,
983 Trakt.TRENDING_LISTS,
984 Trakt.POPULAR_LISTS,
985 Trakt.WATCHED_HISTORY,
986 Trakt.COLLECTION,
987 Trakt.SEARCH_LISTS,
988 }
989 paginated_categories = {
990 Anime.TRENDING,
991 Anime.TRENDING_RECENT,
992 Anime.MOST_WATCHED,
993 Anime.FAVORITED,
994 }
995 return query in paginated_queries or category in paginated_categories
996
997 @staticmethod
998 def _should_cache_directory(query):
999 non_cached_queries = {
1000 Trakt.WATCHLIST,
1001 Trakt.WATCHED_HISTORY,
1002 Trakt.CALENDAR,
1003 Trakt.UP_NEXT,
1004 Trakt.COLLECTION,
1005 Trakt.FAVORITES,
1006 Trakt.MY_LISTS,
1007 Trakt.LIKED_LISTS,
1008 Trakt.ACCOUNT_INFO,
1009 }
1010 return query not in non_cached_queries
1011
1012 @staticmethod
1013 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 )
1017 if not data:
1018 notification("No results found", time=3000)
1019 end_of_directory(cache=not bool(with_auth))
1020 return
1021 paginator_db.initialize(data)
1022 items = paginator_db.get_page(page)
1023 execute_thread_pool(items, TraktPresentation.show_lists_content_items)
1024 add_next_button("list_trakt_page", page, mode=mode)
1025 end_of_directory(cache=not bool(with_auth))
1026
1027 @staticmethod
1028 def show_list_trakt_page(page, mode):
1029 items = paginator_db.get_page(page)
1030 execute_thread_pool(items, TraktPresentation.show_lists_content_items)
1031 add_next_button("list_trakt_page", page, mode=mode)
1032 end_of_directory()
1033
1034
1035class TraktPresentation:
1036 @staticmethod
1037 def _format_bool_label(value):
1038 return "Yes" if value else "No"
1039
1040 @staticmethod
1041 def _format_iso_date(value):
1042 if not value:
1043 return ""
1044 if "T" in str(value):
1045 return str(value).split("T")[0]
1046 return str(value)
1047
1048 @staticmethod
1049 def _resolve_media_ids(mode, media_ids):
1050 tmdb_id = media_ids.get("tmdb") or media_ids.get("tmdb_id")
1051 imdb_id = media_ids.get("imdb") or media_ids.get("imdb_id", "")
1052 tvdb_id = media_ids.get("tvdb") or media_ids.get("tvdb_id", "")
1053
1054 if mode == "tv":
1055 if not tmdb_id and imdb_id:
1056 found = tmdb_get("find_by_imdb_id", imdb_id)
1057 if getattr(found, "tv_results", []):
1058 tmdb_id = str(found.tv_results[0]["id"])
1059 if not tmdb_id and tvdb_id:
1060 found = tmdb_get("find_by_tvdb", tvdb_id)
1061 if getattr(found, "tv_results", []):
1062 tmdb_id = str(found.tv_results[0]["id"])
1063 else:
1064 if not tmdb_id and imdb_id:
1065 found = tmdb_get("find_by_imdb_id", imdb_id)
1066 if getattr(found, "movie_results", []):
1067 tmdb_id = str(found.movie_results[0]["id"])
1068
1069 return {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id}
1070
1071 @staticmethod
1072 def show_create_list_entry(mode):
1073 list_item = make_list_item(label=f"[B]+ {translation(30926)}[/B]")
1074 add_kodi_dir_item(
1075 list_item=list_item,
1076 url=build_url("trakt_create_list", mode=mode),
1077 is_folder=False,
1078 )
1079
1080 @staticmethod
1081 def _format_account_info(results):
1082 settings = results.get("settings", {}) if isinstance(results, dict) else {}
1083 stats = results.get("stats", {}) if isinstance(results, dict) else {}
1084 user = settings.get("user", {})
1085 account = settings.get("account", {})
1086 connections = settings.get("connections", {})
1087
1088 lines = [str(translation(30916)), ""]
1089 lines.append(f"Username: {user.get('username', '')}")
1090 lines.append(f"Name: {user.get('name', '')}")
1091 lines.append(
1092 f"Joined: {TraktPresentation._format_iso_date(user.get('joined_at', ''))}"
1093 )
1094 lines.append(
1095 f"Private: {TraktPresentation._format_bool_label(account.get('private', False))}"
1096 )
1097 lines.append(
1098 f"VIP: {TraktPresentation._format_bool_label(user.get('vip', False))}"
1099 )
1100 lines.append(f"Timezone: {account.get('timezone', '')}")
1101 lines.append(f"Locale: {account.get('locale', '')}")
1102 lines.append("")
1103 lines.append("Stats")
1104 lines.append(f"Movies Watched: {stats.get('movies', {}).get('watched', 0)}")
1105 lines.append(f"Shows Watched: {stats.get('shows', {}).get('watched', 0)}")
1106 lines.append(f"Episodes Watched: {stats.get('episodes', {}).get('watched', 0)}")
1107 lines.append(f"Movies Collected: {stats.get('movies', {}).get('collected', 0)}")
1108 lines.append(f"Shows Collected: {stats.get('shows', {}).get('collected', 0)}")
1109 lines.append(f"Lists: {stats.get('lists', {}).get('count', 0)}")
1110 lines.append(f"Ratings: {stats.get('ratings', {}).get('total', 0)}")
1111 lines.append("")
1112 lines.append("Connections")
1113 for key in ("facebook", "google", "twitter", "mastodon"):
1114 connection = connections.get(key, False)
1115 lines.append(
1116 f"{key.title()}: {TraktPresentation._format_bool_label(bool(connection))}"
1117 )
1118 return "\n".join(lines)
1119
1120 @staticmethod
1121 def show_account_info(results):
1122 content = TraktPresentation._format_account_info(results)
1123 dialog_text(translation(30916), content)
1124
1125 @staticmethod
1126 def show_anime_common(res, mode):
1127 ids = extract_ids(res, mode)
1128 # Handle both show and movie responses
1129 if mode == "movies" and "movie" in res:
1130 title = res["movie"]["title"]
1131 else:
1132 title = res["show"]["title"]
1133
1134 tmdb_id = ids["tmdb_id"]
1135 if mode == "tv":
1136 details = tmdb_get("tv_details", tmdb_id)
1137 else:
1138 details = tmdb_get("movie_details", tmdb_id)
1139
1140 list_item = make_list_item(label=title)
1141 set_media_infoTag(list_item, data=details, mode=mode)
1142
1143 BaseTraktClient._add_media_directory_item(
1144 list_item=list_item,
1145 mode=mode,
1146 title=title,
1147 ids=ids,
1148 media_type=res.get("media_type"),
1149 )
1150
1151 @staticmethod
1152 def show_common_categories(res, mode):
1153 if mode == "tv":
1154 title = res["show"]["title"]
1155 ids = extract_ids(res, mode)
1156 tmdb_id = ids["tmdb_id"]
1157 details = tmdb_get("tv_details", tmdb_id)
1158 else:
1159 title = res["movie"]["title"]
1160 ids = extract_ids(res, mode)
1161 tmdb_id = ids["tmdb_id"]
1162 details = tmdb_get("movie_details", tmdb_id)
1163
1164 list_item = make_list_item(label=title)
1165 set_media_infoTag(list_item, data=details, mode=mode)
1166
1167 BaseTraktClient._add_media_directory_item(
1168 list_item=list_item,
1169 mode=mode,
1170 title=title,
1171 ids=ids,
1172 media_type=res.get("media_type"),
1173 )
1174
1175 @staticmethod
1176 def show_watchlist(res, mode):
1177 title = res["title"]
1178 ids = TraktPresentation._resolve_media_ids(mode, res["media_ids"])
1179 tmdb_id = ids["tmdb_id"]
1180 if not tmdb_id:
1181 return
1182
1183 if mode == "tv":
1184 details = tmdb_get("tv_details", tmdb_id)
1185 else:
1186 details = tmdb_get("movie_details", tmdb_id)
1187
1188 list_item = make_list_item(label=title)
1189 set_media_infoTag(list_item, data=details, mode=mode)
1190
1191 BaseTraktClient._add_media_directory_item(
1192 list_item=list_item,
1193 mode=mode,
1194 title=title,
1195 ids=ids,
1196 media_type=res.get("media_type"),
1197 context_flags=BaseTraktClient._exclusive_context_flags("watchlist"),
1198 )
1199
1200 @staticmethod
1201 def show_favorites(res, mode):
1202 title = res["title"]
1203 ids = TraktPresentation._resolve_media_ids(mode, res["media_ids"])
1204 tmdb_id = ids["tmdb_id"]
1205 if not tmdb_id:
1206 return
1207
1208 if mode == "tv":
1209 details = tmdb_get("tv_details", tmdb_id)
1210 else:
1211 details = tmdb_get("movie_details", tmdb_id)
1212
1213 list_item = make_list_item(label=title)
1214 set_media_infoTag(list_item, data=details, mode=mode)
1215
1216 BaseTraktClient._add_media_directory_item(
1217 list_item=list_item,
1218 mode=mode,
1219 title=title,
1220 ids=ids,
1221 media_type=res.get("media_type"),
1222 context_flags=BaseTraktClient._exclusive_context_flags("favorites"),
1223 )
1224
1225 @staticmethod
1226 def show_collection(res, mode):
1227 if mode == "tv":
1228 title = res["show"]["title"]
1229 ids = TraktPresentation._resolve_media_ids(mode, res["show"].get("ids", {}))
1230 tmdb_id = ids["tmdb_id"]
1231 if not tmdb_id:
1232 return
1233 details = tmdb_get("tv_details", tmdb_id)
1234 else:
1235 title = res["movie"]["title"]
1236 ids = TraktPresentation._resolve_media_ids(mode, res["movie"].get("ids", {}))
1237 tmdb_id = ids["tmdb_id"]
1238 if not tmdb_id:
1239 return
1240 details = tmdb_get("movie_details", tmdb_id)
1241
1242 list_item = make_list_item(label=title)
1243 set_media_infoTag(list_item, data=details, mode=mode)
1244
1245 BaseTraktClient._add_media_directory_item(
1246 list_item=list_item,
1247 mode=mode,
1248 title=title,
1249 ids=ids,
1250 media_type=res.get("media_type"),
1251 context_flags=BaseTraktClient._exclusive_context_flags("collection"),
1252 )
1253
1254 @staticmethod
1255 def show_trending_lists(res, mode):
1256 list_title = res["list"]["name"]
1257 description = res["list"]["description"]
1258 user_data = res["list"].get("user", {})
1259 user_ids = user_data.get("ids", {})
1260 user_slug = _normalize_user_slug(
1261 user_ids.get("slug") or user_data.get("username")
1262 )
1263
1264 info_labels = {
1265 "title": list_title,
1266 "plot": description,
1267 }
1268
1269 url = build_url(
1270 "trakt_list_content",
1271 list_type=res["list"]["type"],
1272 mode=mode,
1273 user=user_slug,
1274 slug=res["list"]["ids"]["slug"],
1275 )
1276
1277 list_item = make_list_item(label=list_title)
1278 info_tag = list_item.getVideoInfoTag()
1279 info_tag.setTitle(list_title)
1280 info_tag.setPlot(truncate_text(description))
1281 if user_slug:
1282 list_item.addContextMenuItems(
1283 [
1284 (
1285 "Like Trakt List",
1286 action_url_run(
1287 "trakt_like_list",
1288 trakt_id=res["list"]["ids"].get("trakt"),
1289 user=user_slug,
1290 ),
1291 )
1292 ]
1293 )
1294
1295 add_kodi_dir_item(
1296 list_item,
1297 url=url,
1298 is_folder=True,
1299 )
1300
1301 @staticmethod
1302 def show_user_lists(res, mode, query=None, page=1, search_term="", api="trakt", category=None, submode=""):
1303 list_title = res.get("name", "Untitled List")
1304 description = res.get("description", "")
1305 item_count = res.get("item_count")
1306 username = res.get("username") or res.get("user_slug", "")
1307
1308 if item_count:
1309 label = f"{list_title} ({item_count})"
1310 else:
1311 label = list_title
1312
1313 if username:
1314 label = f"{label} - [I]{username}[/I]"
1315
1316 list_item = make_list_item(label=label)
1317 info_tag = list_item.getVideoInfoTag()
1318 info_tag.setTitle(list_title)
1319 info_tag.setPlot(truncate_text(description))
1320
1321 context_menu = []
1322 if res.get("can_delete"):
1323 context_menu.append(
1324 (
1325 "Delete Trakt List",
1326 action_url_run(
1327 "trakt_delete_list",
1328 trakt_id=res.get("trakt_id", ""),
1329 ),
1330 )
1331 )
1332 if res.get("can_unlike"):
1333 context_menu.append(
1334 (
1335 "Unlike Trakt List",
1336 action_url_run(
1337 "trakt_unlike_list",
1338 trakt_id=res.get("trakt_id", ""),
1339 user=res.get("user_slug", ""),
1340 query=query,
1341 category=category,
1342 mode=mode,
1343 submode=submode,
1344 api=api,
1345 page=page,
1346 search_term=search_term,
1347 ),
1348 )
1349 )
1350 if res.get("can_like"):
1351 context_menu.append(
1352 (
1353 "Like Trakt List",
1354 action_url_run(
1355 "trakt_like_list",
1356 trakt_id=res.get("trakt_id", ""),
1357 user=res.get("user_slug", ""),
1358 query=query,
1359 category=category,
1360 mode=mode,
1361 submode=submode,
1362 api=api,
1363 page=page,
1364 search_term=search_term,
1365 ),
1366 )
1367 )
1368 if context_menu:
1369 list_item.addContextMenuItems(context_menu)
1370
1371 add_kodi_dir_item(
1372 list_item=list_item,
1373 url=build_url(
1374 "trakt_list_content",
1375 list_type=res.get("list_type", "user_lists"),
1376 mode=mode,
1377 user=res.get("user_slug", ""),
1378 slug=res.get("slug", ""),
1379 with_auth=res.get("with_auth", False),
1380 trakt_id=res.get("trakt_id", ""),
1381 ),
1382 is_folder=True,
1383 )
1384
1385 @staticmethod
1386 def show_recommendations(res, mode):
1387 title = res["title"]
1388 tmdb_id = res["ids"]["tmdb"]
1389 imdb_id = res["ids"]["imdb"]
1390 ids = {"tmdb_id": tmdb_id, "tvdb_id": "", "imdb_id": imdb_id}
1391
1392 if mode == "tv":
1393 details = tmdb_get("tv_details", tmdb_id)
1394 else:
1395 details = tmdb_get("movie_details", tmdb_id)
1396
1397 list_item = make_list_item(label=title)
1398 set_media_infoTag(list_item, data=details, mode=mode)
1399
1400 BaseTraktClient._add_media_directory_item(
1401 list_item=list_item,
1402 mode=mode,
1403 title=title,
1404 ids=ids,
1405 media_type=res.get("media_type"),
1406 )
1407
1408 @staticmethod
1409 def show_watched_history(res):
1410 title = res["title"]
1411
1412 if res["type"] == "show":
1413 mode = "tv"
1414 ids = TraktPresentation._resolve_media_ids(mode, res["media_ids"])
1415 tmdb_id = ids["tmdb_id"]
1416 if not tmdb_id:
1417 return
1418 details = tmdb_get("tv_details", tmdb_id)
1419 url = build_url(
1420 "search",
1421 ids=ids,
1422 mode=mode,
1423 query=res["show_title"],
1424 tv_data={
1425 "name": res["ep_title"],
1426 "episode": res["episode"],
1427 "season": res["season"],
1428 },
1429 )
1430 is_folder = True
1431 is_playable = False
1432 else:
1433 mode = "movies"
1434 ids = TraktPresentation._resolve_media_ids(mode, res["media_ids"])
1435 tmdb_id = ids["tmdb_id"]
1436 if not tmdb_id:
1437 return
1438 details = tmdb_get("movie_details", tmdb_id)
1439 url = build_url(
1440 "search",
1441 query=title,
1442 mode=mode,
1443 ids=ids,
1444 )
1445 is_folder = False
1446 is_playable = True
1447
1448 list_item = make_list_item(label=title)
1449 set_media_infoTag(list_item, data=details, mode=mode)
1450 list_item.addContextMenuItems(
1451 BaseTraktClient._trakt_context_menu(
1452 mode,
1453 ids,
1454 context_flags=BaseTraktClient._exclusive_context_flags("watched"),
1455 )
1456 )
1457
1458 add_kodi_dir_item(
1459 list_item,
1460 url=url,
1461 is_folder=is_folder,
1462 is_playable=is_playable,
1463 )
1464
1465 @staticmethod
1466 def show_lists_content_items(res):
1467 title = res["title"]
1468
1469 if res["type"] == "show":
1470 mode = "tv"
1471 ids = TraktPresentation._resolve_media_ids(mode, res.get("media_ids", {}))
1472 tmdb_id = ids["tmdb_id"]
1473 if not tmdb_id:
1474 return
1475 details = tmdb_get("tv_details", tmdb_id)
1476 else:
1477 mode = "movies"
1478 ids = TraktPresentation._resolve_media_ids(mode, res.get("media_ids", {}))
1479 tmdb_id = ids["tmdb_id"]
1480 if not tmdb_id:
1481 return
1482 details = tmdb_get("movie_details", tmdb_id)
1483
1484 list_item = make_list_item(label=title)
1485 set_media_infoTag(list_item, data=details, mode=mode)
1486
1487 BaseTraktClient._add_media_directory_item(
1488 list_item=list_item,
1489 mode=mode,
1490 title=title,
1491 ids=ids,
1492 media_type=res.get("media_type"),
1493 context_flags=BaseTraktClient._exclusive_context_flags("custom_list"),
1494 )
1495
1496 @staticmethod
1497 def show_calendar_items(res):
1498 show_title = res.get("show", {}).get("title")
1499 ep_data = res.get("episode", {})
1500 title = ep_data.get("title")
1501 season = ep_data.get("season")
1502 episode = ep_data.get("number")
1503 if not show_title or not title or season is None or episode is None:
1504 return
1505
1506 first_aired = res.get("first_aired") or ep_data.get("first_aired")
1507
1508 ids = TraktPresentation._resolve_media_ids(
1509 "tv", res.get("show", {}).get("ids", {})
1510 )
1511 tmdb_id = ids["tmdb_id"]
1512 imdb_id = ids["imdb_id"]
1513 tvdb_id = ids["tvdb_id"]
1514
1515 if not tmdb_id:
1516 return
1517
1518 display_title = f"{show_title} - S{int(season):02d}E{int(episode):02d} - {title}"
1519 if first_aired:
1520 date_part = first_aired.split("T")[0]
1521 display_title = f"{date_part} | {display_title}"
1522
1523 details = tmdb_get("tv_details", tmdb_id) or {}
1524
1525 list_item = make_list_item(label=display_title)
1526 set_media_infoTag(list_item, data=details, mode="tv")
1527
1528 # Override title
1529 list_item.setLabel(display_title)
1530 info_tag = list_item.getVideoInfoTag()
1531 info_tag.setTitle(display_title)
1532 info_tag.setPlot(truncate_text(details.get("overview", "")))
1533
1534 url = build_url(
1535 "search",
1536 ids=ids,
1537 mode="tv",
1538 query=show_title,
1539 tv_data={
1540 "name": title,
1541 "episode": episode,
1542 "season": season,
1543 },
1544 )
1545
1546 add_kodi_dir_item(
1547 list_item=list_item,
1548 url=url,
1549 is_folder=True,
1550 )
1551
1552 @staticmethod
1553 def show_up_next_items(res):
1554 show_data = res.get("show", {})
1555 episode = res.get("episode", {})
1556 show_title = show_data.get("title", "")
1557 title = episode.get("title", "")
1558 season = episode.get("season")
1559 episode_number = episode.get("number")
1560 progress = res.get("progress", 0)
1561 tmdb_id = show_data.get("ids", {}).get("tmdb")
1562 imdb_id = show_data.get("ids", {}).get("imdb")
1563 tvdb_id = show_data.get("ids", {}).get("tvdb")
1564
1565 if not tmdb_id or season is None or episode_number is None:
1566 return
1567
1568 prefix = "[Resume]" if res.get("type") == "resume" else "[Next]"
1569 display_title = (
1570 f"{prefix} {show_title} - S{int(season):02d}E{int(episode_number):02d} - {title}"
1571 )
1572 if progress:
1573 display_title = f"{display_title} ({int(progress)}%)"
1574
1575 ids = {"tmdb_id": tmdb_id, "imdb_id": imdb_id, "tvdb_id": tvdb_id}
1576 details = tmdb_get("tv_details", tmdb_id) or {}
1577
1578 list_item = make_list_item(label=display_title)
1579 set_media_infoTag(list_item, data=details, mode="tv")
1580 info_tag = list_item.getVideoInfoTag()
1581 info_tag.setTitle(display_title)
1582 info_tag.setPlot(truncate_text(details.get("overview", "")))
1583 if progress:
1584 list_item.setProperty("PercentPlayed", str(progress))
1585
1586 url = build_url(
1587 "search",
1588 ids=ids,
1589 mode="tv",
1590 query=show_title,
1591 tv_data={
1592 "name": title,
1593 "episode": episode_number,
1594 "season": season,
1595 },
1596 )
1597
1598 add_kodi_dir_item(list_item=list_item, url=url, is_folder=True)
1599
1600 @staticmethod
1601 def show_movies_progress_items(res):
1602 movie_data = res.get("movie", {})
1603 title = movie_data.get("title", "")
1604 progress = res.get("progress", 0)
1605 tmdb_id = movie_data.get("ids", {}).get("tmdb")
1606 imdb_id = movie_data.get("ids", {}).get("imdb")
1607
1608 if not tmdb_id:
1609 return
1610
1611 display_title = f"[Resume] {title}"
1612 if progress:
1613 display_title = f"{display_title} ({int(progress)}%)"
1614
1615 ids = {"tmdb_id": tmdb_id, "imdb_id": imdb_id}
1616 details = tmdb_get("movie_details", tmdb_id) or {}
1617
1618 list_item = make_list_item(label=display_title)
1619 set_media_infoTag(list_item, data=details, mode="movies")
1620 info_tag = list_item.getVideoInfoTag()
1621 info_tag.setTitle(display_title)
1622 info_tag.setPlot(truncate_text(details.get("overview", "")))
1623 if progress:
1624 list_item.setProperty("PercentPlayed", str(progress))
1625
1626 url = build_url(
1627 "search",
1628 ids=ids,
1629 mode="movies",
1630 query=title,
1631 )
1632
1633 add_kodi_dir_item(list_item=list_item, url=url, is_folder=True)
plugin.video.jacktook/lib/clients/trakt/utils.py +23 -0
……@@ -0,0 +1,23 @@
1from lib.utils.kodi.utils import ADDON_HANDLE
2
3from xbmcplugin import addDirectoryItem
4
5
6def add_kodi_dir_item(
7 list_item,
8 url,
9 is_folder,
10 is_playable=False,
11):
12 if is_playable:
13 list_item.setProperty("IsPlayable", "true")
14 addDirectoryItem(ADDON_HANDLE, url, list_item, isFolder=is_folder)
15
16
17def extract_ids(res, mode="tv"):
18 ids = res.get("show" if mode == "tv" else "movie", {}).get("ids", {})
19 return {
20 "tmdb_id": ids.get("tmdb"),
21 "tvdb_id": ids.get("tvdb") if mode == "tv" else None,
22 "imdb_id": ids.get("imdb"),
23 }
plugin.video.jacktook/lib/clients/webdav/client.py +128 -0
……@@ -0,0 +1,128 @@
1import requests
2from lib.api.webdav.webdav import WebDAVClient
3from lib.utils.kodi.utils import (
4 ADDON_HANDLE,
5 build_url,
6 dialog_ok,
7 end_of_directory,
8 get_setting,
9 kodilog,
10 make_list_item,
11 notification,
12 show_picture,
13 translation,
14)
15from xbmcgui import Dialog
16from xbmcplugin import addDirectoryItem, addSortMethod, SORT_METHOD_LABEL_IGNORE_THE, SORT_METHOD_FILE
17
18
19def list_webdav(params):
20 relative_path = params.get("path", "").strip("/")
21
22 client = WebDAVClient(
23 get_setting("webdav_hostname"),
24 get_setting("webdav_username"),
25 get_setting("webdav_password"),
26 get_setting("webdav_port"),
27 get_setting("webdav_remote_path"),
28 )
29
30 items = client.list_dir(relative_path)
31
32 for item in items:
33 list_item = make_list_item(label=item["name"])
34 url = ""
35 is_folder = False
36
37 # --- FOLDERS ---
38 if item["type"] == "folder":
39 new_relative_path = (
40 f"{relative_path}/{item['name']}" if relative_path else item["name"]
41 )
42 url = build_url("list_webdav", path=new_relative_path)
43 is_folder = True
44
45 # --- VIDEOS ---
46 elif item["type"] == "video":
47 list_item.setProperty("IsPlayable", "true")
48 info_tag = list_item.getVideoInfoTag()
49 info_tag.setTitle(item["name"])
50 url = build_url("play_url", url=item["url"], name=item["name"])
51 is_folder = False
52
53 # --- AUDIO ---
54 elif item["type"] == "audio":
55 info_tag = list_item.getMusicInfoTag()
56 info_tag.setTitle(item["name"])
57 list_item.setProperty("IsPlayable", "true")
58 url = build_url("play_url", url=item["url"], name=item["name"])
59 is_folder = False
60
61 # --- IMAGES ---
62 elif item["type"] == "image":
63 list_item.setProperty("IsPlayable", "false")
64 list_item.setArt({"thumb": item["url"]})
65 list_item.setInfo("pictures", {"title": item["name"]})
66
67 name_parts = item["name"].rsplit(".", 1)
68 ext = name_parts[-1].lower() if len(name_parts) > 1 else "jpeg"
69 mime = f"image/{ext}" if ext not in ["jpg", "jpeg"] else "image/jpeg"
70 list_item.setMimeType(mime)
71
72 url = build_url("show_picture_webdav", url=item["url"])
73 is_folder = False
74 elif item["type"] == "text":
75 list_item.setProperty("IsPlayable", "false")
76 url = build_url("display_text_webdav", url=item["url"], title=item["name"])
77 is_folder = False
78 else:
79 list_item.setProperty("IsPlayable", "false")
80 url = item["url"]
81 is_folder = False
82
83 addDirectoryItem(
84 handle=ADDON_HANDLE, url=url, listitem=list_item, isFolder=is_folder
85 )
86
87 addSortMethod(ADDON_HANDLE, SORT_METHOD_LABEL_IGNORE_THE)
88 addSortMethod(ADDON_HANDLE, SORT_METHOD_FILE)
89
90 end_of_directory()
91
92
93def webdav_provider_test(params):
94 client = WebDAVClient(
95 get_setting("webdav_hostname"),
96 get_setting("webdav_username"),
97 get_setting("webdav_password"),
98 get_setting("webdav_port"),
99 get_setting("webdav_remote_path"),
100 )
101 result = client.test_connection()
102 if result["success"]:
103 dialog_ok(translation(90131), result["message"])
104 else:
105 dialog_ok(translation(90562), result["message"])
106
107
108def display_text_webdav(params):
109 url = params.get("url")
110 title = params.get("title", translation(90663))
111 if not url:
112 return
113 try:
114 r = requests.get(url, timeout=15)
115 r.raise_for_status()
116 r.encoding = r.apparent_encoding
117 content = r.text
118 Dialog().textviewer(title, content)
119 except requests.exceptions.RequestException as e:
120 kodilog(f"Error downloading text file ({url}): {e}")
121 notification(translation(90597), translation(90598) % title)
122 except Exception as e:
123 kodilog(f"General error displaying text: {e}")
124 notification(translation(90599), translation(90600))
125
126
127def show_picture_webdav(params):
128 show_picture(url=params.get("url"))
plugin.video.jacktook/lib/clients/youtube_resolver.py +534 -0
……@@ -0,0 +1,534 @@
1import re
2import time
3from typing import Dict, List, Optional
4from urllib.parse import parse_qs, urlparse
5
6import requests
7
8from lib.clients.tmdb.trailers import resolve_tmdb_trailer
9from lib.utils.kodi.utils import kodilog
10
11
12WATCH_URL = "https://www.youtube.com/watch"
13PLAYER_URL = "https://www.youtube.com/youtubei/v1/player"
14CACHE_TTL_SECONDS = 300
15VIDEO_CONTAINER_NEAR_TIE_BITRATE_MARGIN = 250000
16VIDEO_CONTAINER_NEAR_TIE_RATIO = 0.10
17WATCH_HEADERS = {
18 "User-Agent": (
19 "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
20 "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
21 ),
22 "Accept-Language": "en-US,en;q=0.9",
23}
24YOUTUBE_CLIENTS = [
25 {
26 "key": "android_vr",
27 "clientName": "ANDROID_VR",
28 "clientVersion": "1.56.21",
29 "client_id": "28",
30 "user_agent": (
31 "com.google.android.apps.youtube.vr.oculus/1.56.21 "
32 "(Linux; U; Android 12; en_US; Quest 3; Build/SQ3A.220605.009.A1) gzip"
33 ),
34 "context": {
35 "clientName": "ANDROID_VR",
36 "clientVersion": "1.56.21",
37 "deviceMake": "Oculus",
38 "deviceModel": "Quest 3",
39 "osName": "Android",
40 "osVersion": "12",
41 "platform": "MOBILE",
42 "androidSdkVersion": 32,
43 "hl": "en",
44 "gl": "US",
45 },
46 },
47 {
48 "key": "android",
49 "clientName": "ANDROID",
50 "clientVersion": "20.10.35",
51 "client_id": "3",
52 "user_agent": "com.google.android.youtube/20.10.35 (Linux; U; Android 14; en_US) gzip",
53 "context": {
54 "clientName": "ANDROID",
55 "clientVersion": "20.10.35",
56 "osName": "Android",
57 "osVersion": "14",
58 "platform": "MOBILE",
59 "androidSdkVersion": 34,
60 "hl": "en",
61 "gl": "US",
62 },
63 },
64 {
65 "key": "ios",
66 "clientName": "IOS",
67 "clientVersion": "20.10.1",
68 "client_id": "5",
69 "user_agent": "com.google.ios.youtube/20.10.1 (iPhone16,2; U; CPU iOS 17_4 like Mac OS X)",
70 "context": {
71 "clientName": "IOS",
72 "clientVersion": "20.10.1",
73 "deviceModel": "iPhone16,2",
74 "osName": "iPhone",
75 "osVersion": "17.4.0.21E219",
76 "platform": "MOBILE",
77 "hl": "en",
78 "gl": "US",
79 },
80 },
81]
82_VIDEO_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{11}$")
83_RESOLVER_CACHE = {}
84
85
86def extract_video_id(value: Optional[str]) -> Optional[str]:
87 if not value:
88 return None
89
90 candidate = value.strip()
91 if _VIDEO_ID_PATTERN.match(candidate):
92 return candidate
93
94 try:
95 parsed = urlparse(candidate)
96 except Exception:
97 return None
98
99 host = (parsed.netloc or "").lower()
100 path_parts = [part for part in parsed.path.split("/") if part]
101
102 if host in ("youtu.be", "www.youtu.be") and path_parts:
103 return path_parts[0] if _VIDEO_ID_PATTERN.match(path_parts[0]) else None
104
105 if host.endswith("youtube.com") or host.endswith("youtube-nocookie.com"):
106 query_video_id = parse_qs(parsed.query).get("v", [None])[0]
107 if query_video_id and _VIDEO_ID_PATTERN.match(query_video_id):
108 return query_video_id
109
110 if len(path_parts) >= 2 and path_parts[0] in ("embed", "shorts", "live"):
111 return path_parts[1] if _VIDEO_ID_PATTERN.match(path_parts[1]) else None
112
113 return None
114
115
116def parse_watch_config(html: str) -> Optional[Dict[str, Optional[str]]]:
117 if not html:
118 return None
119
120 api_key_match = re.search(r'"INNERTUBE_API_KEY"\s*:\s*"([^"]+)"', html)
121 if not api_key_match:
122 return None
123
124 visitor_match = re.search(r'"VISITOR_DATA"\s*:\s*"([^"]+)"', html)
125 return {
126 "api_key": api_key_match.group(1),
127 "visitor_data": visitor_match.group(1) if visitor_match else None,
128 }
129
130
131def _video_score(fmt: Dict[str, object]):
132 return (
133 int(fmt.get("height") or 0),
134 int(fmt.get("fps") or 0),
135 int(fmt.get("bitrate") or fmt.get("averageBitrate") or 0),
136 )
137
138
139def _audio_score(fmt: Dict[str, object]):
140 sample_rate = fmt.get("audioSampleRate") or 0
141 try:
142 sample_rate = int(sample_rate)
143 except (TypeError, ValueError):
144 sample_rate = 0
145
146 return (
147 int(fmt.get("bitrate") or fmt.get("averageBitrate") or 0),
148 sample_rate,
149 )
150
151
152def _container_rank(mime_type: object) -> int:
153 mime_type = str(mime_type or "").lower()
154 if "mp4" in mime_type or "m4a" in mime_type:
155 return 0
156 if "webm" in mime_type:
157 return 1
158 return 2
159
160
161def _video_rank(fmt: Dict[str, object]):
162 height, fps, bitrate = _video_score(fmt)
163 return (height, fps, -_container_rank(fmt.get("mimeType")), bitrate)
164
165
166def _audio_rank(fmt: Dict[str, object]):
167 bitrate, sample_rate = _audio_score(fmt)
168 return (bitrate, sample_rate, -_container_rank(fmt.get("mimeType")))
169
170
171def _video_bitrate_near_tie(left_bitrate: int, right_bitrate: int) -> bool:
172 margin = max(
173 VIDEO_CONTAINER_NEAR_TIE_BITRATE_MARGIN,
174 int(max(left_bitrate, right_bitrate) * VIDEO_CONTAINER_NEAR_TIE_RATIO),
175 )
176 return abs(left_bitrate - right_bitrate) <= margin
177
178
179def _choose_better_video_format(best_fmt: Dict[str, object], candidate_fmt: Dict[str, object]):
180 best_height, best_fps, best_bitrate = _video_score(best_fmt)
181 candidate_height, candidate_fps, candidate_bitrate = _video_score(candidate_fmt)
182
183 if candidate_height != best_height:
184 return candidate_fmt if candidate_height > best_height else best_fmt
185
186 if candidate_fps != best_fps:
187 return candidate_fmt if candidate_fps > best_fps else best_fmt
188
189 best_container_rank = _container_rank(best_fmt.get("mimeType"))
190 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)
194 ):
195 return candidate_fmt if candidate_container_rank < best_container_rank else best_fmt
196
197 if candidate_bitrate != best_bitrate:
198 return candidate_fmt if candidate_bitrate > best_bitrate else best_fmt
199
200 if candidate_container_rank != best_container_rank:
201 return candidate_fmt if candidate_container_rank < best_container_rank else best_fmt
202
203 return candidate_fmt if _video_rank(candidate_fmt) > _video_rank(best_fmt) else best_fmt
204
205
206def _select_best_video_format(formats: List[Dict[str, object]]) -> Optional[Dict[str, object]]:
207 if not formats:
208 return None
209
210 best_format = formats[0]
211 for fmt in formats[1:]:
212 best_format = _choose_better_video_format(best_format, fmt)
213
214 return best_format
215
216
217def select_best_stream(player_response: Dict) -> Optional[Dict[str, Optional[str]]]:
218 streaming_data = player_response.get("streamingData") or {}
219 hls_manifest_url = streaming_data.get("hlsManifestUrl")
220
221 progressive_formats = []
222 for fmt in streaming_data.get("formats") or []:
223 mime_type = str(fmt.get("mimeType") or "")
224 url = fmt.get("url")
225 if not url or "video/" not in mime_type:
226 continue
227 progressive_formats.append(fmt)
228
229 adaptive_video_formats = []
230 adaptive_audio_formats = []
231 for fmt in streaming_data.get("adaptiveFormats") or []:
232 mime_type = str(fmt.get("mimeType") or "")
233 url = fmt.get("url")
234 if not url:
235 continue
236 if "video/" in mime_type:
237 adaptive_video_formats.append(fmt)
238 elif "audio/" in mime_type:
239 adaptive_audio_formats.append(fmt)
240
241 if adaptive_video_formats and adaptive_audio_formats:
242 best_video = _select_best_video_format(adaptive_video_formats)
243 best_audio = max(adaptive_audio_formats, key=_audio_rank)
244 return {
245 "video_url": best_video.get("url"),
246 "audio_url": best_audio.get("url"),
247 "source_type": "adaptive",
248 }
249
250 if progressive_formats:
251 best_format = _select_best_video_format(progressive_formats)
252 return {
253 "video_url": best_format.get("url"),
254 "audio_url": None,
255 "source_type": "progressive",
256 }
257
258 if hls_manifest_url:
259 return {
260 "video_url": hls_manifest_url,
261 "audio_url": None,
262 "source_type": "hls",
263 }
264
265 return None
266
267
268def resolve_trailer_playback(
269 video_id: Optional[str] = None,
270 youtube_url: Optional[str] = None,
271 session: Optional[requests.Session] = None,
272 cache_ttl: int = CACHE_TTL_SECONDS,
273) -> Optional[Dict[str, Optional[str]]]:
274 normalized_video_id = extract_video_id(video_id or youtube_url)
275 if not normalized_video_id:
276 kodilog(
277 f"Trailer/YouTube: invalid video id input video_id={video_id!r} youtube_url={youtube_url!r}"
278 )
279 return None
280
281 cached = _get_cached_result(normalized_video_id)
282 if cached:
283 return cached
284
285 http_session = session or requests.Session()
286
287 try:
288 watch_response = http_session.get(
289 WATCH_URL,
290 params={"v": normalized_video_id},
291 headers=WATCH_HEADERS.copy(),
292 timeout=10,
293 )
294 watch_response.raise_for_status()
295 except Exception as exc:
296 kodilog(
297 f"Trailer/YouTube: watch request failed video_id={normalized_video_id!r} error={exc}"
298 )
299 return None
300
301 watch_config = parse_watch_config(watch_response.text)
302 if not watch_config:
303 kodilog(
304 f"Trailer/YouTube: missing watch config video_id={normalized_video_id!r}"
305 )
306 return None
307
308 for client in YOUTUBE_CLIENTS:
309 payload = _call_player_api(
310 http_session=http_session,
311 client=client,
312 watch_config=watch_config,
313 normalized_video_id=normalized_video_id,
314 )
315 if payload and payload.get("_request_failed"):
316 kodilog(
317 "Trailer/YouTube: player api failed "
318 f"video_id={normalized_video_id!r} client={client['clientName']!r} "
319 f"status_code={payload.get('_status_code')!r} error={payload.get('_error')!r} "
320 f"preview={payload.get('_preview')!r}"
321 )
322 continue
323
324 if not payload:
325 kodilog(
326 f"Trailer/YouTube: player api failed video_id={normalized_video_id!r} client={client['clientName']!r}"
327 )
328 continue
329
330 if not _is_playable(payload):
331 status = ((payload.get("playabilityStatus") or {}).get("status") or "").upper()
332 kodilog(
333 f"Trailer/YouTube: unplayable response video_id={normalized_video_id!r} client={client['clientName']!r} status={status!r}"
334 )
335 continue
336
337 stream = select_best_stream(payload)
338 if not stream:
339 kodilog(
340 f"Trailer/YouTube: no playable stream video_id={normalized_video_id!r} client={client['clientName']!r}"
341 )
342 continue
343
344 result = dict(stream)
345 result["video_id"] = normalized_video_id
346 _set_cached_result(normalized_video_id, result, cache_ttl)
347 kodilog(
348 f"Trailer/YouTube: resolved video_id={normalized_video_id!r} client={client['clientName']!r} source_type={result.get('source_type')!r}"
349 )
350 return result
351
352 kodilog(f"Trailer/YouTube: exhausted clients without stream video_id={normalized_video_id!r}")
353 return None
354
355
356def resolve_item_trailer_playback(
357 item=None,
358 yt_id: Optional[str] = None,
359 youtube_url: Optional[str] = None,
360 tmdb_id: Optional[object] = None,
361 media_type: Optional[str] = None,
362 session: Optional[requests.Session] = None,
363 cache_ttl: int = CACHE_TTL_SECONDS,
364) -> Optional[Dict[str, Optional[str]]]:
365 result = resolve_item_trailer(
366 item=item,
367 yt_id=yt_id,
368 youtube_url=youtube_url,
369 tmdb_id=tmdb_id,
370 media_type=media_type,
371 session=session,
372 cache_ttl=cache_ttl,
373 )
374
375 return result.get("playback") if result else None
376
377
378def resolve_item_trailer(
379 item=None,
380 yt_id: Optional[str] = None,
381 youtube_url: Optional[str] = None,
382 tmdb_id: Optional[object] = None,
383 media_type: Optional[str] = None,
384 session: Optional[requests.Session] = None,
385 cache_ttl: int = CACHE_TTL_SECONDS,
386) -> Optional[Dict[str, Optional[Dict[str, Optional[str]]]]]:
387 trailer = resolve_item_trailer_metadata(
388 item=item,
389 yt_id=yt_id,
390 youtube_url=youtube_url,
391 tmdb_id=tmdb_id,
392 media_type=media_type,
393 )
394
395 if not trailer:
396 return None
397
398 playback = resolve_trailer_playback(
399 video_id=trailer.get("yt_id"),
400 youtube_url=trailer.get("youtube_url"),
401 session=session,
402 cache_ttl=cache_ttl,
403 )
404
405 return {
406 "trailer": trailer,
407 "playback": playback,
408 }
409
410
411def resolve_item_trailer_metadata(
412 item=None,
413 yt_id: Optional[str] = None,
414 youtube_url: Optional[str] = None,
415 tmdb_id: Optional[object] = None,
416 media_type: Optional[str] = None,
417) -> Optional[Dict[str, Optional[str]]]:
418 direct_video_id = yt_id or _get_item_value(item, "yt_id", "ytId")
419 direct_youtube_url = youtube_url or _get_item_value(item, "youtube_url", "youtubeUrl")
420 resolved_tmdb_id = tmdb_id or _get_item_value(item, "tmdb_id", "id")
421 resolved_media_type = media_type or _get_item_value(item, "media_type", "type")
422
423 trailer = None
424 if direct_video_id or direct_youtube_url:
425 kodilog(
426 f"Trailer: using direct youtube metadata tmdb_id={resolved_tmdb_id!r} media_type={resolved_media_type!r} yt_id={direct_video_id!r} youtube_url={direct_youtube_url!r}"
427 )
428 trailer = {
429 "yt_id": direct_video_id,
430 "youtube_url": direct_youtube_url,
431 }
432 else:
433 kodilog(
434 f"Trailer: requesting TMDB trailer tmdb_id={resolved_tmdb_id!r} media_type={resolved_media_type!r}"
435 )
436 trailer = resolve_tmdb_trailer(resolved_tmdb_id, resolved_media_type)
437
438 if not trailer:
439 kodilog(
440 f"Trailer: no trailer metadata available tmdb_id={resolved_tmdb_id!r} media_type={resolved_media_type!r}"
441 )
442 return None
443
444 return trailer
445
446
447def _call_player_api(
448 http_session: requests.Session,
449 client: Dict[str, object],
450 watch_config: Dict[str, Optional[str]],
451 normalized_video_id: str,
452) -> Optional[Dict]:
453 headers = WATCH_HEADERS.copy()
454 headers.update(
455 {
456 "Content-Type": "application/json",
457 "Origin": "https://www.youtube.com",
458 "User-Agent": str(client["user_agent"]),
459 "X-YouTube-Client-Name": str(client["client_id"]),
460 "X-YouTube-Client-Version": str(client["clientVersion"]),
461 }
462 )
463 if watch_config.get("visitor_data"):
464 headers["X-Goog-Visitor-Id"] = str(watch_config["visitor_data"])
465
466 try:
467 response = http_session.post(
468 PLAYER_URL,
469 params={"key": watch_config["api_key"]},
470 headers=headers,
471 json={
472 "videoId": normalized_video_id,
473 "contentCheckOk": True,
474 "racyCheckOk": True,
475 "context": {"client": dict(client["context"])},
476 "playbackContext": {
477 "contentPlaybackContext": {
478 "html5Preference": "HTML5_PREF_WANTS",
479 }
480 },
481 },
482 timeout=10,
483 )
484 response.raise_for_status()
485 return response.json()
486 except Exception as exc:
487 preview = ""
488 status_code = None
489 if "response" in locals():
490 preview = getattr(response, "text", "")[:200]
491 status_code = getattr(response, "status_code", None)
492 return {
493 "_request_failed": True,
494 "_error": str(exc),
495 "_preview": preview,
496 "_status_code": status_code,
497 }
498
499
500def _is_playable(player_response: Dict) -> bool:
501 status = ((player_response.get("playabilityStatus") or {}).get("status") or "").upper()
502 return status == "OK"
503
504
505def _get_item_value(item, *keys):
506 if item is None:
507 return None
508
509 for key in keys:
510 if isinstance(item, dict) and key in item:
511 return item.get(key)
512 if hasattr(item, key):
513 return getattr(item, key)
514
515 return None
516
517
518def _get_cached_result(video_id: str) -> Optional[Dict[str, Optional[str]]]:
519 cached = _RESOLVER_CACHE.get(video_id)
520 if not cached:
521 return None
522
523 if cached["expires_at"] <= time.time():
524 _RESOLVER_CACHE.pop(video_id, None)
525 return None
526
527 return dict(cached["value"])
528
529
530def _set_cached_result(video_id: str, value: Dict[str, Optional[str]], ttl: int) -> None:
531 _RESOLVER_CACHE[video_id] = {
532 "expires_at": time.time() + max(ttl, 0),
533 "value": dict(value),
534 }
plugin.video.jacktook/lib/clients/zilean.py +126 -0
……@@ -0,0 +1,126 @@
1import json
2import logging
3from types import SimpleNamespace
4from requests import ConnectTimeout, ReadTimeout
5from requests.exceptions import RequestException
6from lib.clients.base import BaseClient, TorrentStream
7from lib.utils.general.utils import USER_AGENT_HEADER, info_hash_to_magnet
8from typing import List, Optional, Dict, Any, Callable
9from lib.utils.kodi.utils import notification
10
11
12class Zilean(BaseClient):
13 def __init__(self, host: str, timeout: int, notification: Callable) -> None:
14 super().__init__(host, notification)
15 self.timeout = timeout
16 self.initialized = self.validate()
17 if not self.initialized:
18 return
19
20 def validate(self) -> bool:
21 try:
22 response = self.ping()
23 return response.ok
24 except Exception as e:
25 notification(f"Zilean failed to initialize: {e}")
26 return False
27
28 def search(
29 self,
30 query: str,
31 mode: str,
32 media_type: str,
33 season: Optional[int],
34 episode: Optional[int],
35 ) -> Optional[List[TorrentStream]]:
36 try:
37 data = self.api_scrape(query, mode, media_type, season, episode)
38 if not data:
39 return None
40 return self.parse_response(data)
41 except RateLimitExceeded:
42 logging.warning(f"Zilean ratelimit exceeded for query: {query}")
43 except ConnectTimeout:
44 logging.warning(f"Zilean connection timeout for query: {query}")
45 except ReadTimeout:
46 logging.warning(f"Zilean read timeout for query: {query}")
47 except RequestException as e:
48 logging.error(f"Zilean request exception: {e}")
49 except Exception as e:
50 logging.error(f"Zilean exception thrown: {e}")
51
52 def api_scrape(
53 self,
54 query: str,
55 mode: str,
56 media_type: str,
57 season: Optional[int],
58 episode: Optional[int],
59 ) -> Optional[List[Dict[str, Any]]]:
60 filtered_url = f"{self.host}/dmm/filtered"
61 search_url = f"{self.host}/dmm/search"
62
63 if mode in {"tv", "movies"} or media_type in {"tv", "movies"}:
64 params: Dict[str, Any] = {"Query": query}
65 if mode == "tv" or media_type == "tv":
66 params.update({"Season": season, "Episode": episode})
67
68 res = self.session.get(
69 filtered_url, params=params, headers=USER_AGENT_HEADER, timeout=10
70 )
71 else:
72 payload = {"queryText": query}
73 res = self.session.post(
74 search_url,
75 json=payload,
76 headers=USER_AGENT_HEADER,
77 timeout=self.timeout,
78 )
79 if res.status_code != 200:
80 return
81 response = json.loads(
82 res.content, object_hook=lambda item: SimpleNamespace(**item)
83 )
84 torrents = []
85 for result in response:
86 torrents.append(
87 {
88 "infoHash": result.info_hash,
89 "filename": result.raw_title,
90 "filesize": result.size,
91 "languages": result.languages,
92 }
93 )
94 return torrents
95
96 def parse_response(self, data: List[Dict[str, Any]]) -> List[TorrentStream]:
97 results = []
98 for item in data:
99 results.append(
100 TorrentStream(
101 title=item["filename"],
102 type="Torrent",
103 indexer="Zilean",
104 guid=info_hash_to_magnet(item["infoHash"]),
105 infoHash=item["infoHash"],
106 size=item["filesize"],
107 seeders=0,
108 languages=item["languages"],
109 fullLanguages="",
110 publishDate="",
111 peers=0,
112 provider="",
113 )
114 )
115 return results
116
117 def ping(self, additional_headers: Optional[Dict[str, str]] = None) -> Any:
118 return self.session.get(
119 f"{self.host}/healthchecks/ping",
120 headers=additional_headers,
121 timeout=self.timeout,
122 )
123
124
125class RateLimitExceeded(Exception):
126 pass
plugin.video.jacktook/lib/db/anime.py +65 -0
……@@ -0,0 +1,65 @@
1import os
2import threading
3from sqlite3 import dbapi2 as db
4from lib.utils.kodi.utils import notification, translation
5
6import xbmcaddon
7from xbmcvfs import translatePath
8
9
10try:
11 if xbmc.getCondVisibility("System.HasAddon(script.otaku.mappings)"):
12 OTAKU_ADDON = xbmcaddon.Addon("script.otaku.mappings")
13 else:
14 OTAKU_ADDON = None
15 TRANSLATEPATH = translatePath
16 if OTAKU_ADDON:
17 mappingPath = TRANSLATEPATH(OTAKU_ADDON.getAddonInfo("path"))
18 mappingDB = os.path.join(mappingPath, "resources", "data", "anime_mappings.db")
19 else:
20 mappingDB = None
21except:
22 OTAKU_ADDON = None
23 mappingDB = None
24
25mappingDB_lock = threading.Lock()
26
27
28def get_all_ids(anilist_id):
29 if OTAKU_ADDON is None:
30 notification(translation(90427))
31 return
32 mappingDB_lock.acquire()
33 conn = db.connect(mappingDB, timeout=60.0)
34 conn.row_factory = _dict_factory
35 conn.execute("PRAGMA FOREIGN_KEYS = 1")
36 cursor = conn.cursor()
37 cursor.execute("SELECT * FROM anime WHERE anilist_id IN ({0})".format(anilist_id))
38 mapping = cursor.fetchone()
39 cursor.close()
40 try_release_lock(mappingDB_lock)
41 all_ids = {}
42 if mapping:
43 if mapping["thetvdb_id"] is not None:
44 all_ids.update({"tvdb": str(mapping["thetvdb_id"])})
45 if mapping["themoviedb_id"] is not None:
46 all_ids.update({"tmdb": str(mapping["themoviedb_id"])})
47 if mapping["anidb_id"] is not None:
48 all_ids.update({"anidb": str(mapping["anidb_id"])})
49 if mapping["imdb_id"] is not None:
50 all_ids.update({"imdb": str(mapping["imdb_id"])})
51 if mapping["trakt_id"] is not None:
52 all_ids.update({"trakt": str(mapping["trakt_id"])})
53 return all_ids
54
55
56def try_release_lock(lock):
57 if lock.locked():
58 lock.release()
59
60
61def _dict_factory(cursor, row):
62 d = {}
63 for idx, col in enumerate(cursor.description):
64 d[col[0]] = row[idx]
65 return d
plugin.video.jacktook/lib/db/cached.py +429 -0
……@@ -0,0 +1,429 @@
1import os
2import pickle
3import sqlite3
4import sys
5import threading
6import traceback
7import re
8
9from base64 import b64encode, b64decode
10from datetime import datetime, timedelta
11from hashlib import sha256
12
13from lib.jacktook.utils import kodilog
14
15import xbmcaddon
16import xbmc
17import xbmcgui
18
19PY3 = sys.version_info.major >= 3
20if PY3:
21 from xbmcvfs import translatePath
22else:
23 from xbmc import translatePath
24
25ADDON_DATA = translatePath(
26 xbmcaddon.Addon("plugin.video.jacktook").getAddonInfo("profile")
27)
28ADDON_ID = xbmcaddon.Addon().getAddonInfo("id")
29
30if not PY3:
31 ADDON_DATA = ADDON_DATA.decode("utf-8")
32
33if not os.path.exists(ADDON_DATA):
34 os.makedirs(ADDON_DATA)
35
36SQLITE_SETTINGS = {
37 "journal_mode": "wal",
38 "auto_vacuum": "full",
39 "cache_size": 8 * 1024,
40 "mmap_size": 64 * 1024 * 1024,
41 "synchronous": "normal",
42}
43
44
45def pickle_hash(obj):
46 data = pickle.dumps(obj)
47 h = sha256()
48 h.update(data)
49 return h.hexdigest()
50
51
52class _BaseCache(object):
53 __instance = None
54
55 _load_func = staticmethod(pickle.loads)
56 _dump_func = staticmethod(pickle.dumps)
57 _hash_func = staticmethod(pickle_hash)
58
59 @classmethod
60 def get_instance(cls):
61 if cls.__instance is None:
62 cls.__instance = cls()
63 return cls.__instance
64
65 def _get(self, key, default=None, hashed_key=False, identifier=""):
66 result = self.get(self._generate_key(key, hashed_key, identifier))
67 return result if result is not None else default
68
69 def _set(self, key, data, expiry_time, hashed_key=False, identifier=""):
70 if expiry_time == timedelta(0):
71 return # Do nothing, as it will expire immediately
72
73 self.set(
74 self._generate_key(key, hashed_key, identifier),
75 data,
76 expiry_time,
77 )
78
79 def close(self):
80 pass
81
82 def _generate_key(self, key, hashed_key=False, identifier=""):
83 if not hashed_key:
84 key = self._hash_func(key)
85 if identifier:
86 key += identifier
87 return key
88
89 def _process(self, obj):
90 return obj
91
92 def _prepare(self, s):
93 return s
94
95
96class RuntimeCache:
97 _store = {}
98
99 @classmethod
100 def set(cls, key, value):
101 cls._store[key] = value
102
103 @classmethod
104 def get(cls, key):
105 return cls._store.get(key)
106
107 @classmethod
108 def delete(cls, key):
109 if key in cls._store:
110 del cls._store[key]
111
112 @classmethod
113 def clear(cls):
114 cls._store.clear()
115
116
117class MemoryCache(_BaseCache):
118 def __init__(self, database=ADDON_ID):
119 self._window = xbmcgui.Window(10000)
120 self._database = database + "."
121
122 def get(self, key):
123 data = self._window.getProperty(self._database + key)
124 if data:
125 try:
126 decoded_data = self._load_func(b64decode(data))
127 # Check for (data, expires) tuple format
128 if isinstance(decoded_data, tuple) and len(decoded_data) == 2:
129 val, expires = decoded_data
130 if isinstance(expires, datetime):
131 if expires > datetime.utcnow():
132 return val
133 else:
134 self.delete(key)
135 return None
136 return decoded_data
137 except Exception:
138 return None
139
140 def set(self, key, data, expires=timedelta(hours=24)):
141 self._add_key_to_index(key)
142 try:
143 # Wrap data with expiry
144 expiry_dt = datetime.utcnow() + expires
145 val_to_store = (data, expiry_dt)
146
147 blob = self._dump_func(val_to_store)
148 self._window.setProperty(self._database + key, b64encode(blob).decode())
149 except Exception as e:
150 kodilog(
151 f"[MemoryCache] Error storing key {key!r}: {str(e)}",
152 level=xbmc.LOGERROR,
153 )
154 kodilog(traceback.format_exc(), level=xbmc.LOGERROR)
155
156 def delete(self, key):
157 self._remove_key_from_index(key)
158 self._window.clearProperty(self._database + key)
159
160 def delete_like(self, pattern):
161 # Convert SQL LIKE pattern to regex
162 regex_pattern = (
163 re.escape(pattern)
164 .replace(r"\%", ".*")
165 .replace("%", ".*")
166 .replace(r"\_", ".")
167 .replace("_", ".")
168 )
169 regex = re.compile(f"^{regex_pattern}$")
170
171 keys = self._get_key_index()
172 to_delete = [k for k in keys if regex.match(k)]
173
174 for k in to_delete:
175 self.delete(k)
176
177 def clean_all(self):
178 keys = self._get_key_index()
179 for k in keys:
180 self._window.clearProperty(self._database + k)
181 self._window.clearProperty(self._database + "_KEY_INDEX_")
182
183 def _get_key_index(self):
184 try:
185 data = self._window.getProperty(self._database + "_KEY_INDEX_")
186 if data:
187 return self._load_func(b64decode(data))
188 except:
189 pass
190 return set()
191
192 def _save_key_index(self, index):
193 try:
194 blob = self._dump_func(index)
195 self._window.setProperty(
196 self._database + "_KEY_INDEX_", b64encode(blob).decode()
197 )
198 except:
199 pass
200
201 def _add_key_to_index(self, key):
202 index = self._get_key_index()
203 if key not in index:
204 index.add(key)
205 self._save_key_index(index)
206
207 def _remove_key_from_index(self, key):
208 index = self._get_key_index()
209 if key in index:
210 index.remove(key)
211 self._save_key_index(index)
212
213
214class HybridCache(_BaseCache):
215 def __init__(self):
216 self.memory = MemoryCache()
217 self.db = SQLiteCache()
218
219 def get(self, key):
220 # L1: Memory
221 data = self.memory.get(key)
222 if data is not None:
223 return data
224
225 # L2: Database
226 data = self.db.get(key)
227 if data is not None:
228 self.memory.set(key, data, expires=timedelta(minutes=30))
229 return data
230 return None
231
232 def set(self, key, data, expires=timedelta(hours=24)):
233 # Write to both
234 self.memory.set(key, data, expires)
235 self.db.set(key, data, expires)
236
237 def delete(self, key):
238 self.memory.delete(key)
239 self.db.delete(key)
240
241 def add_to_list(self, key, item, expires):
242 existing_data = self.get_list(key)
243 if item in existing_data:
244 existing_data.remove(item)
245 existing_data.append(item)
246 self.set(key, existing_data, expires)
247
248 def get_list(self, key):
249 result = self.get(key)
250 if result:
251 seen = set()
252 deduped = []
253 for item in reversed(result):
254 t = tuple(item)
255 if t not in seen:
256 seen.add(t)
257 deduped.append(t)
258 deduped.reverse()
259 return deduped
260 return []
261
262 def clear_list(self, key):
263 self.set(key, [], expires=timedelta(seconds=1))
264
265 def clean_all(self):
266 self.memory.clean_all()
267 self.db.clean_all()
268
269 def delete_like(self, pattern):
270 self.memory.delete_like(pattern)
271 self.db.delete_like(pattern)
272
273
274class SQLiteCache(_BaseCache):
275 def __init__(
276 self,
277 database=os.path.join(ADDON_DATA, ADDON_ID + ".cached.sqlite"),
278 cleanup_interval=timedelta(minutes=15),
279 ):
280 self._conn = sqlite3.connect(
281 database,
282 detect_types=sqlite3.PARSE_DECLTYPES,
283 isolation_level=None,
284 check_same_thread=False,
285 )
286 self._conn.execute(
287 "CREATE TABLE IF NOT EXISTS `cached` ("
288 "key TEXT PRIMARY KEY NOT NULL, "
289 "data BLOB NOT NULL, "
290 "expires TIMESTAMP NOT NULL"
291 ")"
292 )
293 for k, v in SQLITE_SETTINGS.items():
294 self._conn.execute("PRAGMA {}={}".format(k, v))
295 self._cleanup_interval = cleanup_interval
296 self._last_cleanup = datetime.utcnow()
297 self.clean_up()
298 self._object_store = {} # store raw objects that can't be pickled
299 self._lock = threading.Lock()
300
301 def _process(self, obj):
302 return self._load_func(obj)
303
304 def _prepare(self, s):
305 return self._dump_func(s)
306
307 def add_to_list(self, key, item, expires):
308 """Append an item to a list stored under the given key."""
309 existing_data = self.get_list(key)
310 if item in existing_data:
311 existing_data.remove(item)
312 existing_data.append(item)
313 self.set(
314 key,
315 existing_data,
316 expires,
317 )
318
319 def get_list(self, key):
320 """Retrieve the list stored under the given key."""
321 result = self.get(key)
322 if result:
323 seen = set()
324 deduped = []
325 for item in reversed(result):
326 t = tuple(item)
327 if t not in seen:
328 seen.add(t)
329 deduped.append(t)
330 deduped.reverse()
331 return deduped
332 return []
333
334 def get(self, key):
335 with self._lock:
336 if key in self._object_store:
337 data, expires = self._object_store[key]
338 if expires > datetime.utcnow():
339 return data
340 else:
341 del self._object_store[key]
342 return None
343 self.check_clean_up()
344 try:
345 result = self._conn.execute(
346 "SELECT data, expires FROM `cached` WHERE key = ?", (key,)
347 ).fetchone()
348 except Exception as e:
349 kodilog(f"SQL error for key {key!r}: {e}", level=xbmc.LOGERROR)
350 kodilog(traceback.format_exc(), level=xbmc.LOGERROR)
351 return None
352 if result:
353 data, expires = result
354 if expires > datetime.utcnow():
355 return self._process(data)
356 return None
357
358 def set(self, key, data, expires):
359 with self._lock:
360 try:
361 self.check_clean_up()
362 self._conn.execute(
363 "INSERT OR REPLACE INTO `cached` (key, data, expires) VALUES(?, ?, ?)",
364 (
365 key,
366 sqlite3.Binary(self._prepare(data)),
367 datetime.utcnow() + expires,
368 ),
369 )
370 kodilog(
371 "Set cache for key '{}' with expiry {}".format(key, expires),
372 level=xbmc.LOGDEBUG,
373 )
374 except Exception as e:
375 kodilog(
376 "Failed to set cache for key '{}': {}".format(key, str(e)),
377 level=xbmc.LOGERROR,
378 )
379 # fallback to raw in‑memory store
380 self._object_store[key] = (data, expires)
381
382 def clear_list(self, key):
383 """Clear the list stored under the given key."""
384 self.set(
385 key,
386 self._prepare([]),
387 datetime.utcnow(), # Set an immediate expiry to clear the list
388 )
389
390 def delete(self, key):
391 """Remove a single key from the SQLite store."""
392 self._conn.execute("DELETE FROM `cached` WHERE key = ?", (key,))
393
394 def delete_like(self, pattern):
395 """Remove keys matching a pattern from the SQLite store."""
396 self._conn.execute("DELETE FROM `cached` WHERE key LIKE ?", (pattern,))
397
398 def _set_version(self, version):
399 self._conn.execute("PRAGMA user_version={}".format(version))
400
401 @property
402 def version(self):
403 return self._conn.execute("PRAGMA user_version").fetchone()[0]
404
405 @property
406 def needs_cleanup(self):
407 return self._last_cleanup + self._cleanup_interval < datetime.utcnow()
408
409 def clean_up(self):
410 self._conn.execute(
411 "DELETE FROM `cached` WHERE expires <= STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"
412 )
413 self._last_cleanup = datetime.utcnow()
414
415 def clean_all(self):
416 self._conn.execute("DELETE FROM cached")
417 self._object_store.clear()
418
419 def check_clean_up(self):
420 clean_up = self.needs_cleanup
421 if clean_up:
422 self.clean_up()
423 return clean_up
424
425 def close(self):
426 self._conn.close()
427
428
429cache = HybridCache()
plugin.video.jacktook/lib/db/pickle_db.py +74 -0
……@@ -0,0 +1,74 @@
1import os
2import pickle
3import xbmcvfs
4
5from lib.jacktook.utils import kodilog
6from lib.utils.kodi.utils import ADDON_ID
7
8
9class PickleDatabase:
10 def __init__(self):
11 BASE_DATABASE = {
12 "jt:watch": {},
13 "jt:lth": {},
14 "jt:lfh": {},
15 "jt:lib": {},
16 }
17
18 data_dir = xbmcvfs.translatePath(
19 os.path.join("special://profile/addon_data/", ADDON_ID)
20 )
21 self._database_path = os.path.join(data_dir, "database.pickle")
22 xbmcvfs.mkdirs(data_dir)
23
24 try:
25 if os.path.exists(self._database_path):
26 with open(self._database_path, "rb") as f:
27 database = pickle.load(f)
28 else:
29 database = {}
30 except Exception as e:
31 kodilog(f"Failed to load database: {e}")
32 database = {}
33
34 self._database = {**BASE_DATABASE, **database}
35 self.addon_xml_path = xbmcvfs.translatePath(
36 os.path.join("special://home/addons/", ADDON_ID, "addon.xml")
37 )
38
39 def set_item(self, key: str, subkey: str, value, commit: bool = True):
40 self._database[key][subkey] = value
41 if commit:
42 self.commit()
43
44 def get_item(self, key: str, subkey: str):
45 return self._database[key].get(subkey, None)
46
47 def delete_item(self, key: str, subkey: str, commit: bool = True):
48 try:
49 if subkey in self._database[key]:
50 del self._database[key][subkey]
51 if commit:
52 self.commit()
53 except KeyError:
54 pass
55
56 def set_key(self, key: str, value, commit: bool = True):
57 self._database[key] = value
58 if commit:
59 self.commit()
60
61 def get_key(self, key: str):
62 return self._database[key]
63
64 def commit(self):
65 try:
66 with open(self._database_path, "wb") as f:
67 pickle.dump(self._database, f)
68 except Exception as e:
69 kodilog(f"Failed to save database: {e}")
70
71 @property
72 def database(self):
73 """Expose internal database dict as read-only property."""
74 return self._database
plugin.video.jacktook/lib/domain/torrent.py +42 -0
……@@ -0,0 +1,42 @@
1from dataclasses import dataclass, field
2from typing import Dict, List
3
4
5@dataclass
6class TorrentStream:
7 # Identification
8 title: str = ""
9 type: str = "" # e.g., "torrent" or "debrid" or "direct"
10 debridType: str = ""
11 indexer: str = ""
12 subindexer: str = ""
13 addonKey: str = ""
14 addonName: str = ""
15 addonInstanceLabel: str = ""
16 guid: str = ""
17 infoHash: str = ""
18
19 # Stats
20 size: int = 0 # in bytes
21 seeders: int = 0
22 peers: int = 0
23
24 # Language info
25 languages: List[str] = field(default_factory=list)
26 fullLanguages: str = ""
27
28 # Source info
29 provider: str = ""
30 publishDate: str = "" # ISO format preferred
31
32 # Quality and URLs
33 quality: str = "N/A"
34 url: str = ""
35 # Stremio behaviorHints.proxyHeaders.request — headers the source requires
36 # on playback (Referer/User-Agent for hotlink-protected CDNs).
37 proxyHeaders: Dict[str, str] = field(default_factory=dict)
38
39 # Flags
40 isPack: bool = False
41 isCached: bool = False
42 addedToDebrid: bool = False
plugin.video.jacktook/lib/downloader.py +332 -0
……@@ -0,0 +1,332 @@
1import json
2import os
3import ssl
4import threading
5import re
6from urllib.request import Request, urlopen
7from urllib.parse import parse_qsl, unquote, urlparse
8from lib.utils.general.utils import set_pluging_category, supported_video_extensions
9from lib.utils.kodi.utils import (
10 ADDON_HANDLE,
11 apply_section_view,
12 action_url_run,
13 bytes_to_human_readable,
14 get_setting,
15 kodilog,
16 translatePath,
17 ADDON_PATH,
18 notification,
19 open_file,
20 translation,
21)
22from lib.db.cached import MemoryCache
23from lib.gui.custom_progress import CustomProgressDialog
24
25from xbmcplugin import (
26 addDirectoryItems,
27 endOfDirectory,
28 setContent,
29)
30import xbmcgui
31import xbmcvfs
32import xbmc
33
34
35cancel_flag_cache = MemoryCache()
36
37
38def handle_download_file(params):
39 destination = params.get("destination")
40 if not destination or not os.path.exists(destination):
41 notification("Invalid download destination.")
42 return
43
44 file_name = normalize_file_name(params.get("file_name", ""), params.get("url", ""))
45 cancel_key = os.path.join(destination, file_name)
46 kodilog(f"Setting cancel event cache key: {cancel_key}")
47
48 downloader = Downloader(
49 url=params.get("url"),
50 destination=destination,
51 name=file_name,
52 )
53 cancel_flag_cache.set(cancel_key, downloader.is_cancelled)
54 downloader.run()
55
56
57def download_video(params):
58 data = json.loads(params["data"])
59 download_dir = get_setting("download_dir")
60 destination = translatePath(download_dir)
61 handle_download_file(
62 {"destination": destination, "file_name": data["title"], "url": data["url"]}
63 )
64
65
66class ProgressHandler:
67 def update(self, percent: int, message: str):
68 pass
69
70 def cancelled(self) -> bool:
71 return False
72
73 def close(self):
74 pass
75
76
77class KodiProgressHandler(ProgressHandler):
78 def __init__(self, title: str, addon_path: str):
79 self.dialog = CustomProgressDialog("custom_progress_dialog.xml", addon_path)
80 self.dialog.show_dialog()
81
82 def update(self, percent: int, message: str):
83 self.dialog.update_progress(percent, message)
84
85 def cancelled(self) -> bool:
86 return self.dialog.cancelled
87
88 def close(self):
89 self.dialog.close()
90
91
92class Downloader:
93 def __init__(
94 self,
95 url: str,
96 destination: str,
97 name: str,
98 ):
99 self.url = url
100 self.destination = destination
101 self.name = name
102 self.headers = {}
103 self.file_size = 0
104 self.monitor = xbmc.Monitor()
105 self.progress_handler = None
106 self.is_cancelled = False
107 self.dest_path = os.path.join(destination, name)
108
109 def run(self):
110 if not self.url:
111 notification("No URL provided for download.")
112 return
113 thread = threading.Thread(target=self._run, daemon=True)
114 thread.start()
115
116 def _run(self):
117 self._prepare_url()
118 if not self._validate_url():
119 notification("Invalid URL for download.")
120 return
121 self._start_download()
122
123 def _prepare_url(self):
124 try:
125 if "|" in self.url:
126 self.headers = dict(parse_qsl(self.url.rsplit("|", 1)[1]))
127 self.url = self.url.split("|")[0]
128 except Exception as e:
129 kodilog(f"[Downloader] Failed to parse headers: {str(e)}")
130
131 def _validate_url(self):
132 try:
133 if not self.headers.get("User-Agent"):
134 from lib.utils.general.utils import USER_AGENT_STRING
135
136 self.headers["User-Agent"] = USER_AGENT_STRING
137
138 request = Request(self.url, headers=self.headers)
139 context = ssl.create_default_context()
140 context.check_hostname = False
141 context.verify_mode = ssl.CERT_NONE
142
143 response = urlopen(request, context=context)
144 self.file_size = int(response.headers.get("Content-Length", 0))
145 return True
146 except Exception as e:
147 kodilog(f"[Downloader] Validation error: {str(e)}")
148 return False
149
150 def _start_download(self):
151 downloaded = 0
152 file_mode = "wb"
153
154 self.progress_handler = KodiProgressHandler("Downloading", ADDON_PATH)
155
156 try:
157 # Resume support
158 if os.path.exists(self.dest_path):
159 downloaded = os.path.getsize(self.dest_path)
160 if downloaded < self.file_size:
161 self.headers["Range"] = f"bytes={downloaded}-"
162 file_mode = "ab"
163 elif downloaded >= self.file_size:
164 notification(f"File already downloaded: {self.name}")
165 return
166
167 request = Request(self.url, headers=self.headers)
168 context = ssl.create_default_context()
169 context.check_hostname = False
170 context.verify_mode = ssl.CERT_NONE
171 response = urlopen(request, context=context)
172
173 with open_file(self.dest_path, file_mode) as file:
174 while not self.monitor.abortRequested():
175 chunk = response.read(1024 * 1024) # 1MB chunks
176 if not chunk:
177 kodilog("No more data to read")
178 break
179
180 file.write(chunk)
181 downloaded += len(chunk)
182
183 if self.file_size:
184 percent = int((downloaded / self.file_size) * 100)
185 else:
186 percent = 0
187
188 self.progress_handler.update(
189 percent,
190 f"{self.name} - {percent}% - {bytes_to_human_readable(downloaded)} / {bytes_to_human_readable(self.file_size)}",
191 )
192
193 # Handle cancellation
194 if (
195 self.progress_handler.cancelled()
196 or cancel_flag_cache.get(self.dest_path) is True
197 ):
198 cancel_flag_cache.set(self.dest_path, True)
199 self.is_cancelled = True
200 file.close()
201 break
202
203 self.monitor.waitForAbort(0.1)
204
205 if self.is_cancelled:
206 notification(f"Download cancelled: {self.name}")
207 else:
208 notification(f"Download completed: {self.name}")
209 except Exception as e:
210 notification(f"Download error: {str(e)}")
211 finally:
212 self.progress_handler.close()
213
214
215def handle_cancel_download(params):
216 file_path = json.loads(params.get("file_path"))
217 if file_path:
218 cancel_flag_cache.set(file_path, True)
219 xbmc.executebuiltin("Container.Refresh")
220 else:
221 notification("No active download found.")
222
223
224def handle_delete_file(params):
225 file_path = json.loads(params.get("file_path"))
226 if not xbmcvfs.exists(file_path):
227 notification("File not found.")
228 return
229 try:
230 xbmcvfs.delete(file_path)
231 notification(f"File Deleted")
232 xbmc.executebuiltin("Container.Refresh")
233 except Exception as e:
234 kodilog(f"Error deleting file: {str(e)}")
235 notification(f"Failed to delete file: {str(e)}")
236
237
238def downloads_viewer(params):
239 set_pluging_category(translation(90015))
240 translated_path = translatePath(get_setting("download_dir"))
241 item_list = []
242
243 try:
244 setContent(ADDON_HANDLE, "files")
245 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
297 addDirectoryItems(ADDON_HANDLE, item_list)
298 endOfDirectory(ADDON_HANDLE)
299 apply_section_view("view.downloads", content_type="files")
300 except Exception as e:
301 notification(f"Error: {str(e)}", translation(90662))
302 endOfDirectory(ADDON_HANDLE, succeeded=False)
303
304
305def is_active_download(path):
306 cancel_flag = cancel_flag_cache.get(path)
307 return cancel_flag is not True
308
309
310def normalize_file_name(file_name, url=""):
311 file_name = (file_name or "").strip()
312 valid_extensions = {ext.lower() for ext in supported_video_extensions()}
313
314 base, ext = os.path.splitext(file_name)
315 if ext.lower() not in valid_extensions:
316 base = file_name
317 ext = ""
318
319 if not base and url:
320 url_path = unquote(urlparse(url.split("|", 1)[0]).path)
321 base = os.path.splitext(os.path.basename(url_path))[0]
322
323 if not ext and url:
324 url_path = unquote(urlparse(url.split("|", 1)[0]).path)
325 _, url_ext = os.path.splitext(url_path)
326 if url_ext.lower() in valid_extensions:
327 ext = url_ext
328
329 # Remove invalid characters and dots from base name
330 base = re.sub(r'[\\/*?:"<>|.]', "_", base)
331 base = re.sub(r"_+", "_", base)
332 return f"{base}{ext}"
plugin.video.jacktook/lib/gui/actor_info_window.py +132 -0
……@@ -0,0 +1,132 @@
1import threading
2from lib.gui.base_window import BaseWindow
3from lib.utils.kodi.utils import (
4 ADDON_PATH,
5 kodilog,
6 notification,
7 execute_builtin,
8)
9from lib.clients.tmdb.utils.utils import tmdb_get
10import xbmcgui
11
12
13class ActorInfoWindow(BaseWindow):
14 def __init__(self, xml_file, location, person_id=None, previous_window=None):
15 super().__init__(xml_file, location, previous_window=previous_window)
16 self.person_id = person_id
17 self.filmography_list_id = 3000
18
19 def onInit(self):
20 super().onInit()
21 self.setFocusId(self.filmography_list_id)
22 threading.Thread(target=self.fetch_person_details, daemon=True).start()
23 threading.Thread(target=self.fetch_filmography, daemon=True).start()
24
25 def fetch_person_details(self):
26 if not self.person_id:
27 return
28 try:
29 data = tmdb_get("person_details", params=self.person_id)
30 if not data:
31 kodilog("No person details found")
32 return
33
34 name = getattr(data, "name", "")
35 biography = getattr(data, "biography", "")
36 birthday = getattr(data, "birthday", "") or ""
37 deathday = getattr(data, "deathday", "") or ""
38 place_of_birth = getattr(data, "place_of_birth", "") or ""
39 known_for = getattr(data, "known_for_department", "") or ""
40 profile_path = getattr(data, "profile_path", "")
41
42 self.setProperty("name", name)
43 self.setProperty("biography", biography or "No biography available.")
44 self.setProperty("known_for", known_for)
45
46 # Birthday info line
47 birthday_parts = []
48 if birthday:
49 birthday_parts.append(f"Born: {birthday}")
50 if place_of_birth:
51 birthday_parts.append(place_of_birth)
52 self.setProperty("birthday_info", " • ".join(birthday_parts))
53
54 # Deathday
55 if deathday:
56 self.setProperty("deathday_info", f"Died: {deathday}")
57
58 # Profile photo
59 if profile_path:
60 photo_url = f"https://image.tmdb.org/t/p/w500{profile_path}"
61 self.setProperty("photo", photo_url)
62
63 except Exception as e:
64 kodilog(f"Error fetching person details: {e}")
65
66 def fetch_filmography(self):
67 if not self.person_id:
68 return
69 try:
70 data = tmdb_get("person_credits", params=self.person_id)
71 if not data:
72 return
73
74 cast_list = getattr(data, "cast", [])
75 if not cast_list:
76 return
77
78 # Sort by date (newest first)
79 def get_date(c):
80 return c.get("release_date") or c.get("first_air_date") or ""
81
82 sorted_credits = sorted(cast_list, key=get_date, reverse=True)
83
84 filmography = self.getControlList(self.filmography_list_id)
85 filmography.reset()
86
87 for credit in sorted_credits[:50]:
88 title = credit.get("title") or credit.get("name") or ""
89 character = credit.get("character", "")
90 media_type = credit.get("media_type", "movie")
91 tmdb_id = credit.get("id", "")
92 poster_path = credit.get("poster_path", "")
93
94 # Extract year
95 date_str = get_date(credit)
96 year = date_str[:4] if date_str else ""
97
98 li = xbmcgui.ListItem(label=title)
99 li.setProperty("role", character)
100 li.setProperty("year", year)
101 li.setProperty("id", str(tmdb_id))
102 li.setProperty("media_type", media_type)
103
104 if poster_path:
105 thumb = f"https://image.tmdb.org/t/p/w300{poster_path}"
106 li.setArt({"thumb": thumb, "icon": thumb})
107
108 filmography.addItem(li)
109
110 self.setProperty("filmography.count", f"({len(sorted_credits)})")
111 self.setFocusId(self.filmography_list_id)
112
113 except Exception as e:
114 kodilog(f"Error fetching filmography: {e}")
115
116 def handle_action(self, action_id, control_id=None):
117 if action_id != 7:
118 return
119
120 # Filmography item clicked → open Extras window for that title
121 if control_id == self.filmography_list_id:
122 item = self.getControlList(self.filmography_list_id).getSelectedItem()
123 tmdb_id = item.getProperty("id")
124 media_type = item.getProperty("media_type")
125 title = item.getLabel()
126 if tmdb_id:
127 execute_builtin(
128 f"RunPlugin(plugin://plugin.video.jacktook/"
129 f"?action=extras&id={tmdb_id}"
130 f"&media_type={media_type}&title={title})"
131 )
132 self.close()
plugin.video.jacktook/lib/gui/base_window.py +273 -0
……@@ -0,0 +1,273 @@
1import abc
2from copy import deepcopy
3import hashlib
4import json
5from typing import Any, Dict, Tuple
6from lib.domain.torrent import TorrentStream
7from lib.utils.debrid.debrid_utils import get_magnet_from_uri, _is_torrent_ready_in_debrid
8from lib.utils.player.utils import resolve_playback_url
9from lib.utils.general.utils import Indexer, IndexerType, truncate_text
10from lib.utils.kodi.logging import summarize_locator_for_log
11from lib.utils.kodi.utils import ADDON, kodilog, translation
12import xbmcgui
13
14
15ACTION_PREVIOUS_MENU = 10
16ACTION_PLAYER_STOP = 13
17ACTION_NAV_BACK = 92
18
19
20class BaseWindow(xbmcgui.WindowXMLDialog):
21 def __init__(self, xml_file, location, item_information=None, previous_window=None):
22 super().__init__(xml_file, location)
23 self.item_information = {}
24 self.previous_window = previous_window
25 self.CACHE_KEY = ""
26 self._last_focused_control = (None, None)
27 self.action_exitkeys_id = {
28 ACTION_PREVIOUS_MENU,
29 ACTION_PLAYER_STOP,
30 ACTION_NAV_BACK,
31 }
32 if item_information is None:
33 return
34 self.add_item_information_to_window(item_information)
35
36 def onInit(self):
37 pass
38
39 def get_cached_focus(self):
40 if not self.CACHE_KEY:
41 return None, None
42
43 cache_prop = self._focus_cache_prop()
44 cached_data = xbmcgui.Window(10000).getProperty(cache_prop)
45 if not cached_data:
46 cached_data = ADDON.getSetting(self.CACHE_KEY)
47 if cached_data:
48 xbmcgui.Window(10000).setProperty(cache_prop, cached_data)
49 ADDON.setSetting(self.CACHE_KEY, "")
50 if cached_data:
51 try:
52 return json.loads(cached_data)
53 except json.JSONDecodeError:
54 return None, None
55 return None, None
56
57 def set_cached_focus(self, control_id, item_id):
58 if not self.CACHE_KEY:
59 return
60 cache_data = json.dumps((control_id, item_id))
61 xbmcgui.Window(10000).setProperty(self._focus_cache_prop(), cache_data)
62
63 def _focus_cache_prop(self):
64 cache_key = str(self.CACHE_KEY or "")
65 cache_hash = hashlib.sha1(cache_key.encode("utf-8")).hexdigest()
66 return f"jacktook.focus.{cache_hash}"
67
68 def set_default_focus(
69 self, control_list=None, control_id=None, control_list_reset=False
70 ):
71 try:
72 # Retrieve cached focus if available
73 control_id, item_id = self.get_cached_focus()
74 if control_id and item_id:
75 control = self.getControl(control_id)
76 if isinstance(control, xbmcgui.ControlList):
77 control.selectItem(int(item_id))
78 self.setFocus(control)
79 return
80
81 if control_list and control_list.size() > 0:
82 if control_list_reset:
83 control_list.selectItem(0)
84 self.setFocus(control_list)
85 elif control_id:
86 control = self.getControl(control_id)
87 self.setFocus(control)
88 else:
89 raise ValueError("Neither valid control list nor control ID provided.")
90 except (RuntimeError, ValueError) as e:
91 kodilog(f"Could not set focus: {e}")
92 if control_id:
93 self.setFocusId(control_id)
94
95 def getControlList(self, control_id):
96 try:
97 control = self.getControl(control_id)
98 except RuntimeError as e:
99 kodilog(f"Control does not exist {control_id}", {e})
100 raise ValueError(f"Control with Id {control_id} does not exist")
101
102 if not isinstance(control, xbmcgui.ControlList):
103 raise AttributeError(
104 f"Control with Id {control_id} should be of type ControlList"
105 )
106 return control
107
108 def add_item_information_to_window(self, item_information):
109 self.item_information = deepcopy(item_information)
110 for i in self.item_information:
111 value = self.item_information[i]
112 if i == "overview":
113 value = truncate_text(str(value))
114 try:
115 self.setProperty(f"info.{i}", str(value))
116 except UnicodeEncodeError:
117 self.setProperty(f"info.{i}", value)
118
119 def getControlProgress(self, control_id):
120 control = self.getControl(control_id)
121 if not isinstance(control, xbmcgui.ControlProgress):
122 raise AttributeError(
123 f"Control with Id {control_id} should be of type ControlProgress"
124 )
125
126 return control
127
128 def onClick(self, control_id):
129 self.handle_action(7, control_id)
130
131 def onAction(self, action):
132 action_id = action.getId()
133 if action_id in self.action_exitkeys_id:
134 if self.previous_window:
135 self.previous_window.setProperty("instant_close", "true")
136 self.previous_window.close()
137 self.close()
138 elif action_id != 7:
139 self.handle_action(action_id, self.getFocusId())
140
141 def _ensure_playback_info(self, source: TorrentStream):
142 url, magnet, is_torrent = self._extract_source_details(source)
143
144 if source.addedToDebrid and source.debridType and source.infoHash:
145 is_ready = _is_torrent_ready_in_debrid(source.debridType, source.infoHash)
146 if not is_ready:
147 dialog = xbmcgui.Dialog()
148 message = (
149 f"{translation(90696) % source.debridType}\n\n"
150 f"{translation(90697)}"
151 )
152 choice = dialog.yesno(
153 translation(90695),
154 message,
155 yeslabel=translation(90698),
156 nolabel=translation(90699)
157 )
158 if not choice:
159 return {}
160 source.addedToDebrid = False
161 source.debridType = ""
162
163 source_data = self.prepare_source_data(
164 source=source,
165 url=url,
166 magnet=magnet,
167 is_torrent=is_torrent,
168 )
169 return resolve_playback_url(source_data) or {}
170
171 def _extract_source_details(self, source: TorrentStream) -> Tuple[str, str, bool]:
172 url = source.url or ""
173 guid = source.guid or ""
174 magnet = ""
175 indexer = source.indexer
176 source_type = source.type
177 is_torrent = source.type == IndexerType.TORRENT
178
179 kodilog(
180 "Source extraction input: indexer={!r}, type={!r}, guid={!r}, url={!r}, infohash={!r}".format(
181 indexer,
182 source_type,
183 summarize_locator_for_log(guid),
184 summarize_locator_for_log(url),
185 str(source.infoHash or "").lower()[:12],
186 )
187 )
188
189 if source_type in (IndexerType.DIRECT, IndexerType.STREMIO_DEBRID):
190 return url, magnet, is_torrent
191
192 # Prefer magnet in guid
193 if guid.startswith("magnet:?"):
194 magnet = guid
195 # Handle Burst indexer
196 elif indexer == Indexer.BURST:
197 url = guid
198 # Prefer .torrent in guid or url
199 elif guid.endswith(".torrent"):
200 url = guid
201 elif url.endswith(".torrent"):
202 pass
203
204 # Try to extract magnet from url
205 if url.startswith("http") and not url.endswith(".torrent"):
206 magnet_candidate, _, torrent_url = get_magnet_from_uri(url)
207 if torrent_url:
208 url = torrent_url
209 elif magnet_candidate:
210 magnet = magnet_candidate
211
212 # Try to extract magnet from guid if it's a details page
213 elif not magnet and guid.startswith("http"):
214 magnet_candidate, _, torrent_url = get_magnet_from_uri(guid)
215 if magnet_candidate:
216 magnet = magnet_candidate
217 elif torrent_url:
218 url = torrent_url
219
220 # --- Fallback: magnet from url ---
221 if not magnet and url.startswith("magnet:?"):
222 magnet, url = url, ""
223
224 # --- Fallback: magnet from infoHash ---
225 used_infohash_fallback = False
226 has_http_url = bool(url and url.startswith("http"))
227 if not magnet and source.infoHash and not has_http_url:
228 from lib.utils.general.utils import info_hash_to_magnet
229
230 magnet = info_hash_to_magnet(source.infoHash)
231 used_infohash_fallback = True
232
233 kodilog(
234 "Source extraction output: url={!r}, magnet={!r}, is_torrent={}, used_infohash_fallback={}".format(
235 summarize_locator_for_log(url),
236 summarize_locator_for_log(magnet),
237 is_torrent,
238 used_infohash_fallback,
239 )
240 )
241
242 return url, magnet, is_torrent
243
244 def prepare_source_data(
245 self,
246 source: TorrentStream,
247 url: str,
248 magnet: str,
249 is_torrent: bool,
250 pack_select: bool = False,
251 ) -> Dict[str, Any]:
252 """Prepare the source data dictionary for resolving playback."""
253 return {
254 "type": source.type,
255 "debrid_type": source.debridType,
256 "indexer": source.indexer,
257 "url": url,
258 "magnet": magnet,
259 # getattr: cached results are unpickled, and pickle restores __dict__
260 # directly without applying dataclass defaults — so sources pickled
261 # before this field existed genuinely lack the attribute.
262 "proxyHeaders": getattr(source, "proxyHeaders", None) or {},
263 "info_hash": source.infoHash,
264 "is_torrent": is_torrent,
265 "is_pack": pack_select,
266 "mode": self.item_information.get("mode"),
267 "ids": self.item_information.get("ids"),
268 "tv_data": self.item_information.get("tv_data"),
269 }
270
271 @abc.abstractmethod
272 def handle_action(self, action_id, control_id=None):
273 pass
plugin.video.jacktook/lib/gui/custom_dialogs.py +334 -0
……@@ -0,0 +1,334 @@
1import json
2from typing import Dict, List
3
4from lib.domain.torrent import TorrentStream
5from lib.gui.custom_progress import CustomProgressDialog
6from lib.gui.play_next_window import PlayNext
7from lib.gui.resolver_window import ResolverWindow
8from lib.gui.resume_window import ResumeDialog
9from lib.utils.kodi.utils import ADDON_PATH, PLAYLIST, kodilog, translation
10from lib.gui.source_select import SourceSelect
11from lib.gui.search_status_window import SearchTaskManager, SearchStatusWindow
12from lib.domain.torrent import TorrentStream
13from concurrent.futures import ThreadPoolExecutor
14
15from xbmcgui import WindowXMLDialog, WindowXML
16import xbmcgui
17import xbmc
18
19
20class CustomWindow(WindowXML):
21 def __init__(self, *args, **kwargs):
22 super().__init__(*args, **kwargs)
23
24 def onInit(self):
25 pass
26
27
28class MyWindow(WindowXML):
29 def __init__(self, *args, **kwargs):
30 super().__init__(*args, **kwargs)
31 self.list_control = None
32
33 def onInit(self):
34 self.list_control = self.getControl(32503)
35
36 def add_item(self, list_item):
37 if self.list_control:
38 self.list_control.addItem(list_item)
39
40 def onClick(self, control_id):
41 if control_id == 32503: # List control
42 if not self.list_control:
43 return
44 selected_item = self.list_control.getSelectedItem()
45 if selected_item:
46 xbmcgui.Dialog().notification(
47 translation(90664),
48 selected_item.getLabel(),
49 xbmcgui.NOTIFICATION_INFO,
50 3000,
51 )
52
53
54class CustomDialog(WindowXMLDialog):
55 _heading = 32500
56 _text = 32501
57 _url = 32502
58 _close_button_id = 32503
59 _qrcode = 32504
60
61 def __init__(self, *args, **kwargs):
62 super().__init__(*args, **kwargs)
63 self.heading = kwargs.get("heading")
64 self.text = kwargs.get("text")
65 self.url = kwargs.get("url")
66 self.qrcode_path = kwargs.get("qrcode")
67
68 def onInit(self):
69 self.getControl(self._heading).setLabel(self.heading)
70 self.getControl(self._text).setLabel(self.text)
71 self.getControl(self._url).setLabel(self.url)
72 if self.qrcode_path:
73 self.getControl(self._qrcode).setImage(self.qrcode_path)
74 self.getControl(self._qrcode).setVisible(True)
75 else:
76 try:
77 self.getControl(self._qrcode).setVisible(False)
78 except:
79 pass
80
81 def onClick(self, controlId):
82 if controlId == self._close_button_id:
83 self.close()
84
85
86fake_torrent = TorrentStream(
87 title="Example Movie 2025",
88 type="movie",
89 indexer="FakeIndexer",
90 guid="1234567890abcdef",
91 infoHash="abcdef1234567890abcdef1234567890abcdef12",
92 size=2_147_483_648, # 2 GB
93 seeders=150,
94 languages=["en", "es"],
95 fullLanguages="English, Spanish",
96 provider="FakeProvider",
97 publishDate="2025-08-11T12:00:00Z",
98 peers=200,
99 quality="1080p",
100 url="magnet:?xt=urn:btih:abcdef1234567890abcdef1234567890abcdef12",
101 isPack=False,
102 isCached=True,
103)
104
105
106_mock_information = {
107 "fanart": "https://assets.fanart.tv/fanart/tv/453280/showbackground/secret-level-674c531a09534.jpg",
108 "poster": "http://image.tmdb.org/t/p/w780/856MRq23grNxpeVl1PdFgmmLiT0.jpg",
109 "clearlogo": "https://assets.fanart.tv/fanart/movies/86161/hdmovielogo/mobile-suit-gundam-0083-the-last-blitz-of-zeon-5c2ede0c7530d.png",
110 "plot": "Silo is the story of the last ten thousand people on earth, their mile-deep home protecting them from the toxic and deadly world outside. However, no one knows when or why the silo was built and any who try to find out face fatal consequences.",
111}
112
113
114def source_select(
115 item_info: Dict[str, str], xml_file: str, sources: List[TorrentStream]
116) -> bool:
117 window = SourceSelect(
118 xml_file,
119 ADDON_PATH,
120 item_information=item_info,
121 sources=sources,
122 uncached=sources,
123 )
124 resolved = window.doModal()
125 del window
126 return resolved
127
128
129def run_next_dialog(params):
130 try:
131 playlist_size = PLAYLIST.size()
132 playlist_pos = PLAYLIST.getposition() if playlist_size > 0 else -1
133 except Exception as e:
134 kodilog(f"Error accessing playlist in run_next_dialog: {e}")
135 return
136
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()
206
207 # Play directly
208 from xbmcgui import ListItem
209
210 list_item = ListItem(path=next_url)
211 player.play(next_url, list_item)
212
213
214def run_resume_dialog(params):
215 resume_window = None
216 try:
217 resume_window = ResumeDialog(
218 "resume_dialog.xml",
219 ADDON_PATH,
220 resume_percent=params.get("resume"),
221 )
222 resume_window.doModal()
223 return resume_window.resume
224 finally:
225 if resume_window is not None:
226 del resume_window
227
228
229def run_skip_intro_dialog(params):
230 """Show skip intro/recap overlay during playback."""
231 window = None
232 try:
233 segment_data = json.loads(params.get("segment_data", "{}"))
234 label = params.get("skip_label", translation(90160))
235
236 from lib.gui.skip_intro_window import SkipIntroWindow
237
238 window = SkipIntroWindow(
239 "skip_intro.xml",
240 ADDON_PATH,
241 segment_data=segment_data,
242 label=label,
243 )
244 window.doModal()
245 except Exception as e:
246 kodilog(f"Error in run_skip_intro_dialog: {e}")
247 finally:
248 if window is not None:
249 del window
250
251
252def run_next_mock():
253 window = None
254 try:
255 window = PlayNext(
256 "playing_next.xml",
257 ADDON_PATH,
258 item_information=_mock_information,
259 )
260 window.doModal()
261 finally:
262 if window is not None:
263 del window
264
265
266def source_select_mock():
267 sources = [fake_torrent for _ in range(10)]
268 window = SourceSelect(
269 "source_select.xml",
270 ADDON_PATH,
271 item_information=_mock_information,
272 sources=sources,
273 uncached=sources,
274 )
275 window.doModal()
276 del window
277
278
279def download_dialog_mock():
280 try:
281 progress_dialog = CustomProgressDialog("custom_progress_dialog.xml", ADDON_PATH)
282 progress_dialog.doModal()
283 finally:
284 pass
285
286
287def resume_dialog_mock():
288 window = None
289 try:
290 window = ResumeDialog(
291 "resume_dialog.xml",
292 ADDON_PATH,
293 resume_percent=25.0,
294 )
295 window.doModal()
296 return window.resume
297 finally:
298 if window is not None:
299 del window
300
301
302def resolver_mock():
303 window = ResolverWindow(
304 "resolver.xml",
305 ADDON_PATH,
306 source=fake_torrent,
307 item_information=_mock_information,
308 )
309 window.doModal()
310 del window
311
312
313def search_status_mock():
314 # Create dummy tasks that succeed/fail slowly
315 import time
316
317 def dummy_task(sleep_time, fail=False, results=10):
318 time.sleep(sleep_time)
319 if fail:
320 raise Exception("Mock failure")
321 return [fake_torrent] * results
322
323 with ThreadPoolExecutor(max_workers=5) as executor:
324 manager = SearchTaskManager(executor)
325 manager.submit_task("Torrentio", "torrentio", dummy_task, 3, False, 15)
326 manager.submit_task("Jackett", "jackett", dummy_task, 5, False, 5)
327 manager.submit_task("Stremio", "stremio", dummy_task, 8, False, 20)
328 manager.submit_task("Prowlarr", "prowlarr", dummy_task, 4, True, 0)
329
330 window = SearchStatusWindow(
331 "search_status.xml", ADDON_PATH, task_manager=manager
332 )
333 window.doModal()
334 del window
plugin.video.jacktook/lib/gui/custom_progress.py +41 -0
……@@ -0,0 +1,41 @@
1from lib.utils.kodi.utils import kodilog, translation
2import xbmcgui
3
4
5class CustomProgressDialog(xbmcgui.WindowXMLDialog):
6 def __init__(self, xml_file: str, location: str):
7 super().__init__(xml_file, location)
8 self.title = translation(30653)
9 self.message = translation(90554)
10 self.progress = 0
11 self.cancelled = False
12
13 def show_dialog(self):
14 self.show()
15 self.onInit()
16
17 def close_dialog(self):
18 self.close()
19
20 def onInit(self):
21 self.getControl(12001).setLabel(self.title)
22 self.getControl(12002).setLabel(self.message)
23 self.getControl(12004).setPercent(self.progress)
24 self.setFocusId(12003)
25
26 def update_progress(self, percent, message=None):
27 self.progress = percent
28 self.getControl(12004).setPercent(percent)
29 if message:
30 self.getControl(12002).setLabel(message)
31
32 def onClick(self, controlId):
33 if controlId == 12003: # Cancel button
34 self.cancelled = True
35 self.close_dialog()
36 elif controlId == 12005: # Close button
37 self.close_dialog()
38
39 def onAction(self, action):
40 if action.getId() in (10, 92):
41 self.close_dialog()
plugin.video.jacktook/lib/gui/extras_window.py +917 -0
……@@ -0,0 +1,917 @@
1import json
2import os
3import threading
4from lib.gui.base_window import BaseWindow
5from lib.utils.kodi.utils import (
6 ADDON_PATH,
7 kodilog,
8 execute_builtin,
9 notification,
10 get_setting,
11 build_url,
12 translation,
13)
14from lib.clients.tmdb.utils.utils import tmdb_get, LANGUAGES
15from lib.api.fanart.fanart import get_fanart
16from lib.api.imdb.imdb_scraper import get_imdb_trivia, get_imdb_goofs
17from lib.utils.general.utils import truncate_text
18import xbmcgui
19import xbmc
20
21
22class ExtrasWindow(BaseWindow):
23 def __init__(self, xml_file, location, item_information=None, previous_window=None):
24 super().__init__(xml_file, location, item_information, previous_window)
25 self.tmdb_id = self.item_information.get("tmdb_id")
26 self.media_type = self.item_information.get("media_type") # "movie" or "tv"
27 self.imdb_id = self.item_information.get("imdb_id", "")
28 self.title = self.item_information.get("title", "")
29
30 # Shared TMDB details data (populated by fetch_details_and_artwork)
31 self._tmdb_data = None
32 self._details_ready = threading.Event()
33
34 # UI Lists
35 self.cast_list_id = 2050
36 self.recommended_list_id = 2051
37 self.similar_list_id = 2052
38 self.ai_similar_list_id = 2053
39 self.reviews_list_id = 2054
40 self.comments_list_id = 2055
41 self.trivia_list_id = 2056
42 self.blunders_list_id = 2057
43 self.parental_guide_list_id = 2058
44 self.trakt_lists_id = 2059
45 self.youtube_list_id = 2060
46 self.more_year_list_id = 2061
47 self.more_genres_list_id = 2062
48 self.more_networks_list_id = 2063
49 self.collection_list_id = 2064
50
51 def onInit(self):
52 super().onInit()
53 self._init_properties()
54 self.set_button_labels()
55
56 # Start background threads to fetch data so GUI doesn't freeze
57 threading.Thread(target=self.fetch_details_and_artwork, daemon=True).start()
58 threading.Thread(target=self.fetch_cast, daemon=True).start()
59 threading.Thread(target=self.fetch_recommended, daemon=True).start()
60 threading.Thread(target=self.fetch_similar, daemon=True).start()
61 threading.Thread(target=self.fetch_imdb_data, daemon=True).start()
62
63 # Threads that depend on TMDB details being fetched first
64 threading.Thread(target=self._fetch_after_details, daemon=True).start()
65
66 # Set focus to Play button initially
67 self.setFocusId(10)
68
69 def _init_properties(self):
70 """Initialize all window properties to safe defaults."""
71 self.setProperty("title", self.title)
72 self.setProperty("clearlogo", "false")
73 self.setProperty("fanart", "false")
74 self.setProperty("poster", "false")
75 self.setProperty("plot_enabled", "false")
76 self.setProperty("plot", "")
77 self.setProperty("genre", "")
78 self.setProperty("tmdb_rating", "false")
79 self.setProperty("imdb_rating", "false")
80 self.setProperty("metascore_rating", "false")
81 self.setProperty("tomatometer_rating", "false")
82 self.setProperty("tomatousermeter_rating", "false")
83 self.setProperty("display_extra_ratings", "false")
84 # Tab system
85 self.setProperty("active_tab", "overview")
86
87 def _fetch_after_details(self):
88 """Wait for TMDB details fetch, then populate sections that depend on it."""
89 self._details_ready.wait(timeout=15)
90 if not self._tmdb_data:
91 return
92 try:
93 self._populate_youtube_videos()
94 except Exception as e:
95 kodilog(f"Error populating YouTube videos: {e}")
96 try:
97 self._populate_collection()
98 except Exception as e:
99 kodilog(f"Error populating collection: {e}")
100 try:
101 self._populate_more_from_year()
102 except Exception as e:
103 kodilog(f"Error populating more from year: {e}")
104 try:
105 self._populate_more_from_genres()
106 except Exception as e:
107 kodilog(f"Error populating more from genres: {e}")
108 try:
109 self._populate_more_from_networks()
110 except Exception as e:
111 kodilog(f"Error populating more from networks: {e}")
112 try:
113 self.fetch_ai_similar()
114 except Exception as e:
115 kodilog(f"Error populating AI similar: {e}")
116
117 # ─── TMDB Details + Artwork ───────────────────────────────────────────────
118
119 def fetch_details_and_artwork(self):
120 try:
121 path = "movie_details" if self.media_type == "movie" else "tv_details"
122 data = tmdb_get(path, {"id": self.tmdb_id})
123 self._tmdb_data = data
124
125 poster = ""
126 fanart = ""
127 tvdb_id = None
128
129 if data:
130 # ── Extract metadata ──
131 self._populate_metadata(data)
132
133 # ── Extract images ──
134 poster_path = getattr(data, "poster_path", "")
135 if poster_path:
136 poster = f"https://image.tmdb.org/t/p/w500{poster_path}"
137
138 backdrop_path = getattr(data, "backdrop_path", "")
139 if backdrop_path:
140 fanart = f"https://image.tmdb.org/t/p/original{backdrop_path}"
141
142 if self.media_type != "movie":
143 external_ids = getattr(data, "external_ids", None)
144 if external_ids:
145 tvdb_id = external_ids.get("tvdb_id")
146 if not self.imdb_id:
147 self.imdb_id = external_ids.get("imdb_id", "")
148 else:
149 if not self.imdb_id:
150 self.imdb_id = getattr(data, "imdb_id", "") or ""
151
152 # ── Fanart.tv for clearlogo and better backgrounds ──
153 language_index = get_setting("language", 18)
154 lang = "en"
155 try:
156 lang = LANGUAGES[int(language_index)].split("-")[0]
157 except Exception:
158 pass
159
160 media_type_fa = "movies" if self.media_type == "movie" else "tv"
161 fa_id = str(self.tmdb_id)
162 if self.media_type != "movie" and tvdb_id:
163 fa_id = str(tvdb_id)
164
165 fa_data = get_fanart(media_type_fa, lang, fa_id)
166
167 clearlogo = fa_data.get("clearlogo", "")
168 if fa_data.get("fanart"):
169 fanart = fa_data.get("fanart")
170 if fa_data.get("poster"):
171 poster = fa_data.get("poster")
172
173 # Set properties if found
174 if poster:
175 self.setProperty("poster", poster)
176 if fanart:
177 self.setProperty("fanart", fanart)
178 if clearlogo:
179 self.setProperty("clearlogo", clearlogo)
180 else:
181 self.setProperty("clearlogo", "false")
182
183 except Exception as e:
184 kodilog(f"Error fetching details/artwork for extras: {e}")
185 finally:
186 self._details_ready.set()
187
188 def _populate_metadata(self, data):
189 """Extract and set genre, plot, rating, year, and runtime from TMDB details."""
190 try:
191 # Genre
192 genres = getattr(data, "genres", None)
193 if genres:
194 genre_names = []
195 for g in genres:
196 name = (
197 g.get("name", "")
198 if hasattr(g, "get")
199 else getattr(g, "name", "")
200 )
201 if name:
202 genre_names.append(name)
203 if genre_names:
204 self.setProperty("genre", " / ".join(genre_names))
205
206 # Plot
207 overview = getattr(data, "overview", "")
208 if overview:
209 self.setProperty("plot_enabled", "true")
210 self.setProperty("plot", truncate_text(str(overview)))
211
212 # Rating (TMDB)
213 vote_average = getattr(data, "vote_average", None)
214 if vote_average and float(vote_average) > 0:
215 self.setProperty("tmdb_rating", "true")
216 try:
217 self.getControl(4005).setLabel(f"{float(vote_average):.1f}")
218 except Exception:
219 pass
220
221 # Year and runtime — populate Line 2 (control 2001)
222 line2_parts = []
223 if self.media_type == "movie":
224 release_date = getattr(data, "release_date", "")
225 if release_date:
226 line2_parts.append(str(release_date)[:4])
227 runtime = getattr(data, "runtime", None)
228 if runtime:
229 hours = int(runtime) // 60
230 mins = int(runtime) % 60
231 if hours:
232 line2_parts.append(f"{hours}h {mins}m")
233 else:
234 line2_parts.append(f"{mins}m")
235 else:
236 first_air = getattr(data, "first_air_date", "")
237 if first_air:
238 line2_parts.append(str(first_air)[:4])
239 num_seasons = getattr(data, "number_of_seasons", None)
240 if num_seasons:
241 line2_parts.append(
242 f"{num_seasons} Season{'s' if int(num_seasons) != 1 else ''}"
243 )
244 status = getattr(data, "status", "")
245 if status:
246 line2_parts.append(str(status))
247
248 if line2_parts:
249 try:
250 self.getControl(2001).setLabel(" • ".join(line2_parts))
251 except Exception:
252 pass
253
254 # Vote count / Tagline — populate Line 3 (control 3001)
255 tagline = getattr(data, "tagline", "")
256 if tagline:
257 try:
258 self.getControl(3001).setLabel(f"[I]{tagline}[/I]")
259 except Exception:
260 pass
261
262 except Exception as e:
263 kodilog(f"Error populating metadata: {e}")
264
265 # ─── Button Labels ────────────────────────────────────────────────────────
266
267 def set_button_labels(self):
268 self.setProperty("button10.label", translation(90413))
269 self.setProperty("button11.label", translation(90414))
270 self.setProperty("button15.label", translation(90035))
271 self.setProperty("button16.label", translation(90415))
272 self.setProperty("button17.label", translation(90416))
273
274 # ─── Cast ─────────────────────────────────────────────────────────────────
275
276 def fetch_cast(self):
277 if not self.tmdb_id:
278 return
279 try:
280 path = "movie_credits" if self.media_type == "movie" else "tv_credits"
281 data = tmdb_get(path, {"id": self.tmdb_id})
282
283 if data and hasattr(data, "cast"):
284 castlist = self.getControlList(self.cast_list_id)
285 castlist.reset()
286 for actor in data.cast[:30]:
287 li = xbmcgui.ListItem(label=actor.get("name", ""))
288 li.setProperty("name", actor.get("name", ""))
289 li.setProperty("role", actor.get("character", ""))
290 li.setProperty("id", str(actor.get("id", "")))
291
292 profile_path = actor.get("profile_path")
293 if profile_path:
294 thumb = f"https://image.tmdb.org/t/p/w300{profile_path}"
295 li.setArt({"thumb": thumb, "icon": thumb})
296 li.setProperty("thumbnail", thumb)
297
298 castlist.addItem(li)
299 self.setProperty("cast.number", f"({len(data.cast)})")
300 except Exception as e:
301 kodilog(f"Error fetching cast: {e}")
302
303 # ─── Recommended (2051) ───────────────────────────────────────────────────
304
305 def fetch_recommended(self):
306 if not self.tmdb_id:
307 return
308 try:
309 path = (
310 "movie_recommendations"
311 if self.media_type == "movie"
312 else "tv_recommendations"
313 )
314 data = tmdb_get(path, {"id": self.tmdb_id, "page": 1})
315 self.populate_media_list(
316 self.recommended_list_id, data, "recommended.number"
317 )
318 except Exception as e:
319 kodilog(f"Error fetching recommended: {e}")
320
321 # ─── Similar / More Like This (2052) ──────────────────────────────────────
322
323 def fetch_similar(self):
324 if not self.tmdb_id:
325 return
326 try:
327 path = "movie_similar" if self.media_type == "movie" else "tv_similar"
328 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 )
332 except Exception as e:
333 kodilog(f"Error fetching similar: {e}")
334
335 # ─── AI Similar (2053) — uses recommendations page 2 ─────────────────────
336
337 def fetch_ai_similar(self):
338 if not self.tmdb_id:
339 return
340 path = (
341 "movie_recommendations"
342 if self.media_type == "movie"
343 else "tv_recommendations"
344 )
345 data = tmdb_get(path, {"id": self.tmdb_id, "page": 2})
346 self.populate_media_list(self.ai_similar_list_id, data, "ai_similar.number")
347
348 # ─── Shared media list populator ──────────────────────────────────────────
349
350 def populate_media_list(self, list_id, tmdb_data, count_property):
351 if not tmdb_data:
352 return
353 try:
354 items = []
355 if hasattr(tmdb_data, "results"):
356 items = tmdb_data.results[:20] if tmdb_data.results else []
357 elif hasattr(tmdb_data, "_obj_list") and tmdb_data._obj_list:
358 items = tmdb_data._obj_list[:20]
359 elif isinstance(tmdb_data, list):
360 items = tmdb_data[:20]
361
362 if not items:
363 return
364
365 media_list = self.getControlList(list_id)
366 media_list.reset()
367
368 for item in items:
369 title = item.get("title", "") or item.get("name", "")
370 li = xbmcgui.ListItem(label=title)
371 li.setProperty("name", title)
372 li.setProperty("id", str(item.get("id", "")))
373 li.setProperty("media_type", item.get("media_type", self.media_type))
374
375 # Extra info for labels in skin
376 release = item.get("release_date", "") or item.get("first_air_date", "")
377 if release:
378 li.setProperty("release_date", str(release)[:4])
379 vote = item.get("vote_average")
380 if vote:
381 li.setProperty("vote_average", f"{float(vote):.1f}")
382
383 poster_path = item.get("poster_path")
384 if poster_path:
385 thumb = f"https://image.tmdb.org/t/p/w300{poster_path}"
386 li.setArt({"thumb": thumb, "icon": thumb})
387 li.setProperty("thumbnail", thumb)
388
389 media_list.addItem(li)
390
391 self.setProperty(count_property, f"({len(items)})")
392 except Exception as e:
393 kodilog(f"Error populating media list {list_id}: {e}")
394
395 # ─── YouTube Videos (2060) ────────────────────────────────────────────────
396
397 def _populate_youtube_videos(self):
398 data = self._tmdb_data
399 if not data:
400 return
401
402 videos = getattr(data, "videos", None)
403 if not videos:
404 return
405
406 results = getattr(videos, "results", None)
407 if not results:
408 return
409
410 video_items = []
411 for v in results:
412 site = v.get("site", "")
413 if str(site).lower() != "youtube":
414 continue
415 video_items.append(v)
416
417 if not video_items:
418 return
419
420 vid_list = self.getControlList(self.youtube_list_id)
421 vid_list.reset()
422 for v in video_items[:20]:
423 name = v.get("name", "")
424 key = v.get("key", "")
425 vtype = v.get("type", "")
426 li = xbmcgui.ListItem(label=name)
427 li.setProperty("name", name)
428 li.setProperty("key", key)
429 li.setProperty("video_type", vtype)
430 thumb = f"https://img.youtube.com/vi/{key}/mqdefault.jpg"
431 li.setProperty("thumbnail", thumb)
432 li.setArt({"thumb": thumb, "icon": thumb})
433 vid_list.addItem(li)
434
435 self.setProperty("youtube_videos.number", f"({len(video_items[:20])})")
436
437 # ─── Collection (2064, movie only) ────────────────────────────────────────
438
439 def _populate_collection(self):
440 if self.media_type != "movie":
441 return
442
443 data = self._tmdb_data
444 if not data:
445 return
446
447 collection = getattr(data, "belongs_to_collection", None)
448 if not collection:
449 return
450
451 collection_id = collection.get("id")
452 collection_name = collection.get("name", "")
453 collection_poster = collection.get("poster_path", "")
454
455 if not collection_id:
456 return
457
458 self.setProperty("more_from_collection.name", collection_name)
459 if collection_poster:
460 self.setProperty(
461 "more_from_collection.poster",
462 f"https://image.tmdb.org/t/p/w300{collection_poster}",
463 )
464
465 # Fetch collection details
466 col_data = tmdb_get("collection_details", collection_id)
467 if not col_data:
468 return
469
470 parts = getattr(col_data, "parts", None)
471 if not parts:
472 return
473
474 col_list = self.getControlList(self.collection_list_id)
475 col_list.reset()
476
477 count = 0
478 for part in parts:
479 title = part.get("title", "") or part.get("name", "")
480 li = xbmcgui.ListItem(label=title)
481 li.setProperty("name", title)
482 li.setProperty("id", str(part.get("id", "")))
483 li.setProperty("media_type", "movie")
484
485 release = part.get("release_date", "")
486 if release:
487 li.setProperty("release_date", str(release)[:4])
488 vote = part.get("vote_average")
489 if vote:
490 li.setProperty("vote_average", f"{float(vote):.1f}")
491
492 poster_path = part.get("poster_path")
493 if poster_path:
494 thumb = f"https://image.tmdb.org/t/p/w300{poster_path}"
495 li.setArt({"thumb": thumb, "icon": thumb})
496 li.setProperty("thumbnail", thumb)
497
498 col_list.addItem(li)
499 count += 1
500
501 self.setProperty("more_from_collection.number", f"({count})")
502
503 # ─── More from Year (2061) ────────────────────────────────────────────────
504
505 def _populate_more_from_year(self):
506 data = self._tmdb_data
507 if not data:
508 return
509
510 if self.media_type == "movie":
511 release_date = getattr(data, "release_date", "")
512 else:
513 release_date = getattr(data, "first_air_date", "")
514
515 if not release_date or len(str(release_date)) < 4:
516 return
517
518 year = str(release_date)[:4]
519
520 if self.media_type == "movie":
521 discover_data = tmdb_get(
522 "discover_movie",
523 {
524 "primary_release_year": year,
525 "sort_by": "vote_count.desc",
526 "page": 1,
527 },
528 )
529 else:
530 discover_data = tmdb_get(
531 "discover_tv",
532 {
533 "first_air_date_year": year,
534 "sort_by": "vote_count.desc",
535 "page": 1,
536 },
537 )
538
539 if discover_data:
540 # Filter out the current item
541 self._filter_and_populate(
542 self.more_year_list_id, discover_data, "more_from_year.number"
543 )
544
545 # ─── More from Genres (2062) ──────────────────────────────────────────────
546
547 def _populate_more_from_genres(self):
548 data = self._tmdb_data
549 if not data:
550 return
551
552 genres = getattr(data, "genres", None)
553 if not genres:
554 return
555
556 genre_ids = []
557 for g in genres:
558 gid = g.get("id") if hasattr(g, "get") else getattr(g, "id", None)
559 if gid:
560 genre_ids.append(str(gid))
561
562 if not genre_ids:
563 return
564
565 genre_str = ",".join(genre_ids[:3])
566
567 if self.media_type == "movie":
568 discover_data = tmdb_get(
569 "discover_movie",
570 {
571 "with_genres": genre_str,
572 "sort_by": "vote_count.desc",
573 "page": 1,
574 },
575 )
576 else:
577 discover_data = tmdb_get(
578 "discover_tv",
579 {
580 "with_genres": genre_str,
581 "sort_by": "vote_count.desc",
582 "page": 1,
583 },
584 )
585
586 if discover_data:
587 self._filter_and_populate(
588 self.more_genres_list_id, discover_data, "more_from_genres.number"
589 )
590
591 # ─── More from Networks (2063, TV only) ───────────────────────────────────
592
593 def _populate_more_from_networks(self):
594 if self.media_type != "tv":
595 return
596
597 data = self._tmdb_data
598 if not data:
599 return
600
601 networks = getattr(data, "networks", None)
602 if not networks:
603 return
604
605 network_ids = []
606 for n in networks:
607 nid = n.get("id") if hasattr(n, "get") else getattr(n, "id", None)
608 if nid:
609 network_ids.append(str(nid))
610
611 if not network_ids:
612 return
613
614 network_str = "|".join(network_ids[:3])
615
616 discover_data = tmdb_get(
617 "discover_tv",
618 {
619 "with_networks": network_str,
620 "sort_by": "vote_count.desc",
621 "page": 1,
622 },
623 )
624
625 if discover_data:
626 self._filter_and_populate(
627 self.more_networks_list_id, discover_data, "more_from_networks.number"
628 )
629
630 def _filter_and_populate(self, list_id, tmdb_data, count_property):
631 """Populate a media list, filtering out the current item."""
632 if not tmdb_data or not hasattr(tmdb_data, "results"):
633 return
634
635 results = []
636 for item in tmdb_data.results:
637 item_id = item.get("id")
638 if str(item_id) != str(self.tmdb_id):
639 results.append(item)
640 if len(results) >= 20:
641 break
642
643 if not results:
644 return
645
646 media_list = self.getControlList(list_id)
647 media_list.reset()
648
649 for item in results:
650 title = item.get("title", "") or item.get("name", "")
651 li = xbmcgui.ListItem(label=title)
652 li.setProperty("name", title)
653 li.setProperty("id", str(item.get("id", "")))
654 li.setProperty("media_type", item.get("media_type", self.media_type))
655
656 release = item.get("release_date", "") or item.get("first_air_date", "")
657 if release:
658 li.setProperty("release_date", str(release)[:4])
659 vote = item.get("vote_average")
660 if vote:
661 li.setProperty("vote_average", f"{float(vote):.1f}")
662
663 poster_path = item.get("poster_path")
664 if poster_path:
665 thumb = f"https://image.tmdb.org/t/p/w300{poster_path}"
666 li.setArt({"thumb": thumb, "icon": thumb})
667 li.setProperty("thumbnail", thumb)
668
669 media_list.addItem(li)
670
671 self.setProperty(count_property, f"({len(results)})")
672
673 # ─── IMDb/TMDB Reviews, Trivia, Blunders, Comments ────────────────────────
674
675 def fetch_imdb_data(self):
676 # Wait up to 5 seconds for fetch_details_and_artwork to set self.imdb_id
677 self._details_ready.wait(timeout=5)
678
679 if not self.imdb_id:
680 # Still try TMDB reviews and Trakt comments even without IMDb ID
681 self._fetch_tmdb_reviews()
682 self._fetch_trakt_comments()
683 return
684
685 # 1. IMDb Trivia
686 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}")
691
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}")
700
701 # 3. IMDb Parental Guide
702 try:
703 from lib.api.imdb.imdb_scraper import get_imdb_parentsguide
704
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}")
711
712 # 4. TMDB Reviews
713 self._fetch_tmdb_reviews()
714
715 # 5. Trakt Comments
716 self._fetch_trakt_comments()
717
718 def _fetch_tmdb_reviews(self):
719 try:
720 path = "movie_reviews" if self.media_type == "movie" else "tv_reviews"
721 reviews_data = tmdb_get(path, {"id": self.tmdb_id, "page": 1})
722 reviews_list = []
723 if reviews_data:
724 results = None
725 if hasattr(reviews_data, "results"):
726 results = reviews_data.results
727 elif hasattr(reviews_data, "_obj_list"):
728 results = reviews_data._obj_list
729
730 if results:
731 for rev in results:
732 content = getattr(rev, "content", "") or rev.get("content", "")
733 author = getattr(rev, "author", "") or rev.get(
734 "author", "Unknown"
735 )
736 if content:
737 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 )
741 except Exception as e:
742 kodilog(f"Error fetching TMDB reviews: {e}")
743
744 def _fetch_trakt_comments(self):
745 try:
746 from lib.api.trakt.trakt import TraktAPI
747
748 trakt_comments_data = TraktAPI().lists.trakt_comments(
749 self.media_type, self.tmdb_id, sort_type="likes", page_no=1
750 )
751 comments_list = []
752 if isinstance(trakt_comments_data, tuple):
753 trakt_comments_data = trakt_comments_data[0]
754
755 if trakt_comments_data and isinstance(trakt_comments_data, list):
756 for comment in trakt_comments_data:
757 text = comment.get("comment", "")
758 user = comment.get("user", {}).get("username", "Unknown")
759 if text:
760 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 )
764 except Exception as e:
765 kodilog(f"Error fetching trakt comments: {e}")
766 self._populate_text_panel(
767 self.comments_list_id, [], "trakt_comments.number"
768 )
769
770 def _populate_text_panel(self, list_id, string_list, count_property):
771 try:
772 panel = self.getControlList(list_id)
773 panel.reset()
774 if string_list:
775 for text_item in string_list[:20]:
776 li = xbmcgui.ListItem(label="")
777 li.setProperty("text", text_item)
778 panel.addItem(li)
779 self.setProperty(count_property, f"({len(string_list)})")
780 except Exception as e:
781 kodilog(f"Error populating text panel {list_id}: {e}")
782
783 # ─── Action Handling ──────────────────────────────────────────────────────
784
785 def handle_action(self, action_id, control_id=None):
786 if action_id == 7: # Select/Click
787 self._handle_click(control_id)
788
789 def _handle_click(self, control_id):
790 # Buttons 10-17
791 if control_id == 10: # Play
792 mode = "movies" if self.media_type == "movie" else "tv"
793 tv_data = self.item_information.get("tv_data", "{}")
794
795 self.close()
796 execute_builtin(
797 f"PlayMedia({build_url('search', query=self.title, mode=mode, ids=json.dumps({'tmdb_id': self.tmdb_id, 'imdb_id': self.imdb_id}), tv_data=tv_data)})"
798 )
799
800 elif control_id == 11: # Trailer
801 self.close()
802 execute_builtin(
803 f"PlayMedia({build_url('play_trailer', tmdb_id=self.tmdb_id, media_type=self.media_type, title=self.title)})"
804 )
805
806 elif control_id == 15: # Watchlist
807 try:
808 from lib.api.trakt.trakt import TraktAPI
809
810 TraktAPI().lists.add_to_watchlist(
811 self.media_type, {"tmdb": self.tmdb_id}
812 )
813 notification(translation(90409))
814 except Exception as e:
815 kodilog(f"Error modifying trakt watchlist: {e}")
816 notification(translation(90410))
817
818 elif control_id == 16: # Watched
819 try:
820 from lib.api.trakt.trakt import TraktAPI
821
822 if self.media_type == "movie":
823 TraktAPI().lists.mark_as_watched(
824 "movie", None, None, {"tmdb": self.tmdb_id}
825 )
826 else:
827 TraktAPI().lists.mark_as_watched(
828 "show", None, None, {"tmdb": self.tmdb_id}
829 )
830 notification(translation(90411))
831 except Exception as e:
832 kodilog(f"Error marking watched on trakt: {e}")
833 notification(translation(90412))
834
835 elif control_id == 17: # Refresh
836 notification(translation(90402))
837 self.close()
838
839 # ── YouTube video click ──
840 elif control_id == self.youtube_list_id:
841 try:
842 item = self.getControlList(self.youtube_list_id).getSelectedItem()
843 key = item.getProperty("key")
844 if key:
845 execute_builtin(
846 f"RunPlugin(plugin://plugin.video.youtube/play/?video_id={key})"
847 )
848 except Exception as e:
849 kodilog(f"Error playing YouTube video: {e}")
850
851 # ── Cast click ──
852 elif control_id == self.cast_list_id:
853 item = self.getControlList(self.cast_list_id).getSelectedItem()
854 person_id = item.getProperty("id")
855 if person_id:
856 from lib.gui.actor_info_window import ActorInfoWindow
857
858 actor_window = ActorInfoWindow(
859 "actor_info.xml",
860 ADDON_PATH,
861 person_id=person_id,
862 )
863 actor_window.doModal()
864
865 # ── Media list clicks (recommended, similar, ai_similar, collection, more_from_*) ──
866 elif control_id in [
867 self.recommended_list_id,
868 self.similar_list_id,
869 self.ai_similar_list_id,
870 self.collection_list_id,
871 self.more_year_list_id,
872 self.more_genres_list_id,
873 self.more_networks_list_id,
874 ]:
875 item = self.getControlList(control_id).getSelectedItem()
876 tmdb_id = item.getProperty("id")
877 media_type = item.getProperty("media_type")
878 title = item.getProperty("name")
879 if tmdb_id:
880 mode = "movies" if media_type == "movie" else "tv"
881 execute_builtin(
882 f"RunPlugin(plugin://plugin.video.jacktook/?action=extras&id={tmdb_id}&media_type={media_type}&title={title})"
883 )
884 self.close()
885
886 # ── Text list clicks (reviews, comments, trivia, blunders, parental guide) ──
887 elif control_id in [
888 self.reviews_list_id,
889 self.comments_list_id,
890 self.trivia_list_id,
891 self.blunders_list_id,
892 self.parental_guide_list_id,
893 ]:
894 item = self.getControlList(control_id).getSelectedItem()
895 text = item.getProperty("text")
896 if text:
897 # Remove markdown formatting for the text viewer
898 clean_text = (
899 text.replace("[B]", "")
900 .replace("[/B]", "")
901 .replace("[I]", "")
902 .replace("[/I]", "")
903 )
904
905 heading = translation(90632)
906 if control_id == self.reviews_list_id:
907 heading = translation(90633)
908 elif control_id == self.comments_list_id:
909 heading = translation(90634)
910 elif control_id == self.trivia_list_id:
911 heading = translation(90635)
912 elif control_id == self.blunders_list_id:
913 heading = translation(90636)
914 elif control_id == self.parental_guide_list_id:
915 heading = translation(90637)
916
917 xbmcgui.Dialog().textviewer(heading, clean_text)
plugin.video.jacktook/lib/gui/filter_items_window.py +28 -0
……@@ -0,0 +1,28 @@
1import xbmcgui
2from lib.gui.base_window import BaseWindow
3
4class FilterWindow(BaseWindow):
5 def __init__(self, xml_file, location, filter):
6 super().__init__(xml_file, location, None)
7 self.filter = filter
8 self.selected_filter = None
9
10 def onInit(self):
11 self.list_control = self.getControl(2000)
12 self.list_control.reset()
13
14 for q in self.filter:
15 item = xbmcgui.ListItem(label=q)
16 self.list_control.addItem(item)
17
18 self.set_default_focus(self.list_control, 2000, control_list_reset=True)
19
20
21 def handle_action(self, action_id, control_id=None):
22 if action_id == 7: # Select
23 pos = self.list_control.getSelectedPosition()
24 if pos < len(self.filter):
25 self.selected_filter = self.filter[pos]
26 self.close()
27 elif action_id in (2, 9, 10, 13, 92): # Back/Escape
28 self.close()
plugin.video.jacktook/lib/gui/filter_type_window.py +54 -0
……@@ -0,0 +1,54 @@
1import xbmcgui
2from lib.gui.base_window import BaseWindow
3from lib.utils.kodi.utils import translation
4
5
6class FilterTypeWindow(BaseWindow):
7 def __init__(self, xml_file, location):
8 super().__init__(xml_file, location, None)
9 self.selected_type = None
10
11 def onInit(self):
12 self.list_control = self.getControl(2000)
13 self.list_control.reset()
14
15 quality_item = xbmcgui.ListItem(label=translation(32300))
16 quality_item.setProperty("type", "quality")
17 self.list_control.addItem(quality_item)
18
19 provider_item = xbmcgui.ListItem(label=translation(32301))
20 provider_item.setProperty("type", "provider")
21 self.list_control.addItem(provider_item)
22
23 source_item = xbmcgui.ListItem(label=translation(32302))
24 source_item.setProperty("type", "indexer")
25 self.list_control.addItem(source_item)
26
27 language_item = xbmcgui.ListItem(label=translation(32303))
28 language_item.setProperty("type", "language")
29 self.list_control.addItem(language_item)
30
31 torrents_item = xbmcgui.ListItem(label=translation(32304))
32 torrents_item.setProperty("type", "only_torrents")
33 self.list_control.addItem(torrents_item)
34
35 debrid_item = xbmcgui.ListItem(label=translation(32305))
36 debrid_item.setProperty("type", "only_debrid")
37 self.list_control.addItem(debrid_item)
38
39 reset_item = xbmcgui.ListItem(label=translation(32306))
40 reset_item.setProperty("type", "reset")
41 self.list_control.addItem(reset_item)
42
43 self.set_default_focus(self.list_control, 2000, control_list_reset=True)
44
45 def handle_action(self, action_id, control_id=None):
46 if action_id == 7: # Select
47 selected_item = self.list_control.getSelectedItem()
48 if selected_item:
49 self.selected_type = selected_item.getProperty("type")
50 if self.selected_type == "reset":
51 self.selected_type = None
52 self.close()
53 elif action_id in (2, 9, 10, 13, 92): # Back/Escape
54 self.close()
plugin.video.jacktook/lib/gui/play_next_window.py +22 -0
……@@ -0,0 +1,22 @@
1from lib.gui.play_window import PlayWindow
2
3
4from lib.utils.kodi.utils import get_setting
5
6
7class PlayNext(PlayWindow):
8 def __init__(self, xml_file, xml_location, item_information=None):
9 super().__init__(xml_file, xml_location, item_information=item_information)
10 self.default_action = 2
11
12 def smart_play_action(self):
13 if get_setting("auto_play"):
14 self.handle_action(7, 3001)
15 return
16
17 if (
18 self.default_action == 1
19 and self.playing_file == self.getPlayingFile()
20 and not self.closed
21 ):
22 self.pause()
plugin.video.jacktook/lib/gui/play_window.py +99 -0
……@@ -0,0 +1,99 @@
1import abc
2from lib.gui.base_window import BaseWindow
3from lib.utils.kodi.utils import kodilog
4import xbmc
5
6
7class PlayWindow(BaseWindow):
8 def __init__(self, xml_file, xml_location, item_information=None):
9 try:
10 super().__init__(xml_file, xml_location, item_information=item_information)
11 self.player = xbmc.Player()
12 self.playing_file = self.getPlayingFile()
13 self.duration = self.getTotalTime() - self.getTime()
14 self.closed = False
15 self.action = None
16 except Exception as e:
17 kodilog(f"Error PlayWindow: {e}")
18 self.player = None
19
20 def __del__(self):
21 self.player = None
22 del self.player
23
24 def getTotalTime(self):
25 return self.player.getTotalTime() if self.isPlaying() else 0
26
27 def getTime(self):
28 return self.player.getTime() if self.isPlaying() else 0
29
30 def isPlaying(self):
31 return self.player.isPlaying()
32
33 def getPlayingFile(self):
34 return self.player.getPlayingFile()
35
36 def seekTime(self, seekTime):
37 self.player.seekTime(seekTime)
38
39 def pause(self):
40 self.player.pause()
41
42 def onInit(self):
43 self.background_tasks()
44 super().onInit()
45
46 def calculate_percent(self):
47 return (
48 (int(self.getTotalTime()) - int(self.getTime())) / float(self.duration)
49 ) * 100
50
51 def background_tasks(self):
52 try:
53 try:
54 progress_bar = self.getControlProgress(3014)
55 except RuntimeError:
56 progress_bar = None
57
58 while (
59 int(self.getTotalTime()) - int(self.getTime()) > 2
60 and not self.closed
61 and self.playing_file == self.getPlayingFile()
62 ):
63 xbmc.sleep(500)
64 if progress_bar is not None:
65 progress_bar.setPercent(self.calculate_percent())
66
67 remaining = int(self.getTotalTime()) - int(self.getTime())
68 self.setProperty("timer", str(remaining))
69
70 if self.playing_file != self.getPlayingFile():
71 self.close()
72 return
73
74 if self.playing_file == self.getPlayingFile():
75 self.smart_play_action()
76 except Exception as e:
77 kodilog(f"Error: {e}")
78
79 self.close()
80
81 @abc.abstractmethod
82 def smart_play_action(self):
83 """
84 Perform the default smartplay action at window timeout
85 :return:
86 """
87
88 def close(self):
89 self.closed = True
90 super().close()
91
92 def handle_action(self, action, control_id=None):
93 if action == 7:
94 if control_id == 3001:
95 self.action = "next_episode"
96 self.close()
97 if control_id == 3002:
98 self.action = "close"
99 self.close()
plugin.video.jacktook/lib/gui/qr_progress_dialog.py +85 -0
……@@ -0,0 +1,85 @@
1import xbmcgui
2from lib.utils.kodi.utils import translation
3
4
5class QRProgressDialog(xbmcgui.WindowXMLDialog):
6 def __init__(
7 self,
8 xml_file: str,
9 location: str,
10 ):
11 super().__init__(xml_file, location)
12 self.title = translation(90558)
13 self.message = ""
14 self.progress = 0
15 self.iscanceled = False
16 self.qr_image_path = ""
17
18 def setup(
19 self,
20 title,
21 qr_code,
22 url,
23 user_code="",
24 debrid_type="",
25 is_debrid=True,
26 url_label_id=90557,
27 custom_message=None,
28 ):
29 self.title = title
30 self.qr_image_path = qr_code
31
32 # If custom message provided, use it directly (bypass translation formatting)
33 if custom_message:
34 self.message = custom_message
35 elif is_debrid:
36 if debrid_type == "RealDebrid":
37 self.message = "%s\n\n%s" % (
38 translation(90541) % "https://real-debrid.com/device",
39 translation(90542) % user_code,
40 )
41 else:
42 self.message = translation(90555) % (url, user_code)
43 else:
44 # For non-debrid services (like Trakt)
45 if user_code:
46 self.message = translation(90556) % (url, user_code)
47 else:
48 # Avoid format errors if translation string doesn't have placeholder
49 trans = translation(url_label_id)
50 try:
51 self.message = trans % url
52 except TypeError:
53 # Translation string doesn't have %s, use as-is
54 self.message = trans
55
56 def show_dialog(self):
57 self.show()
58 self.onInit()
59
60 def close_dialog(self):
61 self.close()
62
63 def onInit(self):
64 self.getControl(12001).setLabel(self.title) # Title label
65 self.getControl(12002).setLabel(self.message) # Message / instructions
66 self.getControl(12004).setPercent(self.progress) # Progress bar
67 if self.qr_image_path:
68 self.getControl(12006).setImage(self.qr_image_path) # QR image
69 self.setFocusId(12003) # Focus on Close button
70
71 def update_progress(self, percent, message=None):
72 self.progress = percent
73 self.getControl(12004).setPercent(percent)
74 if message:
75 self.getControl(12002).setLabel(message)
76
77 def onClick(self, controlId):
78 if controlId == 12003: # Close button
79 self.iscanceled = True
80 self.close_dialog()
81
82 def onAction(self, action):
83 if action.getId() in (10, 92):
84 self.iscanceled = True
85 self.close_dialog()
plugin.video.jacktook/lib/gui/resolver_window.py +201 -0
……@@ -0,0 +1,201 @@
1from typing import Optional, Dict, Any, cast
2from datetime import timedelta
3
4from lib.clients.subtitle.submanager import SubtitleManager
5from lib.db.cached import cache
6from lib.gui.base_window import BaseWindow
7from lib.gui.source_pack_select import SourcePackSelect
8from lib.player import JacktookPLayer
9from lib.utils.debrid.debrid_utils import get_pack_info
10from lib.utils.kodi.utils import get_setting, kodilog
11from lib.utils.kodi.utils import ADDON_PATH, notification, set_property
12from lib.domain.torrent import TorrentStream
13
14
15class ResolverWindow(BaseWindow):
16 def __init__(
17 self,
18 xml_file: str,
19 location: str,
20 source: TorrentStream,
21 item_information: Optional[Dict[str, Any]] = None,
22 previous_window: Optional[BaseWindow] = None,
23 close_callback: Optional[Any] = None,
24 is_subtitle_download: bool = False,
25 local_subtitle_path: Optional[str] = None,
26 ) -> None:
27 super().__init__(
28 xml_file,
29 location,
30 item_information=item_information,
31 previous_window=previous_window,
32 )
33 self.stream_data: Optional[Any] = None
34 self.progress: int = 1
35 self.resolver: Optional[Any] = None
36 self.source: TorrentStream = source
37 self.pack_select: bool = False
38 self.is_subtitle_download = is_subtitle_download
39 self.local_subtitle_path = local_subtitle_path
40 self.item_information: Dict = item_information or {}
41 self.close_callback: Optional[Any] = close_callback
42 self.playback_info: Optional[Dict[str, Any]] = None
43 self.pack_info: Optional[Any] = None
44 self.previous_window: BaseWindow = previous_window
45 self.setProperty("enable_busy_spinner", "false")
46 self.resolved = False
47
48 def doModal(
49 self,
50 pack_select: bool = False,
51 ) -> bool:
52 self.pack_select = pack_select
53
54 self._update_window_properties(self.source)
55 super().doModal()
56 return self.resolved
57
58 def onInit(self) -> None:
59 super().onInit()
60 self.resolve_source()
61
62 def close_windows(self):
63 if self.previous_window:
64 self.previous_window.setProperty("instant_close", "true")
65 self.previous_window.close()
66 self.close()
67
68 def handle_playback_started(self):
69 self.resolved = True
70 self.close_windows()
71
72 def handle_playback_error(self):
73 self.resolved = False
74 self.setProperty("resolving", "false")
75 self.close()
76
77 def resolve_source(self) -> Optional[Dict[str, Any]]:
78 try:
79 is_pack = self.source.isPack or self.pack_select
80 has_debrid = bool(self.source.debridType)
81
82 if is_pack and has_debrid:
83 self.resolve_pack_source()
84 else:
85 self.resolve_single_source()
86
87 if self.is_subtitle_download:
88 self._download_subtitle()
89
90 if self.local_subtitle_path and self.playback_info:
91 existing_subtitles = self.playback_info.get("subtitles_path", [])
92 if self.local_subtitle_path not in existing_subtitles:
93 existing_subtitles.append(self.local_subtitle_path)
94 self.playback_info["subtitles_path"] = existing_subtitles
95 set_property("search_subtitles", "true")
96
97 if not self.playback_info:
98 raise Exception("Failed to resolve source")
99
100 player = JacktookPLayer(
101 on_started=self.handle_playback_started,
102 on_error=self.handle_playback_error,
103 )
104 player.run(data=self.playback_info)
105 except Exception as e:
106 import traceback
107
108 kodilog(f"Error in resolver window: {e}")
109 kodilog(traceback.format_exc())
110 notification(str(e), time=5000)
111 if self.previous_window:
112 self.previous_window.setProperty("instant_close", "true")
113 self.previous_window.close()
114 self.close()
115
116 def resolve_single_source(self) -> None:
117 self.playback_info = self._ensure_playback_info(source=self.source)
118 if self.playback_info:
119 self.playback_info.update(self.item_information)
120 self._cache_playback_info()
121
122 def resolve_pack_source(self) -> None:
123 self.pack_info = get_pack_info(
124 debrid_type=self.source.debridType,
125 info_hash=self.source.infoHash,
126 )
127 self.window = SourcePackSelect(
128 "source_pack_select.xml",
129 ADDON_PATH,
130 source=self.source,
131 pack_info=self.pack_info,
132 item_information=self.item_information,
133 )
134 self.playback_info = self.window.doModal()
135 if self.playback_info:
136 self.playback_info.update(self.item_information)
137 self._cache_playback_info()
138 else:
139 self.close_windows()
140
141 del self.window
142
143 def _cache_playback_info(self) -> None:
144 playback_info = cast(Dict[str, Any], self.playback_info or {})
145 if get_setting("super_quick_play", False):
146 kodilog("Super quick play enabled, caching playback info")
147 key = ""
148 if "ids" in playback_info:
149 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 )
156
157 if key:
158 tv_data = cast(Dict[str, Any], playback_info.get("tv_data", {}) or {})
159 if tv_data and "season" in tv_data and "episode" in tv_data:
160 key += f'_{tv_data["season"]}_{tv_data["episode"]}'
161
162 if key:
163 cache.set(key, playback_info, timedelta(days=30))
164 kodilog(f"Playback info cached with key: {key}")
165
166 def _download_subtitle(self):
167 try:
168 subtitle_manager = SubtitleManager(self.playback_info, notification)
169 subtitles_path = subtitle_manager.fetch_subtitles()
170 if subtitles_path:
171 set_property("search_subtitles", "true")
172 if self.playback_info:
173 self.playback_info.update({"subtitles_path": subtitles_path})
174 else:
175 if subtitle_manager.last_fetch_status == "not_found":
176 kodilog("No subtitles found, continuing without subtitles")
177 elif subtitle_manager.last_fetch_status == "not_selected":
178 kodilog("No subtitles selected, continuing without subtitles")
179 else:
180 kodilog("Subtitle download skipped, continuing without subtitles")
181 except Exception as e:
182 kodilog(f"Subtitle download failed, continuing without subtitles: {e}")
183
184 def _update_window_properties(self, source: TorrentStream) -> None:
185 self.setProperty("enable_busy_spinner", "true")
186
187
188class SourceException(Exception):
189 def __init__(
190 self, message: str, status_code: Optional[int] = None, error_content: Any = None
191 ):
192 self.message = message
193 self.status_code = status_code
194 self.error_content = error_content
195 details = f"{self.message}"
196 if self.status_code is not None:
197 details += f" (Status code: {self.status_code})"
198 if self.error_content is not None:
199 details += f"\nError content: {self.error_content}"
200 super().__init__(details)
201 notification(details)
plugin.video.jacktook/lib/gui/resume_window.py +28 -0
……@@ -0,0 +1,28 @@
1from lib.gui.base_window import BaseWindow
2from lib.utils.kodi.utils import translation
3
4class ResumeDialog(BaseWindow):
5 def __init__(self, xml_file, xml_location, **kwargs):
6 super().__init__(xml_file, xml_location)
7 self.resume = None
8 self.resume_percent = kwargs.get("resume_percent", 0.0)
9
10 def doModal(self):
11 super().doModal()
12
13 def onInit(self):
14 super().onInit()
15 self.getControl(1002).setLabel(translation(90538) % self.resume_percent)
16 self.getControl(1003).setLabel(translation(90539))
17
18 def onClick(self, control_id):
19 if control_id == 1002: # Resume button ID
20 self.resume = True
21 elif control_id == 1003: # Start button ID
22 self.resume = False
23 self.close()
24
25 def handle_action(self, action_id, control_id=None):
26 if action_id in (10, 92): # Back or Escape
27 self.resume = None
28 self.close()
plugin.video.jacktook/lib/gui/search_status_window.py +215 -0
……@@ -0,0 +1,215 @@
1from dataclasses import dataclass, field
2from typing import List, Optional, Callable, Dict, Any
3from concurrent.futures import Future, ThreadPoolExecutor, as_completed
4import threading
5
6import xbmc
7import xbmcgui
8
9from lib.utils.kodi.utils import kodilog, translation
10from lib.gui.base_window import BaseWindow
11from lib.domain.torrent import TorrentStream
12
13
14STATUS_MAP = {
15 "Pending": 90244,
16 "In Progress": 90245,
17 "Completed": 90246,
18 "Failed": 90247,
19 "Cancelled": 90248,
20}
21
22
23@dataclass
24class SearchTask:
25 name: str
26 indexer_key: str
27 status: str = (
28 "Pending" # "Pending", "In Progress", "Completed", "Failed", "Cancelled"
29 )
30 result_count: int = 0
31 error: str = ""
32 future: Optional[Future] = None
33
34
35class SearchTaskManager:
36 def __init__(self, executor: ThreadPoolExecutor):
37 self.executor = executor
38 self.tasks: List[SearchTask] = []
39 self._cancel_event = threading.Event()
40
41 def submit_task(
42 self, name: str, indexer_key: str, fn: Callable, *args, **kwargs
43 ) -> SearchTask:
44 task = SearchTask(name=name, indexer_key=indexer_key)
45 self.tasks.append(task)
46
47 def wrapped_fn(*a, **kw):
48 if self._cancel_event.is_set():
49 task.status = "Cancelled"
50 return []
51
52 task.status = "In Progress"
53 try:
54 # ARTIFICIAL DELAY FOR TESTING GUI UPDATES
55 # xbmc.sleep(8000)
56
57 results = fn(*a, **kw)
58
59 if self._cancel_event.is_set():
60 task.status = "Cancelled"
61 return results if results else []
62
63 task.status = "Completed"
64 task.result_count = len(results) if results else 0
65 return results
66 except Exception as e:
67 task.status = "Failed"
68 task.error = str(e)
69 kodilog(f"Task {name} failed: {e}")
70 return []
71
72 future = self.executor.submit(wrapped_fn, *args, **kwargs)
73 task.future = future
74 return task
75
76 def cancel_pending(self):
77 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"
82
83 @property
84 def is_cancelled(self) -> bool:
85 return self._cancel_event.is_set()
86
87 @property
88 def is_complete(self) -> bool:
89 return all(t.status in ["Completed", "Failed", "Cancelled"] for t in self.tasks)
90
91 def collect_results(self) -> List[TorrentStream]:
92 total_results = []
93 for task in self.tasks:
94 if task.future and task.future.done():
95 try:
96 res = task.future.result()
97 if res:
98 total_results.extend(res)
99 except Exception as e:
100 kodilog(f"Error collecting result for {task.name}: {e}")
101 return total_results
102
103
104class SearchStatusWindow(BaseWindow):
105 MAX_TASK_SLOTS = 10
106
107 def __init__(
108 self,
109 xml_file: str,
110 location: str,
111 task_manager: SearchTaskManager,
112 item_information=None,
113 ):
114 super().__init__(xml_file, location, item_information=item_information)
115 self.task_manager = task_manager
116 self._monitor = xbmc.Monitor()
117
118 def onInit(self):
119 self.getControl(13001).setLabel(translation(90241))
120 self.setProperty("instant_close", "false")
121
122 # 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()
137
138 def _get_status_color(self, status: str) -> str:
139 if status == "Pending":
140 return "FF888888" # Grey
141 elif status == "In Progress":
142 return "FFFFD700" # Yellow
143 elif status == "Completed":
144 return "FF00FF00" # Green
145 elif status == "Failed":
146 return "FFFF0000" # Red
147 elif status == "Cancelled":
148 return "FFFFA500" # Orange
149 return "FFFFFFFF" # White
150
151 def _update_loop(self):
152 while not self._monitor.abortRequested() and not self.task_manager.is_complete:
153 xbmc.sleep(250)
154
155 if self.task_manager.is_cancelled:
156 break
157
158 self._update_ui_state()
159
160 # Final update
161 self._update_ui_state()
162
163 # Give user a brief moment to see final state if not cancelled
164 if not self.task_manager.is_cancelled:
165 xbmc.sleep(500)
166
167 self.close()
168
169 def _update_ui_state(self):
170 total_tasks = len(self.task_manager.tasks)
171 completed_tasks = 0
172 total_results = 0
173
174 for i, task in enumerate(self.task_manager.tasks):
175 if i >= self.MAX_TASK_SLOTS:
176 break
177
178 status_text = translation(STATUS_MAP.get(task.status, 90244))
179 self.setProperty(f"task_{i}_name", task.name)
180 self.setProperty(f"task_{i}_status", status_text)
181 self.setProperty(f"task_{i}_color", self._get_status_color(task.status))
182
183 total_results += task.result_count
184
185 if task.status == "Completed":
186 completed_tasks += 1
187 self.setProperty(f"task_{i}_results", str(task.result_count))
188 elif task.status in ["Failed", "Cancelled"]:
189 completed_tasks += 1
190 self.setProperty(f"task_{i}_results", "")
191 elif task.status == "In Progress":
192 self.setProperty(
193 f"task_{i}_results",
194 str(task.result_count) if task.result_count > 0 else "",
195 )
196 else:
197 self.setProperty(f"task_{i}_results", "")
198
199 # Update progress
200 if total_tasks > 0:
201 percent = int((completed_tasks / total_tasks) * 100)
202 self.getControl(13002).setPercent(percent)
203
204 self.setProperty("search_results_count", f"{translation(90243)}{total_results}")
205
206 def onAction(self, action):
207 action_id = action.getId()
208 if action_id in self.action_exitkeys_id:
209 self.task_manager.cancel_pending()
210 super().onAction(action)
211
212 def handle_action(self, action_id, control_id=None):
213 if action_id == 7 and control_id == 13004: # Cancel button clicked
214 self.task_manager.cancel_pending()
215 self.close()
plugin.video.jacktook/lib/gui/skip_intro_window.py +92 -0
……@@ -0,0 +1,92 @@
1from threading import Thread
2
3import xbmc
4import xbmcgui
5
6from lib.utils.kodi.utils import ADDON_PATH, kodilog
7
8
9ACTION_PREVIOUS_MENU = 10
10ACTION_PLAYER_STOP = 13
11ACTION_NAV_BACK = 92
12
13
14class SkipIntroWindow(xbmcgui.WindowXMLDialog):
15 """
16 Overlay dialog that shows a 'Skip Intro' or 'Skip Recap' button
17 during playback when IntroDB segment data is available.
18 """
19
20 def __init__(self, xml_file, xml_location, segment_data=None, label="Skip Intro"):
21 super().__init__(xml_file, xml_location)
22 self.segment_data = segment_data or {}
23 self.label = label
24 self.action = None
25 self.closed = False
26 self.monitor_thread = None
27 self.player = xbmc.Player()
28
29 def onInit(self):
30 self.setProperty("skip_label", self.label)
31 self._start_background_monitor()
32
33 def _start_background_monitor(self):
34 if self.monitor_thread and self.monitor_thread.is_alive():
35 return
36
37 self.monitor_thread = Thread(target=self._background_monitor)
38 self.monitor_thread.daemon = True
39 self.monitor_thread.start()
40
41 def _background_monitor(self):
42 """Monitor playback and auto-close when segment ends or playback stops."""
43 try:
44 end_sec = self.segment_data.get("end_sec", 0)
45
46 while not self.closed and self.player.isPlaying():
47 try:
48 current_time = self.player.getTime()
49 except RuntimeError:
50 break
51
52 # Auto-close if we've passed the segment end
53 if current_time >= end_sec:
54 break
55
56 xbmc.sleep(500)
57 except Exception as e:
58 kodilog(f"SkipIntroWindow monitor error: {e}")
59
60 if not self.closed:
61 self.close()
62
63 def onClick(self, control_id):
64 if control_id == 4001:
65 # Skip button pressed
66 self.action = "skip"
67 self._do_skip()
68 self.close()
69 elif control_id == 4002:
70 # Dismiss button pressed
71 self.action = "dismiss"
72 self.close()
73
74 def onAction(self, action):
75 action_id = action.getId()
76 if action_id in (ACTION_PREVIOUS_MENU, ACTION_PLAYER_STOP, ACTION_NAV_BACK):
77 self.action = "dismiss"
78 self.close()
79
80 def _do_skip(self):
81 """Seek to the end of the current segment."""
82 try:
83 end_sec = self.segment_data.get("end_sec", 0)
84 if end_sec > 0 and self.player.isPlaying():
85 self.player.seekTime(end_sec)
86 kodilog(f"SkipIntroWindow: Skipped to {end_sec}s")
87 except Exception as e:
88 kodilog(f"SkipIntroWindow: Error seeking: {e}")
89
90 def close(self):
91 self.closed = True
92 super().close()
plugin.video.jacktook/lib/gui/source_pack_select.py +79 -0
……@@ -0,0 +1,79 @@
1from typing import Optional, Dict, Any
2from lib.gui.source_pack_window import SourcePackWindow
3from lib.utils.general.utils import DebridType
4from lib.domain.torrent import TorrentStream
5from lib.utils.player.utils import resolve_playback_url
6
7
8class SourcePackSelect(SourcePackWindow):
9 def __init__(
10 self,
11 xml_file: str,
12 location: str,
13 source: Optional[TorrentStream] = None,
14 pack_info: Optional[Dict[str, Any]] = None,
15 item_information: Optional[Dict[str, Any]] = None,
16 ) -> None:
17 super().__init__(
18 xml_file, location, pack_info=pack_info, item_information=item_information
19 )
20 self.source: Optional[TorrentStream] = source
21 self.pack_info: Optional[Dict[str, Any]] = pack_info
22 self.position: int = -1
23 self.playback_info: Optional[Dict[str, Any]] = None
24 self.setProperty("instant_close", "false")
25 self.setProperty("resolving", "false")
26
27 def doModal(self) -> Optional[Dict[str, Any]]:
28 super().doModal()
29 return self.playback_info
30
31 def handle_action(self, action_id: int, control_id: Optional[int] = None) -> None:
32 self.position = self.display_list.getSelectedPosition()
33 if action_id == 7:
34 if control_id == 1000:
35 self._resolve_item()
36
37 def _resolve_item(self) -> None:
38 self.setProperty("resolving", "true")
39
40 if self.source and self.pack_info:
41 common_data = {
42 "type": self.source.type,
43 "debrid_type": self.source.debridType,
44 "is_torrent": False,
45 "is_pack": True,
46 "mode": self.item_information.get("mode"),
47 "ids": self.item_information.get("ids"),
48 "tv_data": self.item_information.get("tv_data"),
49 }
50 if self.source.debridType in [DebridType.RD, DebridType.TB]:
51 file_id, title = self.pack_info["files"][self.position]
52 data = {
53 **common_data,
54 "title": title,
55 "pack_info": {
56 "file_id": file_id,
57 "file_position": self.position,
58 "torrent_id": self.pack_info["torrent_id"],
59 },
60 }
61 else:
62 url, title = self.pack_info["files"][self.position]
63 data = {
64 **common_data,
65 "title": title,
66 "pack_info": {
67 "file_position": self.position,
68 "url": url,
69 },
70 }
71 self.playback_info = resolve_playback_url(data)
72
73 if not self.playback_info:
74 self.setProperty("resolving", "false")
75 self.close()
76 return
77
78 self.setProperty("instant_close", "true")
79 self.close()
plugin.video.jacktook/lib/gui/source_pack_window.py +37 -0
……@@ -0,0 +1,37 @@
1import abc
2import xbmcgui
3from lib.gui.base_window import BaseWindow
4
5
6class SourcePackWindow(BaseWindow):
7 def __init__(self, xml_file, location, source=None, pack_info=None, item_information=None):
8 super().__init__(xml_file, location, item_information=item_information)
9 self.pack_info = pack_info or {"files": []}
10 self.source = source or type("EmptySource", (), {"type": "", "quality": ""})()
11 self.display_list = None
12
13 def onInit(self):
14 self.display_list = self.getControlList(1000)
15 self.populate_sources_list()
16 self.set_default_focus(self.display_list, 1000, control_list_reset=True)
17 super().onInit()
18
19 def populate_sources_list(self):
20 self.display_list.reset()
21
22 files = self.pack_info.get("files", [])
23 if files:
24 for _, title in files:
25 menu_item = xbmcgui.ListItem(label=title)
26 menu_item.setProperty("title", title or "")
27 menu_item.setProperty("type", getattr(self.source, "type", "") or "")
28 menu_item.setProperty("quality", getattr(self.source, "quality", "") or "")
29 self.display_list.addItem(menu_item)
30 else:
31 self.setProperty("resolving", "false")
32 self.close()
33
34 @abc.abstractmethod
35 def handle_action(self, action_id, control_id=None):
36 """Subclasses must implement this to handle user actions."""
37 pass
plugin.video.jacktook/lib/gui/source_select.py +640 -0
……@@ -0,0 +1,640 @@
1from typing import List, Optional, Dict
2from lib.domain.torrent import TorrentStream
3from lib.gui.filter_type_window import FilterTypeWindow
4from lib.gui.filter_items_window import FilterWindow
5from lib.gui.base_window import BaseWindow
6from lib.gui.resolver_window import ResolverWindow
7from lib.gui.resume_window import ResumeDialog
8from lib.utils.debrid.debrid_utils import (
9 add_source_to_debrid,
10 get_torrent_data_from_uri,
11 get_source_status,
12)
13from lib.utils.kodi.utils import (
14 action_url_run,
15 bytes_to_human_readable,
16 ADDON_PATH,
17 get_setting,
18 kodilog,
19 notification,
20 translatePath,
21 translation,
22)
23from lib.utils.general.utils import (
24 IndexerType,
25 extract_publish_date,
26 get_info_hash_from_magnet,
27 get_colored_languages,
28 get_provider_color,
29 get_random_color,
30 format_season_episode,
31 safe_json_loads,
32 normalize_tv_data,
33)
34from lib.utils.parsers.title_parser import parse_title_info
35from lib.utils.torrent.torrserver_utils import add_source_to_torrserver
36
37import xbmcgui
38import xbmc
39import threading
40
41# For web subtitle upload
42from lib.services.subtitle_server import SubtitleUploadServer, get_local_ip
43from lib.utils.debrid.qrcode_utils import make_qrcode
44from lib.gui.qr_progress_dialog import QRProgressDialog
45
46THEMES = {
47 "0": {"card_bg": "FF362e33", "card_focus": "992A3E5C", "card_accent": "FF00559D"},
48 "1": {"card_bg": "FF000000", "card_focus": "FF1A1A1A", "card_accent": "FF333333"},
49 "2": {"card_bg": "FF1A0B2E", "card_focus": "994D004D", "card_accent": "FF00F3FF"},
50 "3": {"card_bg": "FF1B261B", "card_focus": "992D402D", "card_accent": "FF7CFC00"},
51 "4": {"card_bg": "FF141414", "card_focus": "992B2510", "card_accent": "FFD4AF37"},
52}
53
54
55class SourceSelect(BaseWindow):
56 def __init__(
57 self,
58 xml_file: str,
59 location: str,
60 item_information: Optional[Dict] = None,
61 sources: Optional[List[TorrentStream]] = None,
62 uncached: Optional[List[TorrentStream]] = None,
63 ):
64 super().__init__(xml_file, location, item_information=item_information)
65 self.uncached_sources: List[TorrentStream] = uncached or []
66 self.position: int = -1
67 self.sources: List[TorrentStream] = sources or []
68 self.list_sources: List[TorrentStream] = []
69 self.item_information: Dict = item_information or {}
70 self.playback_info: Optional[Dict] = None
71 self.resume: Optional[bool] = None
72 self.CACHE_KEY: str = str(self.item_information.get("tv_data", "")) or str(
73 self.item_information.get("ids", "")
74 )
75 self.setProperty("instant_close", "false")
76 self.setProperty("resolving", "false")
77 self.filtered_sources: Optional[List[TorrentStream]] = None
78 self.filter_applied: bool = False
79 self.resolved = False
80
81 def onInit(self) -> None:
82 theme_index = get_setting("source_select_theme", "0")
83 theme = THEMES.get(str(theme_index), THEMES["0"])
84 self.setProperty("style.card_bg", theme["card_bg"])
85 self.setProperty("style.card_focus", theme["card_focus"])
86 self.setProperty("style.card_accent", theme["card_accent"])
87
88 self.display_list: xbmcgui.ControlList = self.getControlList(1000)
89 self.populate_qualities_header()
90 self.populate_sources_list()
91 self.set_default_focus(self.display_list, 1000, control_list_reset=True)
92 super().onInit()
93
94 def populate_qualities_header(self):
95 from collections import Counter
96
97 fixed_qualities = [
98 ("[B][COLOR yellow]4k[/COLOR][/B]", "4k"),
99 ("[B][COLOR blue]1080p[/COLOR][/B]", "1080p"),
100 ("[B][COLOR orange]720p[/COLOR][/B]", "720p"),
101 ("[B][COLOR yellow]N/A[/COLOR][/B]", "N/A"),
102 ]
103
104 qualities = [s.quality for s in self.sources if s.quality]
105 counts = Counter(qualities)
106 qualities_list = self.getControl(1300)
107 qualities_list.reset()
108
109 # Set fixed width for 4 items
110 self.setProperty("quality_item_width", str(int(800 / 4)))
111
112 for display, key in fixed_qualities:
113 count = counts.get(display, 0)
114 label = f"{display} ({count})"
115 list_item = xbmcgui.ListItem(label)
116 list_item.setProperty("quality", key)
117 qualities_list.addItem(list_item)
118
119 def doModal(self) -> bool:
120 super().doModal()
121 return self.resolved
122
123 def handle_action(self, action_id: int, control_id: Optional[int] = None) -> None:
124 if action_id == 1 and control_id == 1000:
125 self._handle_filter_action()
126 elif action_id == 117: # Context menu action
127 self._handle_context_menu_action()
128 elif control_id == 1300:
129 self._handle_quality_select_action()
130 elif action_id == 7 and control_id == 1000: # Select action
131 self._handle_select_action(control_id)
132
133 def _handle_filter_action(self):
134 filter_type_popup = FilterTypeWindow("filter_type.xml", ADDON_PATH)
135 filter_type_popup.doModal()
136 selected_type = filter_type_popup.selected_type
137 del filter_type_popup
138
139 def get_unique(attr):
140 return sorted(
141 set(getattr(s, attr) for s in self.sources if getattr(s, attr))
142 )
143
144 filter_map = {
145 "quality": {
146 "items": lambda: get_unique("quality"),
147 "filter": lambda val: [s for s in self.sources if s.quality == val],
148 },
149 "provider": {
150 "items": lambda: get_unique("provider"),
151 "filter": lambda val: [s for s in self.sources if s.provider == val],
152 },
153 "type": {
154 "items": lambda: get_unique("type"),
155 "filter": lambda val: [s for s in self.sources if s.type == val],
156 },
157 "indexer": {
158 "items": lambda: get_unique("indexer"),
159 "filter": lambda val: [s for s in self.sources if s.indexer == val],
160 },
161 "language": {
162 "items": self._get_all_languages,
163 "filter": lambda val: [
164 s
165 for s in self.sources
166 if val in getattr(s, "languages", [])
167 or val in getattr(s, "fullLanguages", [])
168 ],
169 },
170 }
171
172 one_click_filters = {
173 "only_torrents": lambda: [s for s in self.sources if s.type == "Torrent"],
174 "only_debrid": lambda: [s for s in self.sources if s.type == "Debrid"],
175 }
176
177 if selected_type in one_click_filters:
178 filtered_sources = one_click_filters[selected_type]()
179 if not filtered_sources:
180 kodilog("No sources found matching the filter")
181 return
182 self.filtered_sources = filtered_sources
183 self.filter_applied = True
184 elif selected_type in filter_map:
185 items = filter_map[selected_type]["items"]()
186 if selected_type == "language" and not items:
187 notification(translation(90406))
188 return
189 popup = FilterWindow("filter_items.xml", ADDON_PATH, filter=items)
190 popup.doModal()
191 selected_filter = popup.selected_filter
192 del popup
193 if selected_filter is not None:
194 filtered_sources = filter_map[selected_type]["filter"](selected_filter)
195 if not filtered_sources:
196 kodilog("No sources found matching the filter")
197 return
198 self.filtered_sources = filtered_sources
199 self.filter_applied = True
200 else:
201 self.filtered_sources = None
202 self.filter_applied = False
203 else:
204 self.filtered_sources = None
205 self.filter_applied = False
206
207 self.populate_sources_list()
208 self.set_default_focus(self.display_list, 1000, control_list_reset=True)
209
210 def _handle_context_menu_action(self):
211 self.position = self.display_list.getSelectedPosition()
212 selected_source = self.list_sources[self.position]
213
214 menu_items = []
215 menu_actions = []
216
217 if selected_source.type == IndexerType.TORRENT:
218 menu_items.extend(
219 [
220 translation(90365),
221 translation(90359),
222 translation(90083),
223 translation(90744),
224 ]
225 )
226 menu_actions.extend(
227 [
228 "download_to_debrid",
229 "add_to_torrserver",
230 "download_file",
231 "upload_subtitle",
232 ]
233 )
234 elif selected_source.type in (IndexerType.DIRECT, IndexerType.STREMIO_DEBRID):
235 menu_items.extend(
236 [translation(90083), translation(90082), translation(90744)]
237 )
238 menu_actions.extend(
239 ["download_file", "subtitle_download", "upload_subtitle"]
240 )
241 else:
242 menu_items.extend(
243 [
244 translation(90084),
245 translation(90083),
246 translation(90082),
247 translation(90744),
248 ]
249 )
250 menu_actions.extend(
251 [
252 "pack_select",
253 "download_file",
254 "subtitle_download",
255 "upload_subtitle",
256 ]
257 )
258
259 response = xbmcgui.Dialog().contextmenu(menu_items)
260 if response < 0 or response >= len(menu_actions):
261 return
262
263 action = menu_actions[response]
264 kodilog(
265 f"SourceSelect context action selected: action={action}, mode={self.item_information.get('mode')}, query={self.item_information.get('query')}, year={self.item_information.get('year')}"
266 )
267 if action == "download_to_debrid":
268 try:
269 self._download_to_debrid(selected_source)
270 except Exception as e:
271 kodilog(f"SourceSelect _download_to_debrid raised: {e}")
272 notification(str(e))
273 elif action == "add_to_torrserver":
274 self._add_to_torrserver(selected_source)
275 elif action == "download_file":
276 self._download_file(selected_source)
277 elif action == "subtitle_download":
278 self._resolve_item(selected_source, is_subtitle_download=True)
279 elif action == "pack_select":
280 self._resolve_item(selected_source, pack_select=True)
281 elif action == "upload_subtitle":
282 self._upload_subtitle(selected_source)
283
284 def _handle_quality_select_action(self):
285 quality_list = self.getControl(1300)
286 selected_item = quality_list.getSelectedItem()
287 if selected_item:
288 selected_quality = selected_item.getProperty("quality")
289 filtered_sources = [
290 s for s in self.sources if selected_quality in s.quality
291 ]
292 if not filtered_sources:
293 kodilog("No sources found matching the filter")
294 return
295 self.filtered_sources = filtered_sources
296 self.filter_applied = True
297 self.populate_sources_list()
298
299 def _handle_select_action(self, control_id):
300 self.position = self.display_list.getSelectedPosition()
301 selected_source = self.list_sources[self.position]
302
303 control_list = self.getControl(control_id)
304 self.set_cached_focus(control_id, control_list.getSelectedPosition())
305 self._resolve_item(selected_source, pack_select=False)
306
307 def populate_sources_list(self) -> None:
308 self.display_list.reset()
309 self.list_sources = (
310 self.filtered_sources
311 if self.filter_applied and self.filtered_sources is not None
312 else self.sources
313 )
314
315 for source in self.list_sources:
316 info = parse_title_info(source.title)
317 menu_item = xbmcgui.ListItem(label=source.title)
318 menu_item.setProperty("title", source.title)
319 menu_item.setProperty("display_title", info["clean_title"])
320 menu_item.setProperty("codec", info["codec"])
321 menu_item.setProperty("audio", info["audio"])
322 menu_item.setProperty("hdr_info", info["badges"])
323 menu_item.setProperty("release_group", info["release_group"])
324 kodilog(f"SourceSelect populate_sources_list: source.type={source.type}")
325 if source.type in IndexerType.TORRENT:
326 if source.addedToDebrid and source.debridType:
327 provider_name = source.debridType
328 else:
329 provider_name = source.type or source.subindexer
330 elif source.type == IndexerType.DEBRID:
331 provider_name = source.debridType or source.type
332 elif source.type == IndexerType.STREMIO_DEBRID:
333 if source.debridType:
334 provider_name = source.debridType
335 elif source.isCached and not source.infoHash:
336 provider_name = source.type
337 else:
338 provider_name = source.subindexer or source.type
339 elif source.type == IndexerType.DIRECT:
340 provider_name = source.indexer or source.type
341 else:
342 provider_name = source.debridType or source.type
343 indexer_label = source.addonInstanceLabel or source.indexer
344 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
434 def _download_file(self, selected_source) -> None:
435 self.playback_info = self._ensure_playback_info(selected_source)
436 if self.playback_info:
437 self.playback_info.update(self.item_information)
438 else:
439 notification(translation(90407))
440 return
441
442 download_dir = get_setting("download_dir")
443 try:
444 title = (self.playback_info.get("title") or self.item_information.get("title") or "").strip()
445 year = self.item_information.get("year", "")
446 tv_data = self.playback_info.get("tv_data")
447 if isinstance(tv_data, str):
448 tv_data = safe_json_loads(tv_data, {})
449 tv_data = normalize_tv_data(tv_data)
450 if tv_data and tv_data.get("season") and tv_data.get("episode"):
451 season = int(tv_data["season"])
452 episode = int(tv_data["episode"])
453 file_name = f"{title} - {format_season_episode(season, episode)}"
454 elif year:
455 file_name = f"{title} ({year})"
456 else:
457 file_name = title
458
459 xbmc.executebuiltin(
460 action_url_run(
461 "handle_download_file",
462 file_name=file_name,
463 url=self.playback_info["url"],
464 destination=translatePath(download_dir),
465 )
466 )
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)
plugin.video.jacktook/lib/jacktook/__init__.py +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/lib/jacktook/client.py +63 -0
……@@ -0,0 +1,63 @@
1from lib.clients.base import BaseClient, TorrentStream
2from .providers import (
3 burst_search,
4 burst_search_episode,
5 burst_search_movie,
6 burst_search_season,
7)
8from lib.utils.kodi.utils import convert_size_to_bytes, get_setting
9from typing import List, Optional, Dict, Any, Callable
10
11
12class Burst(BaseClient):
13 def __init__(self, notification: Callable) -> None:
14 super().__init__("", notification)
15
16 def search(
17 self,
18 tmdb_id: str,
19 query: str,
20 mode: str,
21 media_type: str,
22 season: Optional[int],
23 episode: Optional[int],
24 silent: bool = False,
25 ) -> Optional[List[TorrentStream]]:
26 try:
27 if mode == "tv" or media_type == "tv":
28 if get_setting("include_season_packs"):
29 results = burst_search_season(tmdb_id, query, season, silent=silent)
30 else:
31 results = burst_search_episode(
32 tmdb_id, query, season, episode, silent=silent
33 )
34 elif mode == "movies" or media_type == "movies":
35 results = burst_search_movie(tmdb_id, query, silent=silent)
36 else:
37 results = burst_search(query, silent=silent)
38 if results:
39 results = self.parse_response(results)
40 return results
41 except Exception as e:
42 self.handle_exception(f"Burst error: {str(e)}")
43
44 def parse_response(self, res: List[Dict[str, Any]]) -> List[TorrentStream]:
45 results = []
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="",
61 )
62 )
63 return results
plugin.video.jacktook/lib/jacktook/listener.py +33 -0
……@@ -0,0 +1,33 @@
1from .utils import NoProvidersError
2from .provider_base import ProviderListener
3from lib.utils.kodi.utils import ADDON_NAME, translation
4from xbmcgui import DialogProgressBG
5
6
7class ProviderListenerDialog(ProviderListener):
8 def __init__(self, providers, method, timeout=10, silent=False):
9 super(ProviderListenerDialog, self).__init__(providers, method, timeout=timeout)
10 self._total = len(providers)
11 self._count = 0
12 self._silent = silent
13 self._dialog = None if silent else DialogProgressBG()
14
15 def on_receive(self, sender):
16 self._count += 1
17 if self._dialog:
18 self._dialog.update(int(100 * self._count / self._total))
19
20 def __enter__(self):
21 ret = super(ProviderListenerDialog, self).__enter__()
22 if self._dialog:
23 self._dialog.create(ADDON_NAME, translation(90660))
24 return ret
25
26 def __exit__(self, exc_type, exc_val, exc_tb):
27 try:
28 return super(ProviderListenerDialog, self).__exit__(
29 exc_type, exc_val, exc_tb
30 )
31 finally:
32 if self._dialog:
33 self._dialog.close()
plugin.video.jacktook/lib/jacktook/provider_base.py +180 -0
……@@ -0,0 +1,180 @@
1import base64
2import json
3import sys
4import threading
5import time
6
7from .utils import (
8 ADDON_ID,
9 bytes_to_str,
10 get_installed_addons,
11 kodilog,
12 notify_all,
13 run_script,
14 str_to_bytes,
15)
16import xbmc
17
18
19__all__ = ["ProviderResult", "Provider"]
20
21
22def get_providers():
23 return [
24 p_id
25 for p_id, _ in get_installed_addons(addon_type="xbmc.python.script")
26 if p_id.startswith("script.jacktook.") and p_id != ADDON_ID
27 ]
28
29
30def send_to_providers(providers, method, *args, **kwargs):
31 data = {}
32 if args:
33 data["args"] = args
34 if kwargs:
35 data["kwargs"] = kwargs
36 data_b64 = bytes_to_str(base64.b64encode(str_to_bytes(json.dumps(data))))
37 for provider in providers:
38 run_script(provider, ADDON_ID, method, data_b64)
39
40
41def _setter_and_getter(attribute):
42 def setter(self, value):
43 self[attribute] = value
44
45 def getter(self):
46 return self.get(attribute)
47
48 setter.__doc__ = None
49 getter.__doc__ = None
50 return setter, property(getter)
51
52
53class ProviderResult(dict):
54 set_title, title = _setter_and_getter("title")
55 set_indexer, indexer = _setter_and_getter("indexer")
56 set_guid, guid = _setter_and_getter("guid")
57 set_quality, quality = _setter_and_getter("quality")
58 set_seeders, seeders = _setter_and_getter("seeders")
59 set_peers, peers = _setter_and_getter("peers")
60 set_size, size = _setter_and_getter("size")
61
62
63class Provider(object):
64 def __init__(self):
65 self._methods = {}
66 for name in dir(self):
67 if not name.startswith("_") and name != "register":
68 attr = getattr(self, name)
69 if callable(attr):
70 self._methods[name] = attr
71
72 def search(self, query):
73 raise NotImplementedError("'search' method must be implemented")
74
75 def search_movie(self, tmdb_id, title, titles, year=None):
76 raise NotImplementedError("'search_movie' method must be implemented")
77
78 def search_show(self, tmdb_id, show_title, titles, year=None):
79 raise NotImplementedError("'search_show' method must be implemented")
80
81 def search_season(self, tmdb_id, show_title, season_number, titles):
82 raise NotImplementedError("'search_season' method must be implemented")
83
84 def search_episode(
85 self, tmdb_id, show_title, season_number, episode_number, titles
86 ):
87 raise NotImplementedError("'search_episode' method must be implemented")
88
89 def resolve(self, provider_data):
90 raise NotImplementedError("'resolve' method must be implemented")
91
92 def ping(self):
93 kodilog("Ping method called")
94 return ADDON_ID
95
96 def register(self):
97 if len(sys.argv) != 4:
98 kodilog("Expecting 4 arguments")
99 kodilog("Providers can't be called")
100 return
101
102 _, sender, method, data_b64 = sys.argv
103 if method in self._methods:
104 try:
105 data = json.loads(base64.b64decode(data_b64))
106 value = self._methods[method](
107 *data.get("args", []), **data.get("kwargs", {})
108 )
109 except Exception as e:
110 kodilog(f"Failed running method '{method}': {e}")
111 value = None
112 if not notify_all(ADDON_ID, f"{sender}.{method}", value):
113 kodilog("Failed sending provider data")
114 else:
115 kodilog(
116 f"Unknown method provided '{method}'. Expecting one of {list(self._methods.keys())}"
117 )
118 raise ValueError("Unknown method provided")
119
120
121class ProviderListener(xbmc.Monitor):
122 def __init__(self, providers, method, timeout=10):
123 super(ProviderListener, self).__init__()
124 self._waiting = {i: True for i in providers}
125 self._method = "Other.{}.{}".format(ADDON_ID, method)
126 self._timeout = timeout
127 self._data = {}
128 self._lock = threading.Lock()
129 self._start_time = time.time()
130
131 def onNotification(self, sender, method, data):
132 kodilog(
133 f"Received notification with sender={sender}, method={method}, data={data}",
134 level=xbmc.LOGDEBUG,
135 )
136 with self._lock:
137 if method == self._method and self._waiting.get(sender, False):
138 try:
139 self._data[sender] = json.loads(data)
140 except Exception as e:
141 kodilog(f"Unable to get data from sender '{sender}': {e}")
142 else:
143 self._waiting[sender] = False
144 self.on_receive(sender)
145
146 def on_receive(self, sender):
147 pass
148
149 def is_complete(self):
150 with self._lock:
151 return not any(self._waiting.values())
152
153 def wait(self, **kwargs):
154 if kwargs.get("reset"):
155 self._start_time = time.time()
156 timeout = kwargs.get("timeout", self._timeout)
157 while not (
158 self.is_complete()
159 or 0 < timeout < time.time() - self._start_time
160 or self.waitForAbort(0.2)
161 ):
162 pass
163
164 def get_missing(self):
165 with self._lock:
166 return [k for k, v in self._waiting.items() if v]
167
168 @property
169 def data(self):
170 return self._data
171
172 def __enter__(self):
173 return self
174
175 def __exit__(self, exc_type, exc_val, exc_tb):
176 self.wait()
177 missing = self.get_missing()
178 if missing:
179 kodilog("Provider(s) timed out: %s", ", ".join(missing))
180 return False
plugin.video.jacktook/lib/jacktook/providers.py +63 -0
……@@ -0,0 +1,63 @@
1from lib.jacktook.provider_base import (
2 ProviderResult,
3 get_providers,
4 send_to_providers,
5)
6from lib.jacktook.listener import ProviderListenerDialog
7from lib.jacktook.utils import NoProvidersError
8from lib.utils.kodi.utils import kodilog
9
10
11def burst_search(query, silent=False):
12 return search("search", query, silent=silent)
13
14
15def burst_search_movie(movie_id, query, silent=False):
16 return search("search_movie", movie_id, query, silent=silent)
17
18
19def burst_search_show(show_id, query, silent=False):
20 return search("search_show", show_id, query, silent=silent)
21
22
23def 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 )
27
28
29def burst_search_episode(show_id, query, season_number, episode_number, silent=False):
30 return search(
31 "search_episode",
32 show_id,
33 query,
34 int(season_number),
35 int(episode_number),
36 silent=silent,
37 )
38
39
40def search(method, *args, **kwargs):
41 silent = kwargs.pop("silent", False)
42 providers = get_providers()
43 if not providers:
44 raise NoProvidersError("No providers available")
45
46 with ProviderListenerDialog(
47 providers, method, timeout=30, silent=silent
48 ) as listener:
49 send_to_providers(providers, method, *args, **kwargs)
50
51 results = []
52 for provider, provider_results in listener.data.items():
53 if not isinstance(provider_results, (tuple, list)):
54 kodilog(f"Expecting list/tuple from {provider}:{method}")
55 continue
56 for provider_result in provider_results:
57 try:
58 results.append(ProviderResult(provider_result))
59 except Exception as e:
60 kodilog(f"Invalid result from {provider}: {e}")
61 if not results:
62 raise Exception("No results found")
63 return results
plugin.video.jacktook/lib/jacktook/utils.py +62 -0
……@@ -0,0 +1,62 @@
1import json
2import xbmc
3import xbmcaddon
4
5from lib.utils.kodi.logging import kodilog
6
7
8ADDON = xbmcaddon.Addon()
9ADDON_PATH = ADDON.getAddonInfo("path")
10ADDON_ID = ADDON.getAddonInfo("id")
11ADDON_VERSION = ADDON.getAddonInfo("version")
12ADDON_NAME = ADDON.getAddonInfo("name")
13
14
15def 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 addons = data["result"].get("addons")
20 return [(a["addonid"], a["type"]) for a in addons] if addons else []
21
22
23def notify_all(sender, message, data=None):
24 params = {"sender": sender, "message": message}
25 if data is not None:
26 params["data"] = data
27 return execute_json_rpc("JSONRPC.NotifyAll", **params).get("result") == "OK"
28
29
30def run_script(script_id, *args):
31 xbmc.executebuiltin("RunScript({})".format(",".join((script_id,) + args)))
32
33
34def execute_json_rpc(method, rpc_version="2.0", rpc_id=1, **params):
35 return json.loads(
36 xbmc.executeJSONRPC(
37 json.dumps(
38 dict(jsonrpc=rpc_version, method=method, params=params, id=rpc_id)
39 )
40 )
41 )
42
43
44
45class ResolveTimeoutError(Exception):
46 pass
47
48
49class NoProvidersError(Exception):
50 pass
51
52
53def assure_str(s):
54 return s
55
56
57def str_to_bytes(s):
58 return s.encode()
59
60
61def bytes_to_str(b):
62 return b.decode()
plugin.video.jacktook/lib/nav/__init__.py +1 -0
……@@ -0,0 +1 @@
1"""Navigation feature modules."""
plugin.video.jacktook/lib/nav/debrid.py +348 -0
……@@ -0,0 +1,348 @@
1from threading import Thread
2from typing import Any, Dict, List
3from datetime import timedelta
4
5from xbmcplugin import addDirectoryItem
6
7from lib.api.debrid.alldebrid import AllDebrid
8from lib.api.debrid.debrider import Debrider
9from lib.api.debrid.premiumize import Premiumize
10from lib.api.debrid.realdebrid import RealDebrid
11from lib.api.debrid.torbox import Torbox
12from lib.clients.debrid.alldebrid import AllDebridHelper
13from lib.clients.debrid.debrider import DebriderHelper
14from lib.clients.debrid.realdebrid import RealDebridHelper
15from lib.clients.debrid.torbox import TorboxHelper
16from lib.services.debrid.auth import (
17 run_alldebrid_auth,
18 run_debrider_auth,
19 run_premiumize_auth,
20 run_realdebrid_auth,
21 run_torbox_auth,
22)
23from lib.services.debrid.download import run_realdebrid_download
24from lib.db.cached import cache
25from lib.utils.general.utils import (
26 DebridType,
27 IndexerType,
28 build_list_item,
29 check_debrid_enabled,
30 get_random_color,
31 set_pluging_category,
32)
33from lib.utils.kodi.utils import (
34 ADDON_HANDLE,
35 action_url_run,
36 apply_section_view,
37 build_url,
38 end_of_directory,
39 execute_builtin,
40 finish_action,
41 get_setting,
42 notification,
43 translation,
44)
45
46
47def _start_realdebrid_download(magnet):
48 rd_client = RealDebrid(token=str(get_setting("real_debrid_token", "")))
49 thread = Thread(target=run_realdebrid_download, args=(rd_client, magnet, False))
50 thread.start()
51
52
53def _start_torbox_download(magnet):
54 tb_client = Torbox(token=str(get_setting("torbox_token")))
55 thread = Thread(target=tb_client.download, args=(magnet,))
56 thread.start()
57
58
59def _start_premiumize_download(magnet):
60 pm_client = Premiumize(token=str(get_setting("premiumize_token")))
61 thread = Thread(target=pm_client.download, args=(magnet,), kwargs={"pack": False})
62 thread.start()
63
64
65DEBRID_CLOUD_ACTIONS = {
66 DebridType.RD: {"downloads": "get_rd_downloads", "info": "real_debrid_info"},
67 DebridType.DB: {"info": "debrider_info"},
68 DebridType.AD: {"info": "alldebrid_info"},
69 DebridType.TB: {"downloads": "get_tb_downloads", "info": "torbox_info"},
70}
71
72DEBRID_DOWNLOAD_HANDLERS = {
73 "RD": _start_realdebrid_download,
74 "TB": _start_torbox_download,
75 "PM": _start_premiumize_download,
76}
77
78DEBRID_INFO_HANDLERS = {
79 DebridType.RD: lambda: RealDebridHelper().get_info(),
80 DebridType.AD: lambda: AllDebridHelper().get_info(),
81 DebridType.DB: lambda: DebriderHelper().get_info(),
82 DebridType.TB: lambda: TorboxHelper().get_info(),
83}
84
85
86DEBRID_CLOUD_CACHE_EXPIRY = timedelta(hours=1)
87
88
89def _cloud_downloads_cache_key(provider: str, page: int = 1) -> str:
90 return f"cloud_downloads|{provider}|{page}"
91
92
93def cloud_details(params):
94 debrid_name = params.get("debrid_name")
95 if debrid_name == DebridType.PM:
96 notification(translation(90423))
97 return
98
99 actions = DEBRID_CLOUD_ACTIONS.get(debrid_name)
100 if not actions:
101 notification(translation(90424))
102 return
103
104 downloads_action = actions.get("downloads")
105 if not downloads_action:
106 end_of_directory(cache=False)
107 DEBRID_INFO_HANDLERS[debrid_name]()
108 return
109
110 addDirectoryItem(
111 ADDON_HANDLE,
112 build_url(downloads_action),
113 build_list_item("Downloads", "download.png"),
114 isFolder=True,
115 )
116 end_of_directory()
117 apply_section_view("view.downloads", content_type="files")
118
119
120def cloud(params):
121 set_pluging_category(translation(90014))
122 activated_debrids = [
123 debrid for debrid in DebridType.values() if check_debrid_enabled(debrid)
124 ]
125 for debrid in activated_debrids:
126 torrent_li = build_list_item(debrid, "download.png")
127 actions = DEBRID_CLOUD_ACTIONS.get(debrid, {})
128 info_action = actions.get("info")
129 if info_action:
130 torrent_li.addContextMenuItems(
131 [("Account Info", action_url_run(info_action))]
132 )
133 addDirectoryItem(
134 ADDON_HANDLE,
135 build_url("cloud_details", debrid_name=debrid),
136 torrent_li,
137 isFolder=True,
138 )
139
140 addDirectoryItem(
141 ADDON_HANDLE,
142 build_url("list_webdav"),
143 build_list_item("Webdav", "download.png"),
144 isFolder=True,
145 )
146
147 end_of_directory()
148 apply_section_view("view.downloads", content_type="files")
149
150
151def real_debrid_info(params):
152 finish_action()
153 DEBRID_INFO_HANDLERS[DebridType.RD]()
154
155
156def alldebrid_info(params):
157 finish_action()
158 DEBRID_INFO_HANDLERS[DebridType.AD]()
159
160
161def debrider_info(params):
162 finish_action()
163 DEBRID_INFO_HANDLERS[DebridType.DB]()
164
165
166def easynews_info(params):
167 from lib.clients.easynews import Easynews
168
169 finish_action()
170
171 user = str(get_setting("easynews_user") or "")
172 password = str(get_setting("easynews_password") or "")
173 timeout = int(get_setting("easynews_timeout", "25") or "25")
174
175 if not user or not password:
176 notification(translation(90425))
177 return
178
179 Easynews(user, password, timeout, notification).get_info()
180
181
182def get_rd_downloads(params):
183 page = int(params.get("page", 1))
184 debrid_type = DebridType.RD
185 debrid_color = get_random_color(debrid_type, formatted=False)
186 formatted_type = f"[B][COLOR {debrid_color}]{debrid_type}[/COLOR][/B]"
187
188 cache_key = _cloud_downloads_cache_key(DebridType.RD, page)
189 raw_downloads = cache.get(cache_key)
190 if raw_downloads is None:
191 rd_client = RealDebrid(token=str(get_setting("real_debrid_token", "")))
192 raw_downloads = rd_client.get_user_downloads_list(page=page)
193 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 ]
197
198 sorted_downloads = sorted(
199 downloads, key=lambda item: str(item.get("filename", "")), reverse=False
200 )
201 for download in sorted_downloads:
202 torrent_li = build_list_item(
203 f"{formatted_type} - {download.get('filename')}", "download.png"
204 )
205 torrent_li.setProperty("IsPlayable", "true")
206 addDirectoryItem(
207 ADDON_HANDLE,
208 build_url(
209 "play_info_hash",
210 url=download.get("download"),
211 title=download.get("filename"),
212 debrid_type=debrid_type,
213 ),
214 torrent_li,
215 isFolder=False,
216 )
217
218 if downloads:
219 addDirectoryItem(
220 ADDON_HANDLE,
221 build_url("get_rd_downloads", page=page + 1),
222 build_list_item("Next Page", "next.png"),
223 isFolder=True,
224 )
225
226 end_of_directory()
227 apply_section_view("view.downloads", content_type="files")
228
229
230def get_tb_downloads(params):
231 debrid_type = DebridType.TB
232 debrid_color = get_random_color(debrid_type, formatted=False)
233 formatted_type = f"[B][COLOR {debrid_color}]{debrid_type}[/COLOR][/B]"
234
235 cache_key = _cloud_downloads_cache_key(DebridType.TB)
236 raw_downloads = cache.get(cache_key)
237 if raw_downloads is None:
238 raw_downloads = TorboxHelper().get_cloud_downloads()
239 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 ]
243
244 def _torbox_sort_key(item: Dict[str, Any]):
245 return (
246 str(item.get("updated_at") or item.get("created_at") or ""),
247 str(item.get("name", "")),
248 )
249
250 sorted_downloads = sorted(
251 downloads,
252 key=_torbox_sort_key,
253 reverse=True,
254 )
255
256 notification(
257 f"Torbox Cloud: {len(sorted_downloads)} playable items found", time=2500, sound=False
258 ) if params.get("debug") else None
259
260 for download in sorted_downloads:
261 torrent_li = build_list_item(
262 f"{formatted_type} - {download.get('name')}", "download.png"
263 )
264 torrent_li.setProperty("IsPlayable", "true")
265 addDirectoryItem(
266 ADDON_HANDLE,
267 build_url(
268 "play_media",
269 data={
270 "title": download.get("name"),
271 "url": "",
272 "mode": "movie",
273 "type": IndexerType.DEBRID,
274 "debrid_type": debrid_type,
275 "torrent_id": download.get("torrent_id"),
276 "file_id": download.get("file_id"),
277 "info_hash": download.get("info_hash", ""),
278 },
279 ),
280 torrent_li,
281 isFolder=False,
282 )
283
284 end_of_directory()
285 apply_section_view("view.downloads", content_type="files")
286
287
288def download(magnet, debrid_type):
289 handler = DEBRID_DOWNLOAD_HANDLERS.get(debrid_type)
290 if not handler:
291 notification(translation(90424))
292 return
293 handler(magnet)
294
295
296def rd_auth(params):
297 rd_client = RealDebrid(token=str(get_setting("real_debrid_token", "")))
298 run_realdebrid_auth(rd_client)
299
300
301def ad_auth(params):
302 ad_client = AllDebrid(token=str(get_setting("alldebrid_token", "")))
303 run_alldebrid_auth(ad_client)
304
305
306def rd_remove_auth(params):
307 rd_client = RealDebrid(token=str(get_setting("real_debrid_token", "")))
308 rd_client.remove_auth()
309
310
311def ad_remove_auth(params):
312 ad_client = AllDebrid(token=str(get_setting("alldebrid_token", "")))
313 ad_client.remove_auth()
314
315
316def debrider_auth(params):
317 debrider_client = Debrider(token=str(get_setting("debrider_token")))
318 run_debrider_auth(debrider_client)
319
320
321def debrider_remove_auth(params):
322 debrider_client = Debrider(token=str(get_setting("debrider_token")))
323 debrider_client.remove_auth()
324
325
326def pm_auth(params):
327 pm_client = Premiumize(token=str(get_setting("premiumize_token")))
328 run_premiumize_auth(pm_client)
329
330
331def pm_remove_auth(params):
332 pm_client = Premiumize(token=str(get_setting("premiumize_token", "")))
333 pm_client.remove_auth()
334
335
336def tb_auth(params):
337 torbox_client = Torbox(token=str(get_setting("torbox_token")))
338 run_torbox_auth(torbox_client)
339
340
341def tb_remove_auth(params):
342 torbox_client = Torbox(token=str(get_setting("torbox_token")))
343 torbox_client.remove_auth()
344
345
346def torbox_info(params):
347 finish_action()
348 DEBRID_INFO_HANDLERS[DebridType.TB]()
plugin.video.jacktook/lib/nav/library_history.py +261 -0
……@@ -0,0 +1,261 @@
1from datetime import timedelta
2import os
3
4from xbmcgui import Dialog
5from xbmcplugin import addDirectoryItem, endOfDirectory
6
7from lib.db.cached import cache
8from lib.utils.general.items_menus import history_menu_items, library_menu_items
9from lib.utils.general.utils import build_list_item, clear_history_by_type, set_pluging_category
10from lib.utils.kodi.settings import get_cache_expiration
11from lib.utils.kodi.utils import (
12 ADDON_PATH,
13 ADDON_HANDLE,
14 add_directory_items_batch,
15 apply_section_view,
16 build_url,
17 container_update,
18 end_of_directory,
19 finish_action,
20 kodi_play_media,
21 make_list_item,
22 notification,
23 show_keyboard,
24 translation,
25)
26from lib.utils.views.last_files import show_last_files
27from lib.utils.views.last_titles import show_last_titles
28from lib.utils.views.weekly_calendar import show_weekly_calendar
29
30
31def _maybe_clear_library(mode, params):
32 from lib.utils.views.library import clear_library_items
33
34 should_clear = str(params.get("clear", "0")).lower() in ("1", "true", "yes")
35 if not should_clear:
36 return
37
38 confirmed = Dialog().yesno(translation(90201), translation(90691))
39 if not confirmed:
40 return
41
42 clear_library_items({"mode": mode})
43
44
45def _render_menu(items, cache_listing=True):
46 directory_items = []
47 for item in items:
48 if "condition" in item and not item["condition"]():
49 continue
50
51 name = item["name"]
52 if isinstance(name, int):
53 name = translation(name)
54
55 list_item = build_list_item(name, item["icon"])
56 params = item.get("params", {})
57 url = build_url(item["action"], **params)
58
59 context_menu = []
60 for context_item in item.get("context_menu", []):
61 label = context_item["label"]
62 if isinstance(label, int):
63 label = translation(label)
64 context_menu.append(
65 (
66 label,
67 container_update(
68 context_item["action"], **context_item.get("params", {})
69 ),
70 )
71 )
72 if context_menu:
73 list_item.addContextMenuItems(context_menu)
74
75 directory_items.append((url, list_item, True))
76
77 add_directory_items_batch(directory_items)
78 end_of_directory(cache=cache_listing)
79
80
81def history_menu(params):
82 set_pluging_category(translation(90017))
83 _render_menu(history_menu_items)
84 apply_section_view("view.history")
85
86
87def library_menu(params):
88 set_pluging_category(translation(90201))
89 _render_menu(library_menu_items, cache_listing=False)
90 apply_section_view("view.library")
91
92
93def continue_watching_menu(params):
94 from lib.utils.views.continue_watching import show_continue_watching
95
96 show_continue_watching()
97
98
99def remove_from_continue_watching(params):
100 from lib.utils.views.continue_watching import remove_continue_watching_item
101
102 remove_continue_watching_item(params)
103
104
105def library_shows(params):
106 from lib.utils.views.library import show_library_items
107
108 _maybe_clear_library("tv", params)
109 show_library_items(mode="tv")
110
111
112def library_movies(params):
113 from lib.utils.views.library import show_library_items
114
115 _maybe_clear_library("movies", params)
116 show_library_items(mode="movies")
117
118
119def library_calendar(params):
120 show_weekly_calendar(library=True)
121
122
123def remove_from_library(params):
124 from lib.utils.views.library import remove_library_item
125
126 remove_library_item(params)
127
128
129def clear_library(params):
130 from lib.utils.views.library import clear_library_items
131 from lib.utils.views.library import show_library_items
132
133 kodilog(f"clear_library action invoked with params={params}")
134
135 confirmed = Dialog().yesno(translation(90201), translation(90691))
136 kodilog(f"clear_library confirmation result={confirmed}")
137 if not confirmed:
138 return
139
140 clear_library_items(params)
141 show_library_items(mode=params.get("mode", "tv"))
142
143
144def add_to_library(params):
145 from lib.utils.general.utils import add_to_library as add_lib
146 import json
147
148 data = params.get("data")
149 if data:
150 add_lib(json.loads(data))
151
152 Dialog().notification(translation(90069), translation(90205))
153
154
155def search_direct(params):
156 set_pluging_category(translation(90011))
157 mode = params.get("mode")
158 query = params.get("query", "")
159 is_clear = params.get("is_clear", False)
160 is_keyboard = params.get("is_keyboard", True)
161 update_listing = params.get("update_listing", False)
162 rename = params.get("rename", False)
163
164 if is_clear:
165 cache.clear_list(key=mode)
166 is_keyboard = False
167
168 if rename or is_clear:
169 update_listing = True
170
171 if is_keyboard:
172 text = show_keyboard(id=30243, default=query)
173 if text:
174 cache.add_to_list(
175 key=mode,
176 item=(mode, text),
177 expires=timedelta(hours=get_cache_expiration()),
178 )
179
180 list_item = make_list_item(label=translation(90006))
181 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "search.png")})
182 addDirectoryItem(
183 ADDON_HANDLE,
184 build_url("search_direct", mode=mode),
185 list_item,
186 isFolder=True,
187 )
188
189 for item_mode, text in cache.get_list(key=mode):
190 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.setProperty("IsPlayable", "true")
195 list_item.addContextMenuItems(
196 [
197 (
198 translation(90049),
199 kodi_play_media(
200 name="search",
201 mode=item_mode,
202 query=text,
203 rescrape=True,
204 direct=True,
205 ),
206 ),
207 (
208 translation(90655),
209 container_update(
210 "search_direct",
211 mode=item_mode,
212 query=text,
213 rename=True,
214 ),
215 ),
216 ]
217 )
218 addDirectoryItem(
219 ADDON_HANDLE,
220 build_url("search", mode=item_mode, query=text, direct=True),
221 list_item,
222 isFolder=False,
223 )
224
225 list_item = make_list_item(label="Clear Searches")
226 list_item.setArt({"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")})
227 addDirectoryItem(
228 ADDON_HANDLE,
229 build_url("search_direct", mode=mode, is_clear=True),
230 list_item,
231 isFolder=True,
232 )
233 endOfDirectory(ADDON_HANDLE, updateListing=update_listing)
234 apply_section_view("view.main")
235
236
237def clear_history(params):
238 clear_history_by_type(type=params.get("type"))
239 notification(translation(90114))
240
241
242def clear_search_history(params):
243 import xbmc
244
245 finish_action()
246 cache.clear_list(key="multi")
247 cache.clear_list(key="direct")
248 notification(translation(90114))
249 xbmc.executebuiltin("Container.Refresh")
250
251
252def files_history(params):
253 show_last_files()
254
255
256def titles_history(params):
257 show_last_titles(params)
258
259
260def titles_calendar(params):
261 show_weekly_calendar()
plugin.video.jacktook/lib/navigation.py +1198 -0
……@@ -0,0 +1,1198 @@
1import json
2import os
3from lib.api.trakt.trakt import ProviderException, TraktAPI
4from lib.clients.trakt.trakt import TraktClient
5
6from lib.clients.stremio.catalog_menus import list_stremio_catalogs
7from lib.clients.tmdb.tmdb import (
8 TmdbClient,
9)
10
11from lib.db.cached import RuntimeCache, cache
12
13from lib.downloader import downloads_viewer
14from lib.gui.custom_dialogs import (
15 CustomDialog,
16 download_dialog_mock,
17 resume_dialog_mock,
18 run_next_mock,
19 source_select_mock,
20)
21
22from lib.player import JacktookPLayer
23from lib.utils.kodi.utils import (
24 ADDON_HANDLE,
25 ADDON_PATH,
26 CHANGELOG_PATH,
27 JACKTORR_ADDON,
28 add_directory_items_batch,
29 apply_section_view,
30 action_url_run,
31 build_url,
32 burst_addon_settings,
33 capture_current_view_id,
34 dialog_text,
35 end_of_directory,
36 execute_builtin,
37 finish_action,
38 clear_cached_settings,
39 get_setting,
40 is_youtube_addon_enabled,
41 kodilog,
42 notification,
43 play_info_hash,
44 reset_saved_views,
45 save_view_id,
46 translation,
47 make_list_item,
48)
49from lib.utils.player.utils import resolve_playback_url
50from lib.utils.torrent.torrserver_init import get_torrserver_api
51from lib.utils.torrentio.utils import open_providers_selection
52from lib.utils.kodi.settings import addon_settings
53from lib.utils.kodi.settings_backup import (
54 export_settings_backup as kodi_export_settings_backup,
55 factory_reset_action as kodi_factory_reset_action,
56 reset_all_settings_action as kodi_reset_all_settings_action,
57 restore_settings_backup as kodi_restore_settings_backup,
58)
59from lib.utils.general.utils import (
60 build_list_item,
61 clear_all_cache,
62 clear_trakt_db_cache,
63 clear_tmdb_cache as utils_clear_tmdb_cache,
64 clear_stremio_cache as utils_clear_stremio_cache,
65 clear_debrid_cache as utils_clear_debrid_cache,
66 clear_mdblist_cache as utils_clear_mdblist_cache,
67 clear_database_cache as utils_clear_database_cache,
68 make_listing,
69 set_content_type,
70 set_pluging_category,
71 show_log_export_dialog,
72)
73from lib.clients.youtube_resolver import (
74 extract_video_id,
75 resolve_item_trailer,
76)
77import lib.nav.debrid as debrid_navigation
78import lib.nav.library_history as library_history_navigation
79from lib.utils.general.items_menus import (
80 animation_items,
81 anime_items,
82 movie_items,
83 trakt_movie_discovery_items,
84 trakt_movie_library_items,
85 trakt_tv_discovery_items,
86 trakt_tv_library_items,
87 tv_items,
88)
89
90from lib.updater import updates_check_addon, downgrade_addon_menu
91
92from xbmcgui import ListItem
93from xbmcplugin import (
94 addDirectoryItem,
95 setResolvedUrl,
96)
97
98
99from lib.utils.general.items_menus import (
100 root_menu_items,
101)
102
103
104def _menu_condition_signature(items):
105 signature = []
106 for item in items:
107 condition = item.get("condition")
108 signature.append(True if condition is None else bool(condition()))
109 return tuple(signature)
110
111
112def _get_cached_menu_entries(cache_key, builder, *builder_args, **builder_kwargs):
113 cached_menu_entries = RuntimeCache.get(cache_key)
114 if cached_menu_entries is not None:
115 return cached_menu_entries
116
117 menu_entries = builder(*builder_args, **builder_kwargs)
118 RuntimeCache.set(cache_key, menu_entries)
119 return menu_entries
120
121
122def _build_search_item_menu_entries(items, mode=None, filter_api=None):
123 menu_entries = []
124 for item in items:
125 if filter_api and item.get("api") != filter_api:
126 continue
127 menu_entries.append(
128 {
129 "url": build_url(
130 "search_item",
131 category=item["category"],
132 mode=item["mode"],
133 submode=mode,
134 api=item["api"],
135 ),
136 "name": item["name"],
137 "icon": item["icon"],
138 "is_folder": True,
139 }
140 )
141 return menu_entries
142
143
144def _build_media_menu_entries(items):
145 menu_entries = []
146 for item in items:
147 if item.get("action"):
148 url = build_url(item["action"], **item.get("params", {}))
149 else:
150 url = build_url(
151 "search_item",
152 mode=item["mode"],
153 submode=item.get("submode", ""),
154 query=item["query"],
155 api=item["api"],
156 )
157 menu_entries.append(
158 {
159 "url": url,
160 "name": item["name"],
161 "icon": item["icon"],
162 "is_folder": True,
163 }
164 )
165 return menu_entries
166
167
168def _tv_menu_entries():
169 return _build_media_menu_entries(tv_items)
170
171
172def _movie_menu_entries():
173 return _build_media_menu_entries(movie_items)
174
175
176def _animation_item_menu_entries(mode):
177 return _build_search_item_menu_entries(
178 animation_items,
179 mode=mode,
180 filter_api=None if mode == "tv" else "tmdb",
181 )
182
183
184def _animation_menu_entries():
185 return [
186 {
187 "url": build_url("animation_item", mode="tv"),
188 "name": translation(90007),
189 "icon": "tv.png",
190 "is_folder": True,
191 },
192 {
193 "url": build_url("animation_item", mode="movies"),
194 "name": translation(90008),
195 "icon": "movies.png",
196 "is_folder": True,
197 },
198 ]
199
200
201def _anime_menu_entries():
202 return [
203 {
204 "url": build_url("anime_item", mode="tv"),
205 "name": translation(90007),
206 "icon": "tv.png",
207 "is_folder": True,
208 },
209 {
210 "url": build_url("anime_item", mode="movies"),
211 "name": translation(90008),
212 "icon": "movies.png",
213 "is_folder": True,
214 },
215 ]
216
217
218def _anime_item_menu_entries(mode):
219 return _build_search_item_menu_entries(anime_items, mode=mode)
220
221
222def _render_cached_menu_entries(menu_entries, cache=True):
223 directory_items = []
224 for menu_entry in menu_entries:
225 directory_items.append(
226 (
227 menu_entry["url"],
228 build_list_item(menu_entry["name"], menu_entry["icon"]),
229 menu_entry.get("is_folder", True),
230 )
231 )
232 add_directory_items_batch(directory_items)
233
234
235def render_menu(items, cache=True, cache_key=None):
236 def _build_menu_entries():
237 menu_entries = []
238 for item in items:
239 if "condition" in item and not item["condition"]():
240 continue
241 name = item["name"]
242 if isinstance(name, int):
243 name = translation(name)
244 menu_entries.append(
245 {
246 "name": name,
247 "icon": item["icon"],
248 "url": build_url(item["action"], **item.get("params", {})),
249 "is_folder": item.get("is_folder", True),
250 }
251 )
252 return menu_entries
253
254 menu_entries = (
255 _build_menu_entries()
256 if not cache_key
257 else _get_cached_menu_entries(cache_key, _build_menu_entries)
258 )
259 _render_cached_menu_entries(menu_entries)
260 end_of_directory(cache=cache)
261
262
263def _translate_view_label(label):
264 if isinstance(label, str) and label.isdigit():
265 return translation(int(label))
266 return label or translation(90712)
267
268
269def _set_chooser_content(content_type):
270 chooser_modes = {
271 "movies": "movies",
272 "tvshows": "tv",
273 "seasons": "season",
274 "episodes": "episode",
275 "files": "files",
276 }
277 chooser_mode = chooser_modes.get(content_type)
278 if chooser_mode:
279 set_content_type(chooser_mode)
280
281
282def choose_view(params):
283 view_key = params.get("view_key", "view.main")
284 content_type = params.get("content_type", "")
285 label = _translate_view_label(params.get("label", "90712"))
286 set_pluging_category(label)
287 addDirectoryItem(
288 ADDON_HANDLE,
289 build_url(
290 "save_view",
291 view_key=view_key,
292 content_type=content_type,
293 label=params.get("label", "90712"),
294 ),
295 build_list_item(translation(90721), "settings.png"),
296 isFolder=False,
297 )
298 _set_chooser_content(content_type)
299 end_of_directory(cache=False)
300 apply_section_view(view_key, content_type=content_type)
301
302
303def settings_menu(params):
304 set_pluging_category(translation(90016))
305 addDirectoryItem(
306 ADDON_HANDLE,
307 build_url("settings"),
308 build_list_item(translation(90742), "settings.png"),
309 isFolder=False,
310 )
311 addDirectoryItem(
312 ADDON_HANDLE,
313 build_url("views_menu"),
314 build_list_item(translation(90743), "settings.png"),
315 isFolder=True,
316 )
317 end_of_directory(cache=False)
318 apply_section_view("view.main")
319
320
321def views_menu(params):
322 set_pluging_category(translation(90712))
323 view_items = (
324 ("view.main", "", "90713"),
325 ("view.movies", "movies", "90714"),
326 ("view.tvshows", "tvshows", "90715"),
327 ("view.seasons", "seasons", "90716"),
328 ("view.episodes", "episodes", "90717"),
329 ("view.library", "tvshows", "90718"),
330 ("view.history", "", "90719"),
331 ("view.downloads", "files", "90720"),
332 )
333 for view_key, content_type, label in view_items:
334 addDirectoryItem(
335 ADDON_HANDLE,
336 build_url(
337 "choose_view",
338 view_key=view_key,
339 content_type=content_type,
340 label=label,
341 ),
342 build_list_item(_translate_view_label(label), "settings.png"),
343 isFolder=True,
344 )
345 addDirectoryItem(
346 ADDON_HANDLE,
347 build_url("reset_views"),
348 build_list_item(translation(90725), "settings.png"),
349 isFolder=False,
350 )
351 end_of_directory(cache=False)
352 apply_section_view("view.main")
353
354
355def save_view(params):
356 view_key = params.get("view_key", "view.main")
357 label = _translate_view_label(params.get("label", "90712"))
358 view_id = capture_current_view_id()
359 if not view_id or not save_view_id(view_key, view_id):
360 notification(label, translation(90723))
361 return
362 notification(label, translation(90722))
363
364
365def reset_views(params):
366 reset_saved_views()
367 notification(translation(90712), translation(90724))
368
369
370def root_menu():
371 set_pluging_category(translation(90069))
372 render_menu(
373 root_menu_items,
374 cache=False,
375 cache_key=f"nav.root:{_menu_condition_signature(root_menu_items)}",
376 )
377 apply_section_view("view.main")
378
379
380def animation_menu(params):
381 _render_cached_menu_entries(
382 _get_cached_menu_entries(
383 "nav.animation_menu",
384 _animation_menu_entries,
385 )
386 )
387 end_of_directory()
388 apply_section_view("view.main")
389
390
391def animation_item(params):
392 mode = params.get("mode")
393 _render_cached_menu_entries(
394 _get_cached_menu_entries(
395 f"nav.animation_item:{mode}",
396 _animation_item_menu_entries,
397 mode,
398 )
399 )
400 end_of_directory()
401 if mode == "tv":
402 apply_section_view("view.tvshows")
403 else:
404 apply_section_view("view.movies")
405
406
407def telegram_menu(params):
408 set_pluging_category(translation(90013))
409 add_directory_items_batch(
410 [
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),
415 ]
416 )
417 end_of_directory()
418 apply_section_view("view.main")
419
420
421def search_tmdb_year(params):
422 mode = params["mode"]
423 submode = params["submode"]
424 page = int(params["page"])
425 year = int(params["year"])
426
427 set_content_type(mode)
428
429 TmdbClient.tmdb_search_year(mode, submode, year, page)
430
431
432def search_tmdb_genres(params):
433 mode = params["mode"]
434 submode = params["submode"]
435 genre_id = params["genre_id"]
436 page = int(params["page"])
437
438 set_content_type(mode)
439
440 TmdbClient.tmdb_search_genres(mode, genre_id, page, submode=submode)
441
442
443def tv_shows_items(params):
444 set_pluging_category(translation(90007))
445 stremio_only = get_setting("stremio_only_catalogs", False)
446
447 if not stremio_only:
448 _render_cached_menu_entries(
449 _get_cached_menu_entries(
450 "nav.tv_items:default",
451 _tv_menu_entries,
452 )
453 )
454 list_stremio_catalogs(menu_type="series", sub_menu_type="series")
455 end_of_directory()
456 apply_section_view("view.main")
457
458
459def movies_items(params):
460 set_pluging_category(translation(90008))
461 stremio_only = get_setting("stremio_only_catalogs", False)
462
463 if not stremio_only:
464 _render_cached_menu_entries(
465 _get_cached_menu_entries(
466 "nav.movie_items:default",
467 _movie_menu_entries,
468 )
469 )
470 list_stremio_catalogs(menu_type="movie", sub_menu_type="movie")
471 end_of_directory()
472 apply_section_view("view.main")
473
474
475def direct_menu(params):
476 search_direct({"mode": "direct"})
477
478
479def trakt_group_menu(params):
480 mode = params["mode"]
481 group = params["group"]
482
483 def _group_items(items):
484 return [
485 {
486 "name": item["name"],
487 "icon": item["icon"],
488 "action": "search_item",
489 "params": {
490 "mode": item["mode"],
491 "submode": item.get("submode", ""),
492 "query": item["query"],
493 "api": item["api"],
494 },
495 }
496 for item in items
497 ]
498
499 if mode == "tv" and group == "library":
500 set_pluging_category(translation(90292))
501 render_menu(_group_items(trakt_tv_library_items), cache=False)
502 apply_section_view("view.tvshows")
503 return
504
505 if mode == "tv" and group == "discovery":
506 set_pluging_category(translation(90293))
507 render_menu(_group_items(trakt_tv_discovery_items), cache=False)
508 apply_section_view("view.tvshows")
509 return
510
511 if mode == "movies" and group == "library":
512 set_pluging_category(translation(90292))
513 render_menu(_group_items(trakt_movie_library_items), cache=False)
514 apply_section_view("view.movies")
515 return
516
517 if mode == "movies" and group == "discovery":
518 set_pluging_category(translation(90293))
519 render_menu(_group_items(trakt_movie_discovery_items), cache=False)
520 apply_section_view("view.movies")
521 return
522
523 end_of_directory(cache=False)
524
525
526def search_menu(params):
527 set_pluging_category(translation(90006))
528 directory_items = []
529
530 directory_items.append(
531 (build_url("handle_tmdb_search", mode="multi", page=1), build_list_item(translation(90207), "search.png"), True)
532 )
533
534 # -- Direct Search --
535 directory_items.append(
536 (build_url("search_direct", mode="direct"), build_list_item(translation(90011), "search.png"), True)
537 )
538
539 # -- Keyword Search --
540 directory_items.append(
541 (build_url("handle_keyword_search", mode="multi"), build_list_item(translation(90368), "tmdb.png"), True)
542 )
543
544 # -- Recent TMDb Searches --
545 tmdb_history = cache.get_list(key="multi")
546 if tmdb_history:
547 header = make_list_item(label=f"[B][COLOR gray]— {translation(90208)} —[/COLOR][/B]")
548 header.setProperty("IsPlayable", "false")
549 directory_items.append(("", header, False))
550
551 for _, text in tmdb_history[:5]:
552 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 )
556 list_item.setProperty("IsPlayable", "false")
557 directory_items.append((build_url("handle_tmdb_search", mode="multi", page=1, query=text), list_item, True))
558
559 # -- Recent Direct Searches --
560 direct_history = cache.get_list(key="direct")
561 if direct_history:
562 header = make_list_item(label=f"[B][COLOR gray]— {translation(90209)} —[/COLOR][/B]")
563 header.setProperty("IsPlayable", "false")
564 directory_items.append(("", header, False))
565
566 for mode, text in direct_history[:5]:
567 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 )
571 list_item.setProperty("IsPlayable", "true")
572 directory_items.append((build_url("search", mode=mode, query=text, direct=True), list_item, False))
573
574 # -- Clear All Search History --
575 if tmdb_history or direct_history:
576 clear_li = make_list_item(label=translation(90210))
577 clear_li.setArt(
578 {"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")}
579 )
580 directory_items.append((build_url("clear_search_history"), clear_li, False))
581
582 add_directory_items_batch(directory_items)
583 end_of_directory()
584 apply_section_view("view.main")
585
586
587def anime_menu(params):
588 set_pluging_category(translation(90009))
589 _render_cached_menu_entries(
590 _get_cached_menu_entries(
591 "nav.anime_menu",
592 _anime_menu_entries,
593 )
594 )
595 end_of_directory()
596 apply_section_view("view.main")
597
598
599def history_menu(params):
600 return library_history_navigation.history_menu(params)
601
602
603def library_menu(params):
604 return library_history_navigation.library_menu(params)
605
606
607def continue_watching_menu(params):
608 return library_history_navigation.continue_watching_menu(params)
609
610
611def remove_from_continue_watching(params):
612 return library_history_navigation.remove_from_continue_watching(params)
613
614
615def library_shows(params):
616 return library_history_navigation.library_shows(params)
617
618
619def library_movies(params):
620 return library_history_navigation.library_movies(params)
621
622
623def library_calendar(params):
624 return library_history_navigation.library_calendar(params)
625
626
627def remove_from_library(params):
628 return library_history_navigation.remove_from_library(params)
629
630
631def clear_library(params):
632 return library_history_navigation.clear_library(params)
633
634
635def add_to_library(params):
636 return library_history_navigation.add_to_library(params)
637
638
639def anime_item(params):
640 set_pluging_category(translation(90009))
641 mode = params.get("mode")
642 stremio_only = get_setting("stremio_only_catalogs", False)
643
644 if mode == "tv":
645 if not stremio_only:
646 _render_cached_menu_entries(
647 _get_cached_menu_entries(
648 "nav.anime_item:tv",
649 _anime_item_menu_entries,
650 mode,
651 )
652 )
653 list_stremio_catalogs(menu_type="anime", sub_menu_type="series")
654 if mode == "movies":
655 if not stremio_only:
656 _render_cached_menu_entries(
657 _get_cached_menu_entries(
658 "nav.anime_item:movies",
659 _anime_item_menu_entries,
660 mode,
661 )
662 )
663 list_stremio_catalogs(menu_type="anime", sub_menu_type="movie")
664 end_of_directory()
665 apply_section_view("view.main")
666
667
668def tv_menu(params):
669 set_pluging_category(translation(90010))
670 list_stremio_catalogs(menu_type="tv")
671 end_of_directory()
672 apply_section_view("view.main")
673
674
675def search_direct(params):
676 return library_history_navigation.search_direct(params)
677
678
679def search(params):
680 from lib.search import run_search_entry
681
682 run_search_entry(params)
683
684
685def cloud_details(params):
686 return debrid_navigation.cloud_details(params)
687
688
689def cloud(params):
690 return debrid_navigation.cloud(params)
691
692
693def real_debrid_info(params):
694 return debrid_navigation.real_debrid_info(params)
695
696
697def alldebrid_info(params):
698 return debrid_navigation.alldebrid_info(params)
699
700
701def debrider_info(params):
702 return debrid_navigation.debrider_info(params)
703
704
705def easynews_info(params):
706 return debrid_navigation.easynews_info(params)
707
708
709def get_rd_downloads(params):
710 return debrid_navigation.get_rd_downloads(params)
711
712
713def get_tb_downloads(params):
714 return debrid_navigation.get_tb_downloads(params)
715
716
717def torrents(params):
718 if not JACKTORR_ADDON:
719 notification(translation(30253))
720 end_of_directory(cache=False)
721 return
722
723 for torrent in get_torrserver_api().torrents():
724 info_hash = torrent.get("hash")
725
726 context_menu_items = [(translation(30700), play_info_hash(info_hash))]
727
728 if torrent.get("stat") in [2, 3]:
729 context_menu_items.append(
730 (
731 translation(30709),
732 action_url_run(
733 "torrent_action", info_hash=info_hash, action_str="drop"
734 ),
735 )
736 )
737
738 context_menu_items.extend(
739 [
740 (
741 translation(30705),
742 action_url_run(
743 "torrent_action",
744 info_hash=info_hash,
745 action_str="remove_torrent",
746 ),
747 ),
748 (
749 translation(30707),
750 action_url_run(
751 "torrent_action",
752 info_hash=info_hash,
753 action_str="torrent_status",
754 ),
755 ),
756 ]
757 )
758
759 torrent_li = build_list_item(torrent.get("title", ""), "download.png")
760 torrent_li.addContextMenuItems(context_menu_items)
761 addDirectoryItem(
762 ADDON_HANDLE,
763 build_url("torrent_files", info_hash=info_hash),
764 torrent_li,
765 isFolder=True,
766 )
767 end_of_directory()
768 apply_section_view("view.downloads", content_type="files")
769
770
771def play_media(params):
772 data = json.loads(params["data"])
773 data = resolve_playback_url(data)
774 if not data:
775 notification("Failed to resolve playback URL")
776 return
777 player = JacktookPLayer()
778 player.run(data=data)
779 del player
780
781
782def play_url(params):
783 url = params.get("url")
784 list_item = ListItem(label=params.get("name"), path=url)
785 list_item.setPath(url)
786 setResolvedUrl(ADDON_HANDLE, True, list_item)
787
788
789def play_trailer(params):
790 ids = json.loads(params.get("ids", "{}")) if params.get("ids") else {}
791 media_type = params.get("media_type") or params.get("mode")
792 tmdb_id = params.get("tmdb_id") or ids.get("tmdb_id")
793 yt_id = params.get("yt_id") or ids.get("trailer_yt_id") or ids.get("yt_id")
794 youtube_url = params.get("youtube_url") or ids.get("trailer_url") or ids.get("youtube_url")
795 title = params.get("title") or ids.get("title") or ids.get("name")
796
797 kodilog(
798 f"Trailer: resolving title={title!r} media_type={media_type!r} tmdb_id={tmdb_id!r} yt_id={yt_id!r} youtube_url={youtube_url!r}"
799 )
800 resolved = resolve_item_trailer(
801 yt_id=yt_id,
802 youtube_url=youtube_url,
803 tmdb_id=tmdb_id,
804 media_type=media_type,
805 )
806 playback = (resolved or {}).get("playback") or {}
807 trailer = (resolved or {}).get("trailer") or {}
808 video_url = playback.get("video_url") if playback else None
809 if not video_url:
810 fallback_yt_id = extract_video_id(
811 trailer.get("yt_id") or trailer.get("youtube_url") or yt_id or youtube_url
812 )
813 kodilog(
814 f"Trailer: fallback attempt title={title!r} media_type={media_type!r} tmdb_id={tmdb_id!r} fallback_yt_id={fallback_yt_id!r}"
815 )
816 addon_available = False
817 if fallback_yt_id:
818 addon_available = is_youtube_addon_enabled()
819 if fallback_yt_id and addon_available:
820 execute_builtin(
821 f"PlayMedia(plugin://plugin.video.youtube/play/?video_id={fallback_yt_id})"
822 )
823 return
824 if fallback_yt_id and not addon_available:
825 kodilog(
826 f"Trailer: youtube addon unavailable title={title!r} media_type={media_type!r} tmdb_id={tmdb_id!r} fallback_yt_id={fallback_yt_id!r}"
827 )
828 kodilog(
829 f"Trailer: unavailable title={title!r} media_type={media_type!r} tmdb_id={tmdb_id!r} yt_id={yt_id!r} youtube_url={youtube_url!r}"
830 )
831 notification(translation(90673))
832 return
833
834 kodilog(
835 f"Trailer: resolved title={title!r} source_type={playback.get('source_type')!r} video_url={video_url!r}"
836 )
837 list_item = ListItem(label=title, path=video_url)
838 list_item.setPath(video_url)
839 list_item.setProperty("IsPlayable", "true")
840 if playback.get("source_type") == "adaptive" and playback.get("audio_url"):
841 list_item.setProperty("inputstream.adaptive.manifest_type", "mpd")
842 setResolvedUrl(ADDON_HANDLE, True, list_item)
843
844
845def play_from_pack(params):
846 data = json.loads(params.get("data"))
847 data = resolve_playback_url(data)
848 if not data:
849 return
850 list_item = make_listing(data)
851 setResolvedUrl(ADDON_HANDLE, True, list_item)
852
853
854def people_menu(mode):
855 set_pluging_category(translation(90078))
856 addDirectoryItem(
857 ADDON_HANDLE,
858 build_url(
859 "search_item",
860 mode=mode,
861 api="tmdb",
862 query="tmdb_people",
863 subquery="search_people",
864 ),
865 build_list_item(translation(90081), "search.png"),
866 isFolder=True,
867 )
868 addDirectoryItem(
869 ADDON_HANDLE,
870 build_url(
871 "search_item",
872 mode=mode,
873 api="tmdb",
874 query="tmdb_people",
875 subquery="latest_people",
876 ),
877 build_list_item(translation(90080), "tmdb.png"),
878 isFolder=True,
879 )
880 addDirectoryItem(
881 ADDON_HANDLE,
882 build_url(
883 "search_item",
884 mode=mode,
885 api="tmdb",
886 query="tmdb_people",
887 subquery="popular_people",
888 ),
889 build_list_item(translation(90079), "tmdb.png"),
890 isFolder=True,
891 )
892 end_of_directory()
893 apply_section_view("view.main")
894
895
896def mdblist_menu(mode):
897 set_pluging_category(translation(90372))
898 addDirectoryItem(
899 ADDON_HANDLE,
900 build_url(
901 "search_mdbd_lists",
902 mode=mode,
903 page=1,
904 ),
905 build_list_item(translation(90076), "search.png"),
906 isFolder=True,
907 )
908 addDirectoryItem(
909 ADDON_HANDLE,
910 build_url(
911 "top_mdbd_lists",
912 mode=mode,
913 page=1,
914 ),
915 build_list_item(translation(90074), "mdblist.png"),
916 isFolder=True,
917 )
918 addDirectoryItem(
919 ADDON_HANDLE,
920 build_url(
921 "user_mdbd_lists",
922 mode=mode,
923 page=1,
924 ),
925 build_list_item(translation(90075), "mdblist.png"),
926 isFolder=True,
927 )
928 end_of_directory()
929 apply_section_view("view.main")
930
931
932def search_item(params):
933 query = params.get("query", "")
934 category = params.get("category", None)
935 api = params["api"]
936 mode = params["mode"]
937 submode = params.get("submode", "")
938 page = int(params.get("page", 1))
939
940 if api == "trakt":
941 try:
942 result = TraktClient.handle_trakt_query(
943 query, category, mode, page, submode, api, params=params
944 )
945 if result is not None:
946 TraktClient.process_trakt_result(
947 result,
948 query,
949 category,
950 mode,
951 submode,
952 api,
953 page,
954 search_term=params.get("search_term", ""),
955 )
956 except ProviderException as error:
957 message = str(error)
958 if "Internal Server Error" in message or "Service Unavailable" in message:
959 notification("Trakt is temporarily unavailable", time=3500)
960 else:
961 notification(message.replace("Trakt API error: ", ""), time=3500)
962 end_of_directory(cache=False)
963 elif api == "tmdb":
964 if submode == "people_menu":
965 people_menu(mode)
966 else:
967 TmdbClient.handle_tmdb_query(params)
968 elif api == "mdblist":
969 mdblist_menu(mode)
970 else:
971 notification("Unsupported API")
972
973
974def trakt_list_content(params):
975 mode = params.get("mode")
976 set_content_type(mode)
977 TraktClient.show_trakt_list_content(
978 params.get("list_type"),
979 mode,
980 params.get("user"),
981 params.get("slug"),
982 params.get("with_auth", ""),
983 params.get("page", 1),
984 params.get("trakt_id"),
985 )
986
987
988def list_trakt_page(params):
989 mode = params.get("mode")
990 set_content_type(mode)
991 TraktClient.show_list_trakt_page(int(params.get("page", "")), mode)
992
993
994def anime_search(params):
995 mode = params.get("mode")
996 page = params.get("page", 1)
997 category = params.get("category")
998 set_content_type(mode)
999 TmdbClient.handle_tmdb_anime_query(category, mode, submode=mode, page=page)
1000
1001
1002def next_page_anime(params):
1003 mode = params.get("mode")
1004 set_content_type(mode)
1005 TmdbClient.handle_tmdb_anime_query(
1006 params.get("category"),
1007 mode,
1008 params.get("submode"),
1009 page=int(params.get("page", 1)) + 1,
1010 )
1011
1012
1013def download(magnet, type):
1014 return debrid_navigation.download(magnet, type)
1015
1016
1017def downloads_menu(params):
1018 downloads_viewer(params)
1019
1020
1021def addon_update(params):
1022 updates_check_addon()
1023
1024
1025def downgrade_addon(params):
1026 downgrade_addon_menu()
1027
1028
1029def show_changelog(params):
1030 dialog_text(translation(90577), file=CHANGELOG_PATH)
1031
1032
1033def donate(params):
1034 from lib.utils.debrid.qrcode_utils import make_qrcode
1035
1036 donation_url = "https://ko-fi.com/sammax09"
1037 qr_code = make_qrcode(donation_url)
1038
1039 dialog = CustomDialog(
1040 "customdialog.xml",
1041 ADDON_PATH,
1042 heading=translation(90023),
1043 text=translation(90022),
1044 url=f"[COLOR snow]{donation_url}[/COLOR]",
1045 qrcode=qr_code,
1046 )
1047 dialog.doModal()
1048
1049
1050def settings(params):
1051 finish_action()
1052 addon_settings()
1053 clear_cached_settings()
1054
1055
1056def export_settings_backup(params):
1057 kodi_export_settings_backup(params)
1058
1059
1060def restore_settings_backup(params):
1061 kodi_restore_settings_backup(params)
1062
1063
1064def reset_all_settings(params):
1065 kodi_reset_all_settings_action(params)
1066
1067
1068def factory_reset(params):
1069 kodi_factory_reset_action(params)
1070
1071
1072def clear_all_cached(params):
1073 clear_all_cache()
1074 notification(translation(30244))
1075
1076
1077def clear_trakt_cache(params):
1078 clear_trakt_db_cache()
1079
1080
1081def clear_tmdb_cache(params):
1082 utils_clear_tmdb_cache()
1083
1084
1085def clear_stremio_cache(params):
1086 utils_clear_stremio_cache()
1087
1088
1089def clear_debrid_cache(params):
1090 utils_clear_debrid_cache()
1091
1092
1093def clear_mdblist_cache(params):
1094 utils_clear_mdblist_cache()
1095
1096
1097def clear_database_cache(params):
1098 utils_clear_database_cache()
1099
1100
1101def clear_history(params):
1102 return library_history_navigation.clear_history(params)
1103
1104
1105def clear_search_history(params):
1106 return library_history_navigation.clear_search_history(params)
1107
1108
1109def kodi_logs(params):
1110 show_log_export_dialog(params)
1111
1112
1113def files_history(params):
1114 return library_history_navigation.files_history(params)
1115
1116
1117def titles_history(params):
1118 return library_history_navigation.titles_history(params)
1119
1120
1121def titles_calendar(params):
1122 return library_history_navigation.titles_calendar(params)
1123
1124
1125def rd_auth(params):
1126 return debrid_navigation.rd_auth(params)
1127
1128
1129def ad_auth(params):
1130 return debrid_navigation.ad_auth(params)
1131
1132
1133def rd_remove_auth(params):
1134 return debrid_navigation.rd_remove_auth(params)
1135
1136
1137def ad_remove_auth(params):
1138 return debrid_navigation.ad_remove_auth(params)
1139
1140
1141def debrider_auth(params):
1142 return debrid_navigation.debrider_auth(params)
1143
1144
1145def debrider_remove_auth(params):
1146 return debrid_navigation.debrider_remove_auth(params)
1147
1148
1149def pm_auth(params):
1150 return debrid_navigation.pm_auth(params)
1151
1152
1153def pm_remove_auth(params):
1154 return debrid_navigation.pm_remove_auth(params)
1155
1156
1157def trakt_auth(params):
1158 TraktAPI().auth.trakt_authenticate()
1159
1160
1161def trakt_auth_revoke(params):
1162 TraktAPI().auth.trakt_revoke_authentication()
1163
1164
1165def tb_auth(params):
1166 return debrid_navigation.tb_auth(params)
1167
1168
1169def tb_remove_auth(params):
1170 return debrid_navigation.tb_remove_auth(params)
1171
1172
1173def torbox_info(params):
1174 return debrid_navigation.torbox_info(params)
1175
1176
1177def open_burst_config(params):
1178 burst_addon_settings()
1179
1180
1181def torrentio_selection(params):
1182 open_providers_selection()
1183
1184
1185def test_run_next(params):
1186 run_next_mock()
1187
1188
1189def test_source_select(params):
1190 source_select_mock()
1191
1192
1193def test_resume_dialog(params):
1194 resume_dialog_mock()
1195
1196
1197def test_download_dialog(params):
1198 download_dialog_mock()
plugin.video.jacktook/lib/player.py +594 -0
……@@ -0,0 +1,594 @@
1from threading import Thread
2from lib.api.trakt.trakt_utils import is_trakt_auth
3from lib.clients.subtitle.utils import get_language_code
4from lib.clients.tmdb.utils.utils import tmdb_get
5from lib.api.trakt.trakt import TraktAPI, TraktLists
6from lib.utils.kodi.utils import (
7 ADDON_HANDLE,
8 PLAYLIST,
9 action_url_run,
10 build_url,
11 clear_property,
12 close_all_dialog,
13 close_busy_dialog,
14 execute_builtin,
15 get_setting,
16 kodilog,
17 notification,
18 set_property,
19 sleep,
20 translation,
21)
22from lib.utils.general.utils import (
23 make_listing,
24 set_watched_file,
25)
26from lib.utils.player.utils import precache_next_episodes
27
28import xbmc
29from xbmc import getCondVisibility as get_visibility
30from xbmcgui import ListItem, Dialog
31from xbmcplugin import setResolvedUrl
32
33from json import dumps as json_dumps, loads
34
35total_time_errors = ("0.0", "", 0.0, None)
36video_fullscreen_check = "Window.IsActive(fullscreenvideo)"
37
38
39class JacktookPLayer(xbmc.Player):
40 def __init__(self, on_started=None, on_error=None):
41 xbmc.Player.__init__(self)
42 self.url = None
43 self.playback_percent = 0.0
44 self.playing_filename = ""
45 self.media_marked = False
46 self.cancel_all_playback = False
47 self.kodi_monitor = xbmc.Monitor()
48 self.next_dialog = get_setting("playnext_dialog_enabled")
49 self.playing_next_time = int(get_setting("playnext_time", 50))
50 self.PLAYLIST = PLAYLIST
51 self.data = {}
52 self.notification = notification
53 self.lang_code = "en"
54 self.subtitles_found = False
55 self.on_started = on_started
56 self.on_error = on_error
57 self._is_trakt_scrobble_active = False
58 self._playback_was_paused = False
59
60 def run(self, data={}):
61 self.set_constants(data)
62 self.clear_playback_properties()
63 self.add_external_trakt_scrolling()
64 self.mark_watched(data)
65
66 precaching_thread = Thread(target=precache_next_episodes, args=(self.data,))
67 precaching_thread.start()
68
69 close_busy_dialog()
70
71 try:
72 self.list_item = make_listing(data)
73 self.PLAYLIST.add(self.url, self.list_item)
74 if self.data["mode"] == "tv":
75 self.build_playlist()
76 self.play_video(self.list_item)
77 except Exception as e:
78 self.run_error(e)
79
80 def play_video(self, list_item):
81 close_busy_dialog()
82
83 if not self._check_volume():
84 self.cancel_playback()
85 return
86
87 try:
88 self._handle_trakt_scrobble(list_item)
89 self.handle_subtitles(list_item)
90 setResolvedUrl(ADDON_HANDLE, True, list_item)
91 self.monitor()
92 except Exception as e:
93 kodilog(f"Error during play_video: {e}")
94 self.run_error(e)
95
96 def _check_volume(self):
97 try:
98 if not get_setting("volume_check_enabled") or get_visibility(
99 "Player.Muted"
100 ):
101 return True
102
103 threshold = int(get_setting("volume_check_threshold", 50))
104
105 request = {
106 "jsonrpc": "2.0",
107 "method": "Application.GetProperties",
108 "params": {"properties": ["volume"]},
109 "id": 1,
110 }
111 json_query = xbmc.executeJSONRPC(json_dumps(request))
112 response = loads(json_query)
113
114 volume = response.get("result", {}).get("volume", 0)
115 if volume > threshold:
116 execute_builtin(f"SetVolume({threshold})")
117 notification(
118 translation(90221) % threshold,
119 heading=translation(90220),
120 time=3000,
121 )
122
123 return True
124 except Exception as e:
125 kodilog(f"Error checking volume: {e}")
126 return True
127
128 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 )
137 if last_position > 0:
138 list_item.setProperty("StartPercent", str(last_position))
139 TraktAPI().scrobble.trakt_start_scrobble(self.data)
140 self._is_trakt_scrobble_active = True
141
142 def _is_trakt_scrobble_enabled(self):
143 return (
144 self._is_trakt_scrobble_active
145 and is_trakt_auth()
146 and get_setting("trakt_scrobbling_enabled")
147 and self.data.get("ids")
148 )
149
150 def handle_trakt_pause_resume(self):
151 is_paused = bool(get_visibility("Player.Paused"))
152
153 if not self._is_trakt_scrobble_enabled():
154 self._playback_was_paused = is_paused
155 return
156
157 if is_paused and not self._playback_was_paused:
158 TraktAPI().scrobble.trakt_pause_scrobble(self.data)
159 elif self._playback_was_paused and not is_paused:
160 TraktAPI().scrobble.trakt_start_scrobble(self.data)
161
162 self._playback_was_paused = is_paused
163
164 def monitor(self):
165 ensure_dialog_closed = False
166
167 try:
168 while not self.isPlayingVideo():
169 if self.kodi_monitor.abortRequested():
170 return
171 if get_visibility("Window.IsTopMost(okdialog)"):
172 execute_builtin("SendClick(okdialog, 11)")
173 return
174 sleep(100)
175
176 # Once playing, initialize streams and subtitles
177 self.select_audio_stream()
178 self.handle_subtitle_selection()
179 self.handle_playback_start()
180
181 # Fetch IntroDB segments in background if enabled
182 if self.skip_intro_enabled and self.data.get("mode") == "tv":
183 introdb_thread = Thread(target=self.fetch_introdb_segments)
184 introdb_thread.daemon = True
185 introdb_thread.start()
186
187 # Monitor loop
188 while self.isPlayingVideo():
189 self.update_playback_progress()
190 self.handle_trakt_pause_resume()
191
192 if not ensure_dialog_closed:
193 ensure_dialog_closed = True
194 self.playback_close_dialogs()
195
196 self.check_skip_intro()
197 self.check_next_dialog()
198 sleep(1000)
199
200 self.handle_playback_stop()
201
202 except Exception as e:
203 self.kill_dialog()
204 finally:
205 self._cleanup_playback_session()
206 self.clear_playback_properties()
207
208 def handle_subtitles(self, list_item):
209 self.subtitles_found = False
210 subtitles_path = self.data.get("subtitles_path", [])
211 if subtitles_path or get_setting("search_subtitles"):
212 list_item.setSubtitles(subtitles_path)
213 self.setSubtitleStream(0)
214 self.subtitles_found = True
215 elif get_setting("stremio_subtitle_enabled"):
216 try:
217 from lib.clients.subtitle.submanager import SubtitleManager
218
219 subtitle_manager = SubtitleManager(self.data, self.notification)
220 subs_paths = subtitle_manager.fetch_subtitles(auto_select=True)
221 if not subs_paths:
222 kodilog("No subtitles found, skipping subtitle loading")
223 self.subtitles_found = False
224 return
225 list_item.setSubtitles(subs_paths)
226 self.setSubtitleStream(0)
227 self.subtitles_found = True
228 except Exception as e:
229 kodilog(f"Error loading subtitles: {e}")
230 self.subtitles_found = False
231 elif get_setting("auto_subtitle_selection"):
232 sub_language = str(get_setting("subtitle_language"))
233 if sub_language and sub_language.lower() != "None":
234 self.lang_code = get_language_code(sub_language)
235 else:
236 kodilog("No subtitle handling method selected, skipping subtitle loading")
237
238 def handle_subtitle_selection(self):
239 """
240 Handles subtitle activation and selection logic after playback starts.
241 """
242 auto_select_enabled = get_setting("auto_subtitle_selection")
243 stremio_subtitle_enabled = get_setting("stremio_subtitle_enabled")
244 search_subtitles = get_setting("search_subtitles")
245
246 if self.subtitles_found or stremio_subtitle_enabled or search_subtitles:
247 self.showSubtitles(True)
248 if self.subtitles_found:
249 self.notification(translation(90257), time=2000)
250 set_property("search_subtitles", "false")
251 return
252
253 if auto_select_enabled:
254 _, _, subtitles = self.get_player_streams()
255 kodilog(f"Available subtitles: {subtitles}", level=xbmc.LOGDEBUG)
256 for sub in subtitles:
257 if (
258 self.lang_code == sub.get("language")
259 and sub.get("isforced") is False
260 ):
261 self.setSubtitleStream(sub["index"])
262 self.showSubtitles(True)
263 break
264
265 elif not (stremio_subtitle_enabled or auto_select_enabled):
266 kodilog("Auto subtitle selection disabled")
267 self.showSubtitles(False)
268
269 def select_audio_stream(self):
270 """
271 Handles automatic audio stream selection based on user settings.
272 """
273 auto_audio_enabled = get_setting("auto_audio")
274 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 ):
280 sleep(500)
281 audio_streams = self.getAvailableAudioStreams()
282 if audio_streams or len(audio_streams) > 0:
283 lang_code = get_language_code(auto_audio_language)
284 for stream_lang in audio_streams:
285 if stream_lang == lang_code:
286 self.setAudioStream(audio_streams.index(stream_lang))
287 break
288
289 def handle_playback_failure(self):
290 self.kill_dialog()
291 if self.on_error:
292 self.on_error()
293 self.stop()
294
295 def handle_playback_start(self):
296 try:
297 if self.getTotalTime() not in total_time_errors and get_visibility(
298 video_fullscreen_check
299 ):
300 if self.on_started:
301 self.on_started()
302 except Exception as e:
303 kodilog(f"Error in handle_playback_start: {e}")
304
305 def update_playback_progress(self):
306 try:
307 self.total_time = self.getTotalTime()
308 self.current_time = self.getTime()
309 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 )
313 self.data["progress"] = self.watched_percentage
314 self.data["current_time"] = self.current_time
315 self.data["total_time"] = self.total_time
316 except Exception as e:
317 kodilog(f"Error updating playback progress: {e}")
318
319 def check_next_dialog(self):
320 try:
321 if not getattr(self, "total_time", None) or self.total_time < 60:
322 return
323 if not getattr(self, "current_time", None):
324 return
325
326 # Stale data check: if getTime() is very high but we just started, skip
327 if not getattr(self, "playback_started_properly", False):
328 if self.current_time < 10:
329 self.playback_started_properly = True
330 else:
331 return
332
333 if self.current_time < (self.total_time * 0.5):
334 # Don't trigger in the first 50% of playback as a safety measure
335 return
336
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
343 except Exception as e:
344 kodilog(f"Error in check_next_dialog: {e}")
345
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
350
351 # Persist playback progress
352 set_watched_file(self.data)
353
354 close_busy_dialog()
355
356 def build_playlist(self):
357 if self.data.get("mode") != "tv":
358 return
359
360 ids = self.data.get("ids")
361 if not ids:
362 return
363
364 tmdb_id = ids.get("tmdb_id")
365 if not tmdb_id:
366 return
367
368 details = tmdb_get("tv_details", tmdb_id)
369 tv_data = self.data.get("tv_data", {})
370 season = tv_data.get("season")
371 episode = tv_data.get("episode")
372
373 if season is None or episode is None:
374 return
375
376 season_details = tmdb_get("season_details", {"id": tmdb_id, "season": season})
377
378 if not season_details or not hasattr(season_details, "episodes"):
379 return
380
381 for e in getattr(season_details, "episodes"):
382 episode_name = getattr(e, "name", "")
383 episode_number = getattr(e, "episode_number", 0)
384
385 if episode_number <= int(episode):
386 continue
387
388 label = f"{season}x{episode_number}. {episode_name}"
389 next_tv_data = {
390 "name": episode_name,
391 "episode": episode_number,
392 "season": season,
393 }
394
395 url = build_url(
396 "search",
397 mode=self.data["mode"],
398 query=getattr(details, "name", ""),
399 ids=ids,
400 tv_data=next_tv_data,
401 rescrape=True,
402 preferred_group=self.preferred_group,
403 )
404
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
411
412 if is_in_playlist:
413 continue
414
415 list_item = ListItem(label=label)
416 list_item.setPath(url)
417 list_item.setProperty("IsPlayable", "true")
418
419 self.PLAYLIST.add(url=url, listitem=list_item)
420
421 def kill_dialog(self):
422 close_all_dialog()
423
424 def playback_close_dialogs(self):
425 close_all_dialog()
426
427 def set_constants(self, data):
428 self.PLAYLIST.clear()
429 self.data = data
430 self.url = self.data["url"]
431 self.watched_percentage = self.data.get("progress", 0.0)
432 self.total_time = 0
433 self.current_time = 0
434 self.playback_started_properly = False
435 self.next_dialog = get_setting("playnext_dialog_enabled")
436 self._is_trakt_scrobble_active = False
437 self._playback_was_paused = False
438 from lib.utils.general.utils import extract_release_group
439
440 self.preferred_group = extract_release_group(self.data.get("title", ""))
441
442 # Skip intro state
443 self.skip_intro_enabled = get_setting("skip_intro_enabled")
444 self.skip_intro_auto = get_setting("skip_intro_auto")
445 self.skip_intro_segments = None
446 self.skip_intro_handled = {"intro": False, "recap": False}
447
448 def fetch_introdb_segments(self):
449 """Fetch segment data from IntroDB in a background thread."""
450 try:
451 ids = self.data.get("ids", {})
452 tv_data = self.data.get("tv_data", {})
453 imdb_id = ids.get("imdb_id")
454 season = tv_data.get("season")
455 episode = tv_data.get("episode")
456
457 if not imdb_id or not season or not episode:
458 kodilog("Skip intro: Missing IMDb ID, season, or episode")
459 return
460
461 from lib.clients.introdb import get_segments
462
463 self.skip_intro_segments = get_segments(imdb_id, season, episode)
464 kodilog(f"IntroDB segments: {self.skip_intro_segments}")
465 except Exception as e:
466 kodilog(f"Error fetching IntroDB segments: {e}")
467
468 def check_skip_intro(self):
469 try:
470 if not self.skip_intro_enabled or not self.skip_intro_segments:
471 return
472 if not getattr(self, "current_time", None):
473 return
474
475 current_ms = int(self.current_time * 1000)
476
477 for segment_type in ("recap", "intro"):
478 if self.skip_intro_handled.get(segment_type):
479 continue
480
481 segment = self.skip_intro_segments.get(segment_type)
482 if not segment:
483 continue
484
485 start_ms = segment.get("start_ms", 0)
486 end_ms = segment.get("end_ms", 0)
487 end_sec = segment.get("end_sec", end_ms / 1000)
488
489 if start_ms <= current_ms <= end_ms:
490 self.skip_intro_handled[segment_type] = True
491
492 if self.skip_intro_auto:
493 self.seekTime(end_sec)
494 else:
495 label = (
496 "Skip Intro" if segment_type == "intro" else "Skip Recap"
497 )
498 xbmc.executebuiltin(
499 action_url_run(
500 name="run_skip_intro_dialog",
501 segment_data=json_dumps(segment),
502 skip_label=label,
503 )
504 )
505 elif current_ms > end_ms:
506 self.skip_intro_handled[segment_type] = True
507 except Exception as e:
508 kodilog(f"Error in check_skip_intro: {e}")
509
510 def clear_playback_properties(self):
511 clear_property("script.trakt.ids")
512
513 def add_external_trakt_scrolling(self):
514 ids = self.data.get("ids", {})
515 mode = self.data.get("mode")
516 title = self.data.get("title", "")
517
518 if ids:
519 trakt_ids = {
520 "tmdb": ids.get("tmdb_id"),
521 "imdb": ids.get("imdb_id"),
522 "slug": TraktLists().make_trakt_slug(title),
523 }
524 if mode == "tv":
525 trakt_ids["tvdb"] = ids.get("tvdb_id")
526 set_property("script.trakt.ids", json_dumps(trakt_ids))
527
528 def get_player_streams(self):
529 activePlayers = (
530 '{"jsonrpc": "2.0", "method": "Player.GetActivePlayers", "id": 1}'
531 )
532 json_query = xbmc.executeJSONRPC(activePlayers)
533 json_response = loads(json_query)
534 if not json_response.get("result"):
535 return None, {}, []
536 playerid = json_response["result"][0]["playerid"]
537 details_query = {
538 "jsonrpc": "2.0",
539 "method": "Player.GetProperties",
540 "params": {
541 "properties": ["currentsubtitle", "subtitles"],
542 "playerid": playerid,
543 },
544 "id": 1,
545 }
546 json_query = xbmc.executeJSONRPC(json_dumps(details_query))
547 details = loads(json_query).get("result", {})
548 return (
549 playerid,
550 details.get("currentsubtitle", {}),
551 details.get("subtitles", []),
552 )
553
554 def ask_user_retry(self):
555 dialog = Dialog()
556 choice = dialog.yesno(
557 translation(90629),
558 translation(90630),
559 yeslabel=translation(90631),
560 nolabel=translation(90242),
561 )
562 if choice:
563 try:
564 self.play_video(make_listing(self.data))
565 except Exception as e:
566 kodilog(f"Retry failed: {e}")
567 self.run_error(e)
568 else:
569 self.cancel_playback()
570 notification("Playback Cancelled", time=2000)
571
572 def mark_watched(self, data):
573 set_watched_file(data)
574
575 def cancel_playback(self):
576 self._cleanup_playback_session()
577 try:
578 setResolvedUrl(ADDON_HANDLE, False, ListItem(offscreen=True))
579 except Exception as e:
580 kodilog(
581 f"setResolvedUrl failed in cancel_playback (expected when using direct play): {e}"
582 )
583
584 def _cleanup_playback_session(self):
585 self.PLAYLIST.clear()
586 close_busy_dialog()
587 close_all_dialog()
588
589 def run_error(self, e: Exception):
590 notification("Playback Failed", time=3500)
591 if self.on_error:
592 self.on_error()
593 self.cancel_playback()
594 self.clear_playback_properties()
plugin.video.jacktook/lib/router.py +783 -0
……@@ -0,0 +1,783 @@
1# -*- coding: utf-8 -*-
2"""
3Jacktook addon router — lazy-loading dispatcher.
4
5Uses prefix-based routing with lazy imports to avoid loading all addon
6modules on every invocation. Only the modules needed for the current
7action are imported.
8"""
9import sys
10from urllib import parse
11
12
13# ---------------------------------------------------------------------------
14# Action sets for set-based lookups
15# ---------------------------------------------------------------------------
16
17_TRAKT_EXTRAS = frozenset(
18 {
19 "add_to_collection",
20 "remove_from_collection",
21 "list_trakt_page",
22 }
23)
24
25_DEBRID_ACTIONS = frozenset(
26 {
27 "rd_auth",
28 "rd_remove_auth",
29 "ad_auth",
30 "ad_remove_auth",
31 "real_debrid_info",
32 "alldebrid_info",
33 "debrider_auth",
34 "debrider_remove_auth",
35 "debrider_info",
36 "get_rd_downloads",
37 "get_tb_downloads",
38 "cloud",
39 "cloud_details",
40 "pm_auth",
41 "pm_remove_auth",
42 "tb_auth",
43 "tb_remove_auth",
44 "torbox_info",
45 "easynews_info",
46 }
47)
48
49_TORRSERVER_ACTIONS = frozenset(
50 {
51 "torrents",
52 "torrent_action",
53 "torrent_files",
54 "torrentio_selection",
55 "display_picture",
56 "display_text",
57 }
58)
59
60_WEBDAV_ACTIONS = frozenset(
61 {
62 "list_webdav",
63 "webdav_provider_test",
64 "display_text_webdav",
65 "show_picture_webdav",
66 }
67)
68
69_DOWNLOAD_ACTIONS = frozenset(
70 {
71 "download_video",
72 "handle_download_file",
73 "handle_cancel_download",
74 "handle_delete_file",
75 "downloads_menu",
76 }
77)
78
79_GUI_ACTIONS = frozenset(
80 {
81 "run_next_dialog",
82 "run_resume_dialog",
83 "run_skip_intro_dialog",
84 "test_source_select",
85 "test_run_next",
86 "test_resume_dialog",
87 "test_download_dialog",
88 "extras",
89 }
90)
91
92_MDBLIST_ACTIONS = frozenset(
93 {
94 "search_mdbd_lists",
95 "user_mdbd_lists",
96 "top_mdbd_lists",
97 "show_mdblist_list",
98 }
99)
100
101
102def _is_stremio_action(action):
103 return action.startswith(("stremio_", "list_stremio")) or action in {
104 "clear_stremio_search_history",
105 "list_catalog",
106 "search_catalog",
107 "add_custom_stremio_addon",
108 "remove_custom_stremio_addon",
109 "torrentio_toggle_providers",
110 "stremio_bypass_addons_select",
111 }
112
113
114def _is_tmdb_action(action):
115 return action in ("tmdb_search_modes", "tmdb_episode_search_modes") or action.startswith(
116 (
117 "handle_tmdb",
118 "search_tmdb",
119 "search_tmbd",
120 "rescrape_tmdb",
121 "show_tmdb",
122 "handle_collection",
123 "search_people",
124 "handle_keyword",
125 "show_keyword",
126 )
127 )
128
129
130def _is_trakt_action(action):
131 return action.startswith("trakt_") or action in _TRAKT_EXTRAS
132
133
134def _is_debrid_action(action):
135 return action in _DEBRID_ACTIONS
136
137
138def _is_telegram_action(action):
139 return action.startswith(("telegram_", "list_telegram", "list_jackgram"))
140
141
142def _is_torrserver_action(action):
143 return action in _TORRSERVER_ACTIONS
144
145
146def _is_webdav_action(action):
147 return action in _WEBDAV_ACTIONS
148
149
150def _is_download_action(action):
151 return action in _DOWNLOAD_ACTIONS
152
153
154def _is_mdblist_action(action):
155 return action in _MDBLIST_ACTIONS
156
157
158def _is_cache_action(action):
159 return action.startswith("clear_")
160
161
162def _is_gui_action(action):
163 return action in _GUI_ACTIONS
164
165
166def _get_route_handler(action):
167 for matcher, handler in ROUTE_GROUPS:
168 if matcher(action):
169 return handler
170 return _route_core
171
172
173# ---------------------------------------------------------------------------
174# Main router
175# ---------------------------------------------------------------------------
176
177
178def addon_router():
179 param_string = sys.argv[2][1:]
180
181 if param_string:
182 params = dict(parse.parse_qsl(param_string))
183 action = params.get("action", "")
184
185 from lib.utils.tmdb_init import ensure_tmdb_init
186
187 ensure_tmdb_init()
188
189 route_handler = _get_route_handler(action)
190 route_handler(action, params)
191 return
192
193 from lib.navigation import root_menu
194
195 root_menu()
196
197
198# ---------------------------------------------------------------------------
199# Route handlers — each lazily imports only the modules it needs
200# ---------------------------------------------------------------------------
201
202
203def _route_stremio(action, params):
204 if action in (
205 "stremio_toggle_addons",
206 "stremio_toggle_catalogs",
207 "stremio_toggle_tv_addons",
208 "stremio_filtered_selection",
209 "add_custom_stremio_addon",
210 "remove_custom_stremio_addon",
211 "stremio_bypass_addons_select",
212 ):
213 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 add_custom_stremio_addon,
219 remove_custom_stremio_addon,
220 stremio_bypass_addons_select,
221 )
222
223 actions = {
224 "stremio_toggle_addons": stremio_toggle_addons,
225 "stremio_toggle_catalogs": stremio_toggle_catalogs,
226 "stremio_toggle_tv_addons": stremio_toggle_tv_addons,
227 "stremio_filtered_selection": stremio_filtered_selection,
228 "add_custom_stremio_addon": add_custom_stremio_addon,
229 "remove_custom_stremio_addon": remove_custom_stremio_addon,
230 "stremio_bypass_addons_select": stremio_bypass_addons_select,
231 }
232 actions[action](params)
233 elif action == "stremio_manage_phone":
234 from lib.clients.stremio.manage_phone import stremio_manage_phone
235
236 stremio_manage_phone(params)
237 elif action in ("stremio_login", "stremio_logout", "stremio_update"):
238 from lib.clients.stremio.authentication import (
239 stremio_login,
240 stremio_logout,
241 stremio_update,
242 )
243
244 actions = {
245 "stremio_login": stremio_login,
246 "stremio_logout": stremio_logout,
247 "stremio_update": stremio_update,
248 }
249 actions[action](params)
250 elif action == "torrentio_toggle_providers":
251 from lib.clients.stremio.torrentio import torrentio_toggle_providers
252
253 torrentio_toggle_providers(params)
254 else:
255 from lib.clients.stremio.catalog_menus import (
256 clear_stremio_search_history,
257 list_catalog,
258 list_stremio_episodes,
259 list_stremio_movie,
260 list_stremio_seasons,
261 list_stremio_tv,
262 list_stremio_tv_streams,
263 search_catalog,
264 )
265
266 actions = {
267 "clear_stremio_search_history": clear_stremio_search_history,
268 "list_catalog": list_catalog,
269 "list_stremio_seasons": list_stremio_seasons,
270 "list_stremio_episodes": list_stremio_episodes,
271 "list_stremio_movie": list_stremio_movie,
272 "list_stremio_tv_streams": list_stremio_tv_streams,
273 "list_stremio_tv": list_stremio_tv,
274 "search_catalog": search_catalog,
275 }
276 action_func = actions.get(action)
277 if action_func:
278 action_func(params)
279
280
281def _route_tmdb(action, params):
282
283 if action in ("search_tmdb_year", "search_tmdb_genres"):
284 from lib.clients.tmdb.tmdb import TmdbClient
285 from lib.utils.general.utils import set_content_type
286
287 if action == "search_tmdb_year":
288 mode = params["mode"]
289 set_content_type(mode)
290 TmdbClient.tmdb_search_year(
291 mode, params["submode"], int(params["year"]), int(params["page"])
292 )
293 else:
294 mode = params["mode"]
295 set_content_type(mode)
296 TmdbClient.tmdb_search_genres(
297 mode,
298 params["genre_id"],
299 int(params["page"]),
300 submode=params.get("submode"),
301 )
302 elif action in (
303 "search_people_by_id",
304 "handle_tmdb_person_details",
305 "handle_tmdb_person_info",
306 ):
307 from lib.clients.tmdb.people_client import PeopleClient
308
309 actions = {
310 "search_people_by_id": PeopleClient.search_people_by_id,
311 "handle_tmdb_person_details": PeopleClient.handle_tmdb_person_details,
312 "handle_tmdb_person_info": PeopleClient.handle_tmdb_person_info,
313 }
314 actions[action](params)
315 elif action == "handle_collection_details":
316 from lib.clients.tmdb.collections import TmdbCollections
317
318 TmdbCollections.add_collection_details(params)
319 else:
320 from lib.clients.tmdb.tmdb import TmdbClient
321
322 actions = {
323 "handle_tmdb_search": TmdbClient.handle_tmdb_search,
324 "handle_tmdb_query": TmdbClient.handle_tmdb_query,
325 "search_tmdb_lang": TmdbClient.show_lang_items,
326 "search_tmbd_network": TmdbClient.show_network_items,
327 "search_tmdb_recommendations": TmdbClient.search_tmdb_recommendations,
328 "search_tmdb_similar": TmdbClient.search_tmdb_similar,
329 "tmdb_search_modes": TmdbClient.tmdb_search_modes,
330 "tmdb_episode_search_modes": TmdbClient.tmdb_episode_search_modes,
331 "rescrape_tmdb_media": TmdbClient.rescrape_tmdb_media,
332 "show_tmdb_item": TmdbClient.show_tmdb_item,
333 "handle_collection_query": TmdbClient.handle_collection_query,
334 "handle_keyword_search": TmdbClient.handle_keyword_search,
335 "show_keyword_results": TmdbClient.show_keyword_results,
336 }
337 action_func = actions.get(action)
338 if action_func:
339 action_func(params)
340
341
342def _route_trakt(action, params):
343 if action in (
344 "trakt_add_to_watchlist",
345 "trakt_remove_from_watchlist",
346 "trakt_add_to_favorites",
347 "trakt_remove_from_favorites",
348 "trakt_mark_as_watched",
349 "trakt_mark_as_unwatched",
350 "add_to_collection",
351 "remove_from_collection",
352 "trakt_create_list",
353 "trakt_delete_list",
354 "trakt_like_list",
355 "trakt_unlike_list",
356 "trakt_add_item_to_list",
357 "trakt_remove_item_from_list",
358 ):
359 from lib.clients.trakt.trakt import TraktClient
360
361 actions = {
362 "trakt_add_to_watchlist": TraktClient.trakt_add_to_watchlist,
363 "trakt_remove_from_watchlist": TraktClient.trakt_remove_from_watchlist,
364 "trakt_add_to_favorites": TraktClient.trakt_add_to_favorites,
365 "trakt_remove_from_favorites": TraktClient.trakt_remove_from_favorites,
366 "trakt_mark_as_watched": TraktClient.trakt_mark_as_watched,
367 "trakt_mark_as_unwatched": TraktClient.trakt_mark_as_unwatched,
368 "add_to_collection": TraktClient.trakt_add_to_collection,
369 "remove_from_collection": TraktClient.trakt_remove_from_collection,
370 "trakt_create_list": TraktClient.trakt_create_list,
371 "trakt_delete_list": TraktClient.trakt_delete_list,
372 "trakt_like_list": TraktClient.trakt_like_list,
373 "trakt_unlike_list": TraktClient.trakt_unlike_list,
374 "trakt_add_item_to_list": TraktClient.trakt_add_item_to_list,
375 "trakt_remove_item_from_list": TraktClient.trakt_remove_item_from_list,
376 }
377 actions[action](params)
378 else:
379 from lib.navigation import (
380 trakt_auth,
381 trakt_auth_revoke,
382 trakt_group_menu,
383 trakt_list_content,
384 list_trakt_page,
385 )
386
387 actions = {
388 "trakt_auth": trakt_auth,
389 "trakt_auth_revoke": trakt_auth_revoke,
390 "trakt_group_menu": trakt_group_menu,
391 "trakt_list_content": trakt_list_content,
392 "list_trakt_page": list_trakt_page,
393 }
394 action_func = actions.get(action)
395 if action_func:
396 action_func(params)
397
398
399def _route_debrid(action, params):
400 from lib.navigation import (
401 rd_auth,
402 rd_remove_auth,
403 ad_auth,
404 ad_remove_auth,
405 real_debrid_info,
406 alldebrid_info,
407 debrider_auth,
408 debrider_remove_auth,
409 debrider_info,
410 get_rd_downloads,
411 get_tb_downloads,
412 cloud,
413 cloud_details,
414 pm_auth,
415 pm_remove_auth,
416 tb_auth,
417 tb_remove_auth,
418 torbox_info,
419 easynews_info,
420 )
421
422 actions = {
423 "rd_auth": rd_auth,
424 "rd_remove_auth": rd_remove_auth,
425 "ad_auth": ad_auth,
426 "ad_remove_auth": ad_remove_auth,
427 "real_debrid_info": real_debrid_info,
428 "alldebrid_info": alldebrid_info,
429 "debrider_auth": debrider_auth,
430 "debrider_remove_auth": debrider_remove_auth,
431 "debrider_info": debrider_info,
432 "get_rd_downloads": get_rd_downloads,
433 "get_tb_downloads": get_tb_downloads,
434 "cloud": cloud,
435 "cloud_details": cloud_details,
436 "pm_auth": pm_auth,
437 "pm_remove_auth": pm_remove_auth,
438 "tb_auth": tb_auth,
439 "tb_remove_auth": tb_remove_auth,
440 "torbox_info": torbox_info,
441 "easynews_info": easynews_info,
442 }
443 actions[action](params)
444
445
446def _route_telegram(action, params):
447 if action == "telegram_menu":
448 from lib.navigation import telegram_menu
449
450 telegram_menu(params)
451 else:
452 from lib.clients.jackgram.utils import (
453 list_jackgram_raw_files,
454 list_jackgram_latest_movies,
455 list_jackgram_latest_series,
456 list_jackgram_title_sources,
457 )
458
459 actions = {
460 "list_jackgram_raw_files": list_jackgram_raw_files,
461 "list_jackgram_latest_movies": list_jackgram_latest_movies,
462 "list_jackgram_latest_series": list_jackgram_latest_series,
463 "list_jackgram_title_sources": list_jackgram_title_sources,
464 }
465 action_func = actions.get(action)
466 if action_func:
467 action_func(params)
468
469
470def _route_torrserver(action, params):
471 if action in ("torrent_action", "torrent_files", "display_picture", "display_text"):
472 from lib.utils.torrent.torrserver_utils import (
473 torrent_action,
474 torrent_files,
475 display_picture,
476 display_text,
477 )
478
479 actions = {
480 "torrent_action": torrent_action,
481 "torrent_files": torrent_files,
482 "display_picture": display_picture,
483 "display_text": display_text,
484 }
485 actions[action](params)
486 elif action == "torrents":
487 from lib.navigation import torrents
488
489 torrents(params)
490 elif action == "torrentio_selection":
491 from lib.navigation import torrentio_selection
492
493 torrentio_selection(params)
494
495
496def _route_webdav(action, params):
497 from lib.clients.webdav.client import (
498 list_webdav,
499 webdav_provider_test,
500 display_text_webdav,
501 show_picture_webdav,
502 )
503
504 actions = {
505 "list_webdav": list_webdav,
506 "webdav_provider_test": webdav_provider_test,
507 "display_text_webdav": display_text_webdav,
508 "show_picture_webdav": show_picture_webdav,
509 }
510 actions[action](params)
511
512
513def _route_downloads(action, params):
514 if action in (
515 "download_video",
516 "handle_download_file",
517 "handle_cancel_download",
518 "handle_delete_file",
519 ):
520 from lib.downloader import (
521 download_video,
522 handle_download_file,
523 handle_cancel_download,
524 handle_delete_file,
525 )
526
527 actions = {
528 "download_video": download_video,
529 "handle_download_file": handle_download_file,
530 "handle_cancel_download": handle_cancel_download,
531 "handle_delete_file": handle_delete_file,
532 }
533 actions[action](params)
534 elif action == "downloads_menu":
535 from lib.navigation import downloads_menu
536
537 downloads_menu(params)
538
539
540def _route_mdblist(action, params):
541 from lib.clients.mdblist.mdblist import (
542 search_mdbd_lists,
543 show_mdblist_list,
544 top_mdbd_lists,
545 user_mdbd_lists,
546 )
547
548 actions = {
549 "search_mdbd_lists": search_mdbd_lists,
550 "user_mdbd_lists": user_mdbd_lists,
551 "top_mdbd_lists": top_mdbd_lists,
552 "show_mdblist_list": show_mdblist_list,
553 }
554 actions[action](params)
555
556
557def _route_cache(action, params):
558 from lib.navigation import (
559 clear_all_cached,
560 clear_trakt_cache,
561 clear_tmdb_cache,
562 clear_stremio_cache,
563 clear_debrid_cache,
564 clear_mdblist_cache,
565 clear_database_cache,
566 clear_history,
567 clear_search_history,
568 )
569
570 actions = {
571 "clear_all_cached": clear_all_cached,
572 "clear_trakt_cache": clear_trakt_cache,
573 "clear_tmdb_cache": clear_tmdb_cache,
574 "clear_stremio_cache": clear_stremio_cache,
575 "clear_debrid_cache": clear_debrid_cache,
576 "clear_mdblist_cache": clear_mdblist_cache,
577 "clear_database_cache": clear_database_cache,
578 "clear_history": clear_history,
579 "clear_search_history": clear_search_history,
580 }
581 action_func = actions.get(action)
582 if action_func:
583 action_func(params)
584
585
586def _route_gui(action, params):
587 if action in ("run_next_dialog", "run_resume_dialog", "run_skip_intro_dialog"):
588 from lib.gui.custom_dialogs import (
589 run_next_dialog,
590 run_resume_dialog,
591 run_skip_intro_dialog,
592 )
593
594 actions = {
595 "run_next_dialog": run_next_dialog,
596 "run_resume_dialog": run_resume_dialog,
597 "run_skip_intro_dialog": run_skip_intro_dialog,
598 }
599 actions[action](params)
600 elif action == "extras":
601 from lib.gui.extras_window import ExtrasWindow
602 from lib.jacktook.utils import ADDON_PATH
603
604 # Load the metadata properties needed by the Extras window
605 item_information = {
606 "tmdb_id": params.get("id"),
607 "imdb_id": params.get("imdb_id"),
608 "media_type": params.get("media_type"),
609 "title": params.get("title"),
610 "plot": params.get("plot", ""),
611 "genre": params.get("genre", ""),
612 "rating": params.get("rating", ""),
613 "tv_data": params.get("tv_data", "{}"),
614 }
615 xml_file = "extras.xml"
616 window = ExtrasWindow(xml_file, ADDON_PATH, item_information=item_information)
617 window.doModal()
618 del window
619 else:
620 from lib.navigation import (
621 test_source_select,
622 test_run_next,
623 test_resume_dialog,
624 test_download_dialog,
625 )
626
627 actions = {
628 "test_source_select": test_source_select,
629 "test_run_next": test_run_next,
630 "test_resume_dialog": test_resume_dialog,
631 "test_download_dialog": test_download_dialog,
632 }
633 action_func = actions.get(action)
634 if action_func:
635 action_func(params)
636
637
638def _route_core(action, params):
639 if action in ("resolve_for_pack_selection", "resolve_for_subtitles"):
640 from lib.actions import resolve_for_pack_selection, resolve_for_subtitles
641
642 actions = {
643 "resolve_for_pack_selection": resolve_for_pack_selection,
644 "resolve_for_subtitles": resolve_for_subtitles,
645 }
646 actions[action](params)
647 elif action in ("show_episodes_details", "show_seasons_details"):
648 from lib.utils.views.shows import (
649 show_episodes_details,
650 show_seasons_details,
651 )
652
653 actions = {
654 "show_episodes_details": show_episodes_details,
655 "show_seasons_details": show_seasons_details,
656 }
657 actions[action](params)
658 elif action == "delete_last_title_entry":
659 from lib.utils.views.last_titles import delete_last_title_entry
660
661 delete_last_title_entry(params)
662 else:
663 from lib.navigation import (
664 tv_shows_items,
665 movies_items,
666 trakt_group_menu,
667 direct_menu,
668 search_menu,
669 anime_menu,
670 anime_item,
671 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,
685 export_settings_backup,
686 restore_settings_backup,
687 reset_all_settings,
688 factory_reset,
689 history_menu,
690 files_history,
691 titles_history,
692 titles_calendar,
693 library_menu,
694 continue_watching_menu,
695 library_shows,
696 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,
706 open_burst_config,
707 kodi_logs,
708 easynews_info,
709 choose_view,
710 save_view,
711 reset_views,
712 views_menu,
713 )
714
715 actions = {
716 "tv_shows_items": tv_shows_items,
717 "movies_items": movies_items,
718 "trakt_group_menu": trakt_group_menu,
719 "direct_menu": direct_menu,
720 "search_menu": search_menu,
721 "anime_menu": anime_menu,
722 "anime_item": anime_item,
723 "anime_search": anime_search,
724 "animation_menu": animation_menu,
725 "animation_item": animation_item,
726 "tv_menu": tv_menu,
727 "search": search,
728 "search_direct": search_direct,
729 "search_item": search_item,
730 "next_page_anime": next_page_anime,
731 "play_media": play_media,
732 "play_from_pack": play_from_pack,
733 "play_trailer": play_trailer,
734 "play_url": play_url,
735 "settings_menu": settings_menu,
736 "settings": settings,
737 "export_settings_backup": export_settings_backup,
738 "restore_settings_backup": restore_settings_backup,
739 "reset_all_settings": reset_all_settings,
740 "factory_reset": factory_reset,
741 "history_menu": history_menu,
742 "files_history": files_history,
743 "titles_history": titles_history,
744 "titles_calendar": titles_calendar,
745 "library_menu": library_menu,
746 "continue_watching_menu": continue_watching_menu,
747 "library_shows": library_shows,
748 "library_movies": library_movies,
749 "library_calendar": library_calendar,
750 "remove_from_library": remove_from_library,
751 "clear_library": clear_library,
752 "remove_continue_watching": remove_from_continue_watching,
753 "add_to_library": add_to_library,
754 "donate": donate,
755 "addon_update": addon_update,
756 "downgrade_addon": downgrade_addon,
757 "show_changelog": show_changelog,
758 "open_burst_config": open_burst_config,
759 "kodi_logs": kodi_logs,
760 "easynews_info": easynews_info,
761 "choose_view": choose_view,
762 "save_view": save_view,
763 "reset_views": reset_views,
764 "views_menu": views_menu,
765 }
766 action_func = actions.get(action)
767 if action_func:
768 action_func(params)
769
770
771ROUTE_GROUPS = (
772 (_is_stremio_action, _route_stremio),
773 (_is_tmdb_action, _route_tmdb),
774 (_is_trakt_action, _route_trakt),
775 (_is_debrid_action, _route_debrid),
776 (_is_telegram_action, _route_telegram),
777 (_is_torrserver_action, _route_torrserver),
778 (_is_webdav_action, _route_webdav),
779 (_is_download_action, _route_downloads),
780 (_is_mdblist_action, _route_mdblist),
781 (_is_cache_action, _route_cache),
782 (_is_gui_action, _route_gui),
783)
plugin.video.jacktook/lib/search.py +1131 -0
……@@ -0,0 +1,1131 @@
1from concurrent.futures import ThreadPoolExecutor, as_completed
2from typing import List, Optional
3
4from lib.clients.stremio.helpers import (
5 get_addon_by_base_url,
6 get_selected_stream_addons,
7)
8from lib.clients.stremio.constants import STREMIO_ADDONS_KEY
9from lib.clients.stremio.addon_client import StremioAddonClient
10from lib.db.cached import cache
11from lib.domain.torrent import TorrentStream
12from lib.gui.custom_dialogs import source_select
13from lib.player import JacktookPLayer
14from lib.utils.clients.utils import get_client, update_dialog
15from lib.utils.general.utils import (
16 DialogListener,
17 Indexer,
18 SearchVariant,
19 cache_results,
20 get_cached_results,
21 pre_process,
22 post_process,
23 build_media_metadata,
24 set_content_type,
25 set_watched_title,
26 clean_auto_play_undesired,
27 normalize_tv_data,
28 safe_json_loads,
29)
30from lib.utils.debrid.debrid_utils import check_debrid_cached
31from lib.utils.kodi.settings import auto_play_enabled, get_setting
32from lib.utils.player.utils import resolve_playback_url
33from lib.utils.kodi.utils import (
34 notification,
35 cancel_playback,
36 kodilog,
37 translation,
38 close_busy_dialog,
39 ADDON_PATH,
40)
41
42from lib.gui.search_status_window import SearchTaskManager, SearchStatusWindow
43
44import xbmc
45from xbmcgui import Dialog
46
47
48TITLE_LANGUAGE_LOCALIZED_FIRST = "localized_first"
49TITLE_LANGUAGE_ENGLISH_FIRST = "english_first"
50TITLE_LANGUAGE_ENGLISH_ONLY = "english_only"
51
52
53def _build_search_cache_scope(scoped_addon_url: str = "") -> str:
54 flags = {
55 "torrent": bool(get_setting("torrent_enable")),
56 "stremio": bool(get_setting("stremio_enabled")),
57 "rd": bool(get_setting("real_debrid_enabled")),
58 "ad": bool(get_setting("alldebrid_enabled")),
59 "tb": bool(get_setting("torbox_enabled")),
60 "pm": bool(get_setting("premiumize_enabled")),
61 "db": bool(get_setting("debrider_enabled")),
62 "ed": bool(get_setting("easydebrid_enabled")),
63 }
64 selected_stream_addons = str(cache.get(STREMIO_ADDONS_KEY) or "")
65 normalized_scoped_url = str(scoped_addon_url or "")
66 return "{}|{}|{}".format(
67 "|".join([f"{key}:{int(value)}" for key, value in sorted(flags.items())]),
68 selected_stream_addons,
69 normalized_scoped_url,
70 )
71
72
73def _clean_title_candidate(value) -> str:
74 if value is None:
75 return ""
76 return str(value).strip()
77
78
79def _unique_title_candidates(candidates: List[str]) -> List[str]:
80 unique = []
81 seen = set()
82
83 for candidate in candidates:
84 cleaned = _clean_title_candidate(candidate)
85 if not cleaned:
86 continue
87 key = cleaned.casefold()
88 if key in seen:
89 continue
90 seen.add(key)
91 unique.append(cleaned)
92
93 return unique
94
95
96def _normalize_search_variant(variant) -> str:
97 allowed = {
98 SearchVariant.DEFAULT,
99 SearchVariant.TITLE_YEAR,
100 SearchVariant.ORIGINAL_TITLE,
101 SearchVariant.ORIGINAL_TITLE_YEAR,
102 }
103 return variant if variant in allowed else SearchVariant.DEFAULT
104
105
106def _normalize_title_language_mode(mode) -> str:
107 allowed = {
108 TITLE_LANGUAGE_LOCALIZED_FIRST,
109 TITLE_LANGUAGE_ENGLISH_FIRST,
110 TITLE_LANGUAGE_ENGLISH_ONLY,
111 }
112 return mode if mode in allowed else TITLE_LANGUAGE_LOCALIZED_FIRST
113
114
115def _extract_english_tmdb_title(details, mode: str) -> str:
116 translations = getattr(details, "translations", None)
117 entries = getattr(translations, "translations", translations)
118 if not entries:
119 return ""
120
121 title_key = "title" if mode == "movies" else "name"
122
123 for entry in entries:
124 if getattr(entry, "iso_639_1", "") != "en":
125 continue
126
127 data = getattr(entry, "data", None)
128 if not data:
129 continue
130
131 return _clean_title_candidate(getattr(data, title_key, ""))
132
133 return ""
134
135
136def _build_title_fallback_queries(
137 query: str,
138 ids: dict,
139 mode: str,
140 variant: str = SearchVariant.DEFAULT,
141 year: Optional[int] = None,
142 title_language_mode: Optional[str] = None,
143) -> List[str]:
144 variant = _normalize_search_variant(variant)
145 title_language_mode = _normalize_title_language_mode(
146 title_language_mode
147 if title_language_mode is not None
148 else get_setting("search_title_language_mode", TITLE_LANGUAGE_LOCALIZED_FIRST)
149 )
150 candidates = []
151
152 cleaned_query = _clean_title_candidate(query)
153 cleaned_year = _clean_title_candidate(year)
154
155 localized_candidate = ""
156 if cleaned_query and variant == SearchVariant.DEFAULT:
157 localized_candidate = cleaned_query
158 elif cleaned_query and cleaned_year and variant == SearchVariant.TITLE_YEAR:
159 localized_candidate = f"{cleaned_query} {cleaned_year}"
160
161 if localized_candidate:
162 candidates.append(localized_candidate)
163
164 if not ids:
165 return _unique_title_candidates(candidates)
166
167 tmdb_id = ids.get("tmdb_id")
168 if not tmdb_id or mode not in ("movies", "tv"):
169 return _unique_title_candidates(candidates)
170
171 try:
172 from lib.clients.tmdb.utils.utils import get_tmdb_media_details
173
174 details = get_tmdb_media_details(tmdb_id, mode)
175 except Exception as e:
176 kodilog(f"Failed to load TMDB titles for fallback: {e}")
177 details = None
178
179 if not details:
180 return _unique_title_candidates(candidates)
181
182 english_title = _extract_english_tmdb_title(details, mode)
183 original_title = getattr(details, "original_title", "") or getattr(
184 details, "original_name", ""
185 )
186
187 english_candidate = ""
188 original_candidate = ""
189 if variant == SearchVariant.DEFAULT:
190 english_candidate = english_title
191 original_candidate = original_title
192 elif variant == SearchVariant.TITLE_YEAR and cleaned_year:
193 if english_title:
194 english_candidate = f"{english_title} {cleaned_year}"
195 if original_title:
196 original_candidate = f"{original_title} {cleaned_year}"
197
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:
208 if title_language_mode == TITLE_LANGUAGE_ENGLISH_ONLY:
209 candidates = [english_candidate, original_candidate]
210 if not any(candidates) and localized_candidate:
211 candidates.append(localized_candidate)
212 elif title_language_mode == TITLE_LANGUAGE_ENGLISH_FIRST:
213 candidates = [english_candidate, original_candidate, localized_candidate]
214 else:
215 candidates = [localized_candidate, english_candidate, original_candidate]
216 elif variant == SearchVariant.ORIGINAL_TITLE:
217 if original_title:
218 candidates.append(original_title)
219 elif cleaned_query:
220 candidates.append(cleaned_query)
221 elif variant == SearchVariant.ORIGINAL_TITLE_YEAR:
222 if original_title and cleaned_year:
223 candidates.append(f"{original_title} {cleaned_year}")
224 elif original_title:
225 candidates.append(original_title)
226 elif cleaned_query and cleaned_year:
227 candidates.append(f"{cleaned_query} {cleaned_year}")
228 elif cleaned_query:
229 candidates.append(cleaned_query)
230
231 return _unique_title_candidates(candidates)
232
233
234def _perform_search_with_title_fallback(
235 indexer_key,
236 dialog,
237 query: str,
238 ids: dict,
239 mode: str,
240 *args,
241 variant: str = SearchVariant.DEFAULT,
242 year: Optional[int] = None,
243 title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST,
244 **kwargs,
245):
246 variant = _normalize_search_variant(variant)
247 title_language_mode = _normalize_title_language_mode(title_language_mode)
248 queries = _build_title_fallback_queries(
249 query,
250 ids,
251 mode,
252 variant,
253 year,
254 title_language_mode=title_language_mode,
255 )
256
257 for attempt, candidate in enumerate(queries, start=1):
258 if attempt > 1:
259 kodilog(
260 f"Retrying {indexer_key} search with fallback title {candidate!r}",
261 level=xbmc.LOGINFO,
262 )
263
264 results = _perform_search(
265 indexer_key, dialog, candidate, mode, *args, variant=variant, year=year, **kwargs
266 )
267 if results:
268 return results
269
270 return []
271
272
273def _handle_super_quick_play(params: dict) -> bool:
274 if not get_setting("super_quick_play", False):
275 kodilog("Super quick play disabled")
276 return False
277
278 ids = safe_json_loads(params.get("ids"))
279 key = str(ids.get("original_id") or ids.get("imdb_id") or ids.get("tmdb_id") or "")
280
281 if key:
282 tv_data = safe_json_loads(params.get("tv_data") or "{}")
283 if tv_data and "season" in tv_data and "episode" in tv_data:
284 key += f'_{tv_data["season"]}_{tv_data["episode"]}'
285
286 if not key:
287 kodilog("Super quick play: No valid key found from ids")
288 return False
289
290 cached_torrent = cache.get(key)
291 if not cached_torrent:
292 kodilog("No cached media found")
293 return False
294
295 kodilog(f"Found cached media: {cached_torrent['title']}")
296
297 if get_setting("silent_resume", False):
298 playback_info = resolve_playback_url(cached_torrent)
299 if not playback_info:
300 notification(translation(90144))
301 return True
302
303 player = JacktookPLayer()
304 player.run(data=playback_info)
305 return True
306
307 dialog = Dialog()
308 if dialog.yesno(
309 translation(90142),
310 translation(90143),
311 ):
312 playback_info = resolve_playback_url(cached_torrent)
313 if not playback_info:
314 notification(translation(90144))
315 return True
316
317 player = JacktookPLayer()
318 player.run(data=playback_info)
319 return True
320
321 kodilog("User chose not to play cached torrent")
322 return False
323
324
325def _process_search_results(
326 results,
327 mode,
328 ep_name,
329 episode,
330 season,
331 scoped_addon_url,
332 query,
333 media_type,
334 rescrape,
335):
336 bypassed_streams = []
337 other_results = results
338
339 if get_setting("stremio_bypass_addons", True):
340 bypass_list_str = str(get_setting("stremio_bypass_addon_list", "") or "")
341 bypass_addons = [value.strip() for value in bypass_list_str.split(",") if value.strip()]
342
343 def is_bypassed(result):
344 if result.addonKey and result.addonKey in bypass_addons:
345 return True
346
347 legacy_name = (result.addonName or result.indexer or "").lower()
348 return any(addon == legacy_name for addon in bypass_addons)
349
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 ]
360
361 pre_results = []
362 if other_results:
363 pre_results = pre_process_results(
364 other_results,
365 mode,
366 ep_name,
367 episode,
368 season,
369 skip_episode_filter=bool(scoped_addon_url),
370 )
371
372 post_results = []
373 if pre_results:
374 post_results = process_results(
375 pre_results, query, mode, media_type, rescrape, episode
376 )
377
378 # Combine results, prioritizing bypassed streams exact native sorting
379 return bypassed_streams + post_results
380
381
382def run_search_entry(params: dict):
383 if _handle_super_quick_play(params):
384 return
385
386 query = params.get("query", "")
387 mode = params.get("mode", "")
388 media_type = params.get("media_type", "")
389 ids = safe_json_loads(params.get("ids"))
390 tv_data = normalize_tv_data(safe_json_loads(params.get("tv_data")))
391 direct = params.get("direct", False)
392 rescrape = params.get("rescrape", False)
393
394 variant = _normalize_search_variant(params.get("search_variant", SearchVariant.DEFAULT))
395 title_language_mode = _normalize_title_language_mode(
396 params.get(
397 "search_title_language_mode",
398 get_setting("search_title_language_mode", TITLE_LANGUAGE_LOCALIZED_FIRST),
399 )
400 )
401 year = params.get("year")
402 if year is not None:
403 try:
404 year = int(year)
405 except (ValueError, TypeError):
406 year = None
407
408 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)}"
410 )
411
412 library_data = None
413 if params.get("stremio_addon_url") and params.get("stremio_catalog_type"):
414 library_data = {
415 "source": "stremio_catalog",
416 "addon_url": params.get("stremio_addon_url"),
417 "catalog_type": params.get("stremio_catalog_type"),
418 "meta_id": params.get("stremio_meta_id") or ids.get("original_id", ""),
419 }
420
421 set_content_type(mode)
422 set_watched_title(query, ids, mode, media_type=media_type, library_data=library_data)
423
424 ep_name = tv_data.get("name", "")
425 episode = tv_data.get("episode", 1)
426 season = tv_data.get("season", 1)
427
428 scoped_addon_url = params.get("scoped_addon_url", "")
429
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 )
443 if not results:
444 notification("No results found")
445 cancel_playback()
446 return
447
448 final_results = _process_search_results(
449 results,
450 mode,
451 ep_name,
452 episode,
453 season,
454 scoped_addon_url,
455 query,
456 media_type,
457 rescrape,
458 )
459
460 if not final_results:
461 notification(translation(90358))
462 cancel_playback()
463 return
464
465 preferred_group = params.get("preferred_group")
466 force_select = params.get("force_select", False)
467
468 if auto_play_enabled() and not force_select:
469 if not auto_play(final_results, ids, tv_data, mode, preferred_group):
470 cancel_playback()
471 return
472
473 if not show_source_select(
474 final_results,
475 mode,
476 ids,
477 tv_data,
478 query,
479 media_type,
480 rescrape,
481 direct,
482 ):
483 cancel_playback()
484
485
486def _perform_search(indexer_key, dialog, *args, **kwargs):
487 show_dialog = kwargs.pop("show_dialog", True)
488 scoped_addon_url = kwargs.pop("scoped_addon_url", "")
489
490 if indexer_key == Indexer.STREMIO:
491 if scoped_addon_url:
492 addon = get_addon_by_base_url(scoped_addon_url)
493 stremio_addons = [addon] if addon else []
494 else:
495 stremio_addons = get_selected_stream_addons()
496 if not stremio_addons:
497 notification("No Stremio addons selected")
498 return []
499
500 ids_dict = args[0]
501 rest_args = args[1:]
502 results = []
503
504 for addon in stremio_addons:
505 if show_dialog:
506 update_dialog(addon.manifest.name, "Searching...", dialog)
507
508 video_id = None
509 original_id = ids_dict.get("original_id")
510 media_kind = "series" if args[1] == "tv" or args[2] == "tv" else "movie"
511
512 if original_id:
513 prefix = original_id.split(":")[0]
514 if addon.isSupported("stream", media_kind, prefix):
515 video_id = original_id
516
517 # Try IMDb ID for addons that declare tt: prefix
518 if not video_id and ids_dict.get("imdb_id"):
519 if addon.isSupported("stream", media_kind, "tt"):
520 video_id = ids_dict.get("imdb_id")
521
522 # Try TMDB ID for addons that declare tmdb: prefix
523 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"):
527 video_id = f"tmdb:{ids_dict['tmdb_id']}"
528
529 if video_id:
530 try:
531 client = StremioAddonClient(addon)
532 results.extend(client.search(video_id, *rest_args))
533 except Exception as e:
534 kodilog(f"Error searching {addon.manifest.name}: {e}")
535
536 return results
537
538 client = get_client(indexer_key)
539 if not client:
540 return []
541
542 if indexer_key == Indexer.BURST and not show_dialog:
543 kwargs["silent"] = True
544
545 return client.search(*args, **kwargs)
546
547
548def _submit_search_tasks(
549 executor,
550 tasks,
551 dialog,
552 query,
553 mode,
554 media_type,
555 season,
556 episode,
557 ids,
558 scoped_addon_url,
559 tmdb_id,
560 imdb_id,
561 show_dialog,
562 variant: str = SearchVariant.DEFAULT,
563 title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST,
564 year: Optional[int] = None,
565):
566 def submit_performer(*args, **kwargs):
567 if "show_dialog" not in kwargs:
568 kwargs["show_dialog"] = show_dialog
569 if "scoped_addon_url" not in kwargs:
570 kwargs["scoped_addon_url"] = scoped_addon_url
571 if "variant" not in kwargs:
572 kwargs["variant"] = variant
573 if "title_language_mode" not in kwargs:
574 kwargs["title_language_mode"] = title_language_mode
575 if "year" not in kwargs:
576 kwargs["year"] = year
577 return executor.submit(
578 _perform_search,
579 *args,
580 **kwargs,
581 )
582
583 if scoped_addon_url:
584 if ids.get("imdb_id") or ids.get("original_id") or ids.get("tmdb_id"):
585 tasks.append(
586 submit_performer(
587 Indexer.STREMIO, dialog, ids, mode, media_type, season, episode
588 )
589 )
590 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"):
627 indexers_ids = get_setting("prowlarr_indexer_ids")
628 tasks.append(
629 executor.submit(
630 _perform_search_with_title_fallback,
631 Indexer.PROWLARR,
632 dialog,
633 query,
634 ids,
635 mode,
636 season,
637 episode,
638 indexers_ids,
639 show_dialog=show_dialog,
640 scoped_addon_url=scoped_addon_url,
641 variant=variant,
642 title_language_mode=title_language_mode,
643 year=year,
644 )
645 )
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")
682 ):
683 tasks.append(
684 submit_performer(
685 Indexer.STREMIO, dialog, ids, mode, media_type, season, episode
686 )
687 )
688
689
690def _collect_search_results(tasks, listener, show_dialog) -> List[TorrentStream]:
691 total_results = []
692 total_tasks = len(tasks)
693 completed_tasks = 0
694
695 for future in as_completed(tasks):
696 try:
697 completed_tasks += 1
698 if show_dialog and total_tasks > 0:
699 percent = int(completed_tasks / total_tasks * 100)
700 update_dialog(
701 "Searching", f"Searching... {percent}%", listener.dialog, percent
702 )
703
704 results = future.result()
705 kodilog(f"Results from {future}: {results}", level=xbmc.LOGDEBUG)
706 if results:
707 total_results.extend(results)
708 except Exception as e:
709 import traceback
710
711 error_details = traceback.format_exc()
712 kodilog(
713 f"Error resolving future result in thread pool: {e}\n{error_details}"
714 )
715
716 return total_results
717
718
719def _submit_search_tasks_managed(
720 manager,
721 dialog,
722 query,
723 mode,
724 media_type,
725 season,
726 episode,
727 ids,
728 scoped_addon_url,
729 tmdb_id,
730 imdb_id,
731 variant: str = SearchVariant.DEFAULT,
732 title_language_mode: str = TITLE_LANGUAGE_LOCALIZED_FIRST,
733 year: Optional[int] = None,
734):
735 def submit_performer_managed(name, indexer_key, *args, **kwargs):
736 kwargs["show_dialog"] = False
737 if "scoped_addon_url" not in kwargs:
738 kwargs["scoped_addon_url"] = scoped_addon_url
739 if "variant" not in kwargs:
740 kwargs["variant"] = variant
741 if "title_language_mode" not in kwargs:
742 kwargs["title_language_mode"] = title_language_mode
743 if "year" not in kwargs:
744 kwargs["year"] = year
745 return manager.submit_task(
746 name,
747 indexer_key,
748 _perform_search,
749 indexer_key,
750 *args,
751 **kwargs,
752 )
753
754 if scoped_addon_url:
755 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")
758 ):
759 submit_performer_managed(
760 addon.manifest.name,
761 Indexer.STREMIO,
762 dialog,
763 ids,
764 mode,
765 media_type,
766 season,
767 episode,
768 scoped_addon_url=scoped_addon_url,
769 )
770 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"):
805 indexers_ids = get_setting("prowlarr_indexer_ids")
806 manager.submit_task(
807 "Prowlarr",
808 Indexer.PROWLARR,
809 _perform_search_with_title_fallback,
810 Indexer.PROWLARR,
811 dialog,
812 query,
813 ids,
814 mode,
815 season,
816 episode,
817 indexers_ids,
818 show_dialog=False,
819 scoped_addon_url=scoped_addon_url,
820 variant=variant,
821 title_language_mode=title_language_mode,
822 year=year,
823 )
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 ):
859 stremio_addons = get_selected_stream_addons()
860 for addon in stremio_addons:
861 submit_performer_managed(
862 addon.manifest.name,
863 Indexer.STREMIO,
864 dialog,
865 ids,
866 mode,
867 media_type,
868 season,
869 episode,
870 scoped_addon_url=addon.url(),
871 )
872
873
874def search_client(
875 query: str,
876 ids: dict,
877 mode: str,
878 media_type: str,
879 rescrape: bool,
880 season: int,
881 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)
890
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
897
898 tmdb_id, imdb_id = (ids.get("tmdb_id"), ids.get("imdb_id")) if ids else (None, None)
899 total_results = []
900 tasks = []
901
902 use_detailed_status = show_dialog and get_setting("search_dialog_style", "0") == "1"
903
904 if use_detailed_status:
905 executor = ThreadPoolExecutor(max_workers=int(get_setting("thread_number", 6)))
906 manager = SearchTaskManager(executor)
907 _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,
922 )
923
924 item_info = {"ids": ids, "mode": mode}
925 if ids:
926 item_info.update(build_media_metadata(ids, mode))
927
928 window = SearchStatusWindow(
929 "search_status.xml",
930 ADDON_PATH,
931 task_manager=manager,
932 item_information=item_info,
933 )
934 window.doModal()
935 del window
936
937 total_results = manager.collect_results()
938
939 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 )
969
970 total_results = _collect_search_results(tasks, listener, show_dialog)
971
972 cache_results(
973 total_results,
974 query,
975 mode,
976 media_type,
977 episode,
978 cache_scope=cache_scope,
979 )
980 return total_results
981
982
983def pre_process_results(
984 results: List[TorrentStream],
985 mode: str,
986 ep_name: str,
987 episode: int,
988 season: int,
989 skip_episode_filter: bool = False,
990) -> List[TorrentStream]:
991 return pre_process(results, mode, ep_name, episode, season, skip_episode_filter)
992
993
994def process_results(
995 pre_results: List[TorrentStream],
996 query: str,
997 mode: str,
998 media_type: str,
999 rescrape: bool,
1000 episode: int,
1001) -> List[TorrentStream]:
1002 torrent_results = []
1003 if get_setting("torrent_enable"):
1004 torrent_results = post_process(pre_results)
1005 close_busy_dialog()
1006 with DialogListener() as listener:
1007 debrid_results = check_debrid_cached(
1008 query, pre_results, mode, media_type, listener.dialog, rescrape, episode
1009 )
1010 return debrid_results + torrent_results
1011
1012
1013def show_source_select(
1014 results: List[TorrentStream],
1015 mode: str,
1016 ids: dict,
1017 tv_data: dict,
1018 query: str,
1019 media_type: str,
1020 rescrape: bool,
1021 direct: bool = False,
1022) -> bool:
1023 item_info = {
1024 "tv_data": tv_data,
1025 "ids": ids,
1026 "mode": mode,
1027 "query": query,
1028 "media_type": media_type,
1029 "rescrape": rescrape,
1030 }
1031
1032 if not direct and ids:
1033 item_info.update(build_media_metadata(ids, mode))
1034
1035 kodilog(
1036 f"show_source_select context: query={query}, mode={mode}, media_type={media_type}, year={item_info.get('year')}, ids={ids}"
1037 )
1038
1039 xml_file_string = (
1040 "source_select_direct.xml" if mode == "direct" else "source_select.xml"
1041 )
1042
1043 return source_select(item_info, xml_file=xml_file_string, sources=results)
1044
1045
1046def auto_play(
1047 results: List[TorrentStream],
1048 ids,
1049 tv_data,
1050 mode,
1051 preferred_group=None,
1052 force_select=False,
1053) -> bool:
1054 if force_select:
1055 return False
1056
1057 filtered_results = clean_auto_play_undesired(results)
1058 if not filtered_results:
1059 notification("No suitable source found for auto play.")
1060 return False
1061
1062 preferred_quality = str(get_setting("auto_play_quality"))
1063 quality_matches = [
1064 r for r in filtered_results if preferred_quality.lower() in r.quality.lower()
1065 ]
1066
1067 if not quality_matches:
1068 notification("No sources found with the preferred quality.")
1069 return False
1070
1071 selected_result = quality_matches[0]
1072 if preferred_group:
1073 group_matches = [
1074 r for r in quality_matches if preferred_group.lower() in r.title.lower()
1075 ]
1076 if group_matches:
1077 selected_result = group_matches[0]
1078
1079 playback_info = resolve_playback_url(
1080 data={
1081 "title": selected_result.title,
1082 "mode": mode,
1083 "indexer": selected_result.indexer,
1084 "type": selected_result.type,
1085 "debrid_type": selected_result.debridType,
1086 "ids": ids,
1087 "info_hash": selected_result.infoHash,
1088 "url": selected_result.url,
1089 # getattr — see prepare_source_data: unpickled cached results predate
1090 # this field and pickle does not apply dataclass defaults.
1091 "proxyHeaders": getattr(selected_result, "proxyHeaders", None) or {},
1092 "tv_data": tv_data,
1093 "is_torrent": False,
1094 },
1095 )
1096
1097 if not playback_info:
1098 return False
1099
1100 player = JacktookPLayer()
1101 player.run(data=playback_info)
1102 del player
1103 return True
1104
1105
1106def stremio_addon_generator(stremio_addons, dialog, show_dialog):
1107 for addon in stremio_addons:
1108 if show_dialog:
1109 update_dialog(addon.manifest.name, "Searching...", dialog)
1110 yield StremioAddonClient(addon)
1111
1112
1113def add_task_if_enabled(
1114 executor, tasks, setting_key, indexer_key, perform_search, dialog, *args, **kwargs
1115):
1116 """Add a search task to the task list if the corresponding setting is enabled."""
1117 if get_setting(setting_key):
1118 tasks.append(
1119 executor.submit(perform_search, indexer_key, dialog, *args, **kwargs)
1120 )
1121
1122
1123def add_task_if_enabled_managed(
1124 manager, setting_key, indexer_key, perform_search, dialog, *args, **kwargs
1125):
1126 """Add a managed search task if the corresponding setting is enabled."""
1127 if get_setting(setting_key):
1128 name = str(indexer_key).title()
1129 manager.submit_task(
1130 name, indexer_key, perform_search, indexer_key, dialog, *args, **kwargs
1131 )
plugin.video.jacktook/lib/services/background_service.py +259 -0
……@@ -0,0 +1,259 @@
1from threading import Thread
2from time import time
3import os
4
5import xbmc
6import xbmcaddon
7import xbmcvfs
8
9from lib.api.trakt.base_cache import setup_databases
10from lib.services.migrations import run_migrations
11from lib.services.preloader import StartupPreloader
12from lib.services.trakt_sync import TraktSyncService
13from lib.updater import updates_check_addon
14from lib.utils.kodi.settings import update_delay
15from lib.utils.kodi.utils import (
16 clear_cached_settings,
17 clear_property,
18 dialog_ok,
19 get_kodi_version,
20 get_property_no_fallback,
21 get_setting,
22 kodilog,
23 set_property_no_fallback,
24 translation,
25 translatePath,
26)
27
28
29first_run_update_prop = "jacktook.first_run_update"
30pause_services_prop = "jacktook.pause_services"
31
32
33class CheckKodiVersion:
34 def run(self):
35 kodilog("Checking Kodi version...")
36 if get_kodi_version() < 20:
37 dialog_ok(
38 heading=xbmcaddon.Addon().getAddonInfo("name"),
39 line1=translation(90559),
40 )
41
42
43class DatabaseSetup:
44 def run(self):
45 setup_databases()
46
47
48class UpdateCheck:
49 def run(self):
50 kodilog("Update Check Service Started...", level=xbmc.LOGINFO)
51 if get_property_no_fallback(first_run_update_prop) == "true":
52 kodilog("Update check already performed, skipping...", level=xbmc.LOGINFO)
53 return
54
55 try:
56 end_pause = time() + update_delay()
57 monitor = xbmc.Monitor()
58 player = xbmc.Player()
59
60 while not monitor.abortRequested():
61 while time() < end_pause:
62 monitor.waitForAbort(1)
63 while player.isPlayingVideo():
64 monitor.waitForAbort(1)
65 set_property_no_fallback(first_run_update_prop, "true")
66 updates_check_addon(automatic=True)
67 break
68
69 kodilog("Update Check Service Finished", level=xbmc.LOGINFO)
70 except Exception as e:
71 kodilog(e, level=xbmc.LOGERROR)
72
73
74def TMDBHelperAutoInstall():
75 jacktook_select_path = (
76 "special://home/addons/plugin.video.jacktook/jacktook.select.json"
77 )
78
79 if not xbmcvfs.exists(jacktook_select_path):
80 kodilog("jacktook.select.json file not found!", level=xbmc.LOGERROR)
81 return
82
83 target_addons = [
84 "plugin.video.themoviedb.helper",
85 "plugin.video.tmdb.bingie.helper",
86 ]
87
88 source_content = _read_text_file(jacktook_select_path)
89 if source_content is None:
90 kodilog("Unable to read bundled jacktook.select.json", level=xbmc.LOGERROR)
91 return
92
93 for addon_id in target_addons:
94 try:
95 _ = xbmcaddon.Addon(addon_id)
96 except RuntimeError:
97 kodilog(
98 f"{addon_id} addon not found, skipping auto-install.",
99 level=xbmc.LOGINFO,
100 )
101 continue
102
103 tmdb_helper_path = (
104 f"special://home/addons/{addon_id}/resources/players/jacktook.select.json"
105 )
106
107 target_content = _read_text_file(tmdb_helper_path)
108 if target_content == source_content:
109 continue
110
111 target_dir = os.path.dirname(translatePath(tmdb_helper_path))
112 if target_dir and not xbmcvfs.exists(target_dir):
113 xbmcvfs.mkdirs(target_dir)
114
115 if xbmcvfs.exists(tmdb_helper_path):
116 xbmcvfs.delete(tmdb_helper_path)
117
118 ok = xbmcvfs.copy(jacktook_select_path, tmdb_helper_path)
119 if ok:
120 kodilog(
121 f"Installed or updated jacktook.select.json file for {addon_id}!",
122 level=xbmc.LOGINFO,
123 )
124 else:
125 kodilog(
126 f"Error installing jacktook.select.json file for {addon_id}!",
127 level=xbmc.LOGERROR,
128 )
129
130
131def _read_text_file(path):
132 translated_path = translatePath(path)
133 if not translated_path or not os.path.exists(translated_path):
134 return None
135
136 try:
137 with open(translated_path, "r", encoding="utf-8") as file_obj:
138 return file_obj.read()
139 except OSError as error:
140 kodilog(f"Failed to read {path}: {error}", level=xbmc.LOGERROR)
141 return None
142
143
144class DownloaderSetup:
145 def run(self):
146 download_dir = get_setting("download_dir")
147 translated_path = translatePath(download_dir)
148 if not xbmcvfs.exists(translated_path):
149 xbmcvfs.mkdir(translated_path)
150
151
152class DebridExpirationCheck:
153 def run(self):
154 kodilog("Debrid Expiration Check Service Started...", level=xbmc.LOGINFO)
155 if not get_setting("debrid_expiration_enabled"):
156 return
157
158 try:
159 threshold = int(get_setting("debrid_expiration_threshold", 3))
160
161 def notify_expiration(name, days):
162 from lib.utils.kodi.utils import notification, translation
163
164 if days == 0:
165 msg = translation(90231) % name
166 else:
167 msg = translation(90230) % (name, days)
168 notification(msg, time=5000)
169
170 if get_setting("real_debrid_enabled") and get_setting("real_debrid_token"):
171 from lib.api.debrid.realdebrid import RealDebrid
172
173 rd = RealDebrid(str(get_setting("real_debrid_token", "")))
174 days = rd.days_remaining()
175 if days is not None and days <= threshold:
176 notify_expiration("Real-Debrid", days)
177
178 alldebrid_token = get_setting("alldebrid_token")
179 if get_setting("alldebrid_enabled") and alldebrid_token:
180 from lib.api.debrid.alldebrid import AllDebrid
181
182 ad = AllDebrid(str(alldebrid_token))
183 days = ad.days_remaining()
184 if days is not None and days <= threshold:
185 notify_expiration("AllDebrid", days)
186
187 if get_setting("torbox_enabled") and get_setting("torbox_token"):
188 from lib.api.debrid.torbox import Torbox
189
190 tb = Torbox(get_setting("torbox_token"))
191 days = tb.days_remaining()
192 if days is not None and days <= threshold:
193 notify_expiration("Torbox", days)
194
195 if get_setting("premiumize_enabled") and get_setting("premiumize_token"):
196 from lib.api.debrid.premiumize import Premiumize
197
198 pm = Premiumize(str(get_setting("premiumize_token", "")))
199 days = pm.days_remaining()
200 if days is not None and days <= threshold:
201 notify_expiration("Premiumize", days)
202
203 if get_setting("debrider_enabled") and get_setting("debrider_token"):
204 from lib.api.debrid.debrider import Debrider
205
206 db = Debrider(get_setting("debrider_token"))
207 days = db.days_remaining()
208 if days is not None and days <= threshold:
209 notify_expiration("Debrider", days)
210
211 except Exception as e:
212 kodilog(f"Error in DebridExpirationCheck: {e}", level=xbmc.LOGERROR)
213
214
215class JacktookMOnitor(xbmc.Monitor):
216 def __init__(self):
217 xbmc.Monitor.__init__(self)
218 self.startServices()
219
220 def startServices(self):
221 CheckKodiVersion().run()
222 DatabaseSetup().run()
223 run_migrations()
224 Thread(target=UpdateCheck().run).start()
225 Thread(target=TraktSyncService().run).start()
226 Thread(target=DebridExpirationCheck().run).start()
227 StartupPreloader().run()
228 DownloaderSetup().run()
229 TMDBHelperAutoInstall()
230
231 def onScreensaverActivated(self):
232 set_property_no_fallback(pause_services_prop, "true")
233 kodilog(
234 "PAUSING Jacktook Services Due to Device Sleep", level=xbmc.LOGINFO
235 )
236
237 def onScreensaverDeactivated(self):
238 clear_property(pause_services_prop)
239 kodilog(
240 "UNPAUSING Jacktook Services Due to Device Awake", level=xbmc.LOGINFO
241 )
242
243 def onSettingsChanged(self):
244 clear_cached_settings()
245 kodilog("Cleared cached settings after Kodi settings change", level=xbmc.LOGINFO)
246
247 def onNotification(self, sender, method, data):
248 if method == "System.OnSleep":
249 set_property_no_fallback(pause_services_prop, "true")
250 kodilog(
251 "PAUSING Jacktook Services Due to Device Sleep",
252 level=xbmc.LOGINFO,
253 )
254 elif method == "System.OnWake":
255 clear_property(pause_services_prop)
256 kodilog(
257 "UNPAUSING Jacktook Services Due to Device Awake",
258 level=xbmc.LOGINFO,
259 )
plugin.video.jacktook/lib/services/debrid/__init__.py +1 -0
……@@ -0,0 +1 @@
1"""Debrid service orchestration layer."""
plugin.video.jacktook/lib/services/debrid/auth.py +292 -0
……@@ -0,0 +1,292 @@
1from time import time
2
3from lib.gui.qr_progress_dialog import QRProgressDialog
4from lib.jacktook.utils import ADDON_PATH
5from lib.utils.debrid.qrcode_utils import make_qrcode
6from lib.utils.general.utils import DebridType
7from lib.utils.kodi.utils import (
8 copy2clip,
9 dialog_ok,
10 progressDialog,
11 set_setting,
12 sleep as ksleep,
13 translation,
14)
15
16
17def run_realdebrid_auth(client):
18 response = client.get_device_code()
19 if not response:
20 return
21
22 sleep_interval = int(response["interval"])
23 expires_in = int(response["expires_in"])
24 device_code = response["device_code"]
25 user_code = response["user_code"]
26 auth_url = response["direct_verification_url"]
27
28 qr_code = make_qrcode(auth_url)
29 copy2clip(auth_url)
30
31 progress_dialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH)
32 progress_dialog.setup(
33 translation(90594),
34 qr_code,
35 auth_url,
36 user_code,
37 DebridType.RD,
38 )
39 progress_dialog.show_dialog()
40
41 start_time = time()
42 while time() - start_time < expires_in:
43 ksleep(1000 * sleep_interval)
44 if progress_dialog.iscanceled:
45 progress_dialog.close_dialog()
46 return
47 try:
48 auth_response = client.authorize(device_code)
49 except Exception:
50 continue
51 try:
52 if "token" in auth_response:
53 client.token = auth_response["token"]
54 set_setting("real_debrid_token", client.token)
55 set_setting("real_debid_authorized", "true")
56
57 client.initialize_headers()
58
59 set_setting("real_debrid_user", client.get_user()["username"])
60 progress_dialog.update_progress(100, translation(90545))
61 progress_dialog.close_dialog()
62 return
63
64 elapsed = time() - start_time
65 percent = int((elapsed / expires_in) * 100)
66 progress_dialog.update_progress(percent)
67 except Exception as error:
68 progress_dialog.close_dialog()
69 dialog_ok(translation(90548), translation(90547) % error)
70 return
71
72
73def run_alldebrid_auth(client):
74 client.token = ""
75 client.initialize_headers()
76
77 response = client.get_ping()
78 result = response["data"]
79
80 expires_in = int(result["expires_in"])
81 sleep_interval = 5
82 user_code = result["pin"]
83 check_id = result["check"]
84 user_url = result["user_url"]
85
86 qr_code = make_qrcode(user_url)
87 copy2clip(user_url)
88
89 progress_dialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH)
90 progress_dialog.setup(
91 translation(90604) % DebridType.AD,
92 qr_code,
93 user_url,
94 user_code,
95 DebridType.AD,
96 )
97 progress_dialog.show_dialog()
98
99 start_time = time()
100 while time() - start_time < expires_in and not client.token:
101 ksleep(1000 * sleep_interval)
102 if progress_dialog.iscanceled:
103 progress_dialog.close_dialog()
104 return
105 response = client.poll_auth(check_id, user_code)
106 if not response["activated"]:
107 elapsed = time() - start_time
108 percent = int((elapsed / expires_in) * 100)
109 progress_dialog.update_progress(percent)
110 continue
111 try:
112 if "apikey" in response:
113 client.token = response.get("apikey", "")
114 set_setting("alldebrid_token", client.token)
115 set_setting("alldebrid_authorized", "true")
116
117 client.initialize_headers()
118
119 user = client.get_user_info()
120 set_setting("alldebrid_user", str(user["user"]["username"]))
121
122 progress_dialog.update_progress(100, translation(90545))
123 progress_dialog.close_dialog()
124 return
125
126 elapsed = time() - start_time
127 percent = int((elapsed / expires_in) * 100)
128 progress_dialog.update_progress(percent)
129 except Exception as error:
130 progress_dialog.close_dialog()
131 dialog_ok(translation(90548), translation(90547) % error)
132 return
133
134
135def run_premiumize_auth(client):
136 response = client.get_device_code()
137 user_code = response["user_code"]
138 copy2clip(user_code)
139 content = "%s[CR]%s[CR]%s" % (
140 translation(90540),
141 translation(90541) % response.get("verification_uri"),
142 translation(90542) % user_code,
143 )
144 progressDialog.create(translation(90543))
145 progressDialog.update(-1, content)
146
147 device_code = response["device_code"]
148 expires_in = int(response["expires_in"])
149 sleep_interval = int(response["interval"])
150 start_time = time()
151 time_passed = 0
152
153 while not progressDialog.iscanceled() and time_passed < expires_in:
154 ksleep(1000 * sleep_interval)
155 auth_response = client.authorize(device_code)
156 if "error" in auth_response:
157 time_passed = time() - start_time
158 progress = int(100 * time_passed / float(expires_in))
159 progressDialog.update(progress, content)
160 continue
161 try:
162 progressDialog.close()
163 client.token = str(auth_response["access_token"])
164 set_setting("premiumize_token", client.token)
165 set_setting("premiumize_authorized", "true")
166
167 client.initialize_headers()
168 try:
169 account_info = client.get_account_info()
170 if account_info and "customer_id" in account_info:
171 customer_id = str(account_info.get("customer_id", ""))
172 set_setting("premiumize_user", customer_id)
173 except Exception:
174 pass
175
176 dialog_ok(translation(90544), translation(90545))
177 return
178 except Exception as error:
179 dialog_ok(translation(90546), translation(90547) % error)
180 break
181
182 try:
183 progressDialog.close()
184 except Exception:
185 pass
186
187
188def run_torbox_auth(client):
189 response = client.get_device_code()
190 if not response or not response.get("success"):
191 return
192
193 data = response.get("data", {})
194 sleep_interval = int(data.get("interval", 5))
195 expires_in = int(data.get("expires_in", 600))
196 device_code = data.get("device_code")
197 user_code = data.get("code")
198 auth_url = data.get("verification_url")
199 friendly_url = data.get("friendly_verification_url") or auth_url
200
201 qr_code = make_qrcode(auth_url)
202 copy2clip(auth_url)
203
204 progress_dialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH)
205 progress_dialog.setup(
206 translation(90595),
207 qr_code,
208 friendly_url,
209 user_code,
210 DebridType.TB,
211 )
212 progress_dialog.show_dialog()
213
214 start_time = time()
215 while time() - start_time < expires_in:
216 ksleep(1000 * sleep_interval)
217 if progress_dialog.iscanceled:
218 progress_dialog.close_dialog()
219 return
220 try:
221 auth_response = client.authorize(device_code)
222 except Exception:
223 continue
224 try:
225 if "token" in auth_response:
226 client.token = auth_response["token"]
227 set_setting("torbox_token", client.token)
228 set_setting("torbox_enabled", "true")
229
230 client.initialize_headers()
231
232 user_data = client.get_user()
233 if user_data.get("success"):
234 set_setting(
235 "torbox_user",
236 user_data.get("data", {}).get("email", ""),
237 )
238
239 progress_dialog.update_progress(100, translation(90545))
240 progress_dialog.close_dialog()
241 dialog_ok(translation(90544), translation(90549))
242 return
243
244 elapsed = time() - start_time
245 percent = int((elapsed / expires_in) * 100)
246 progress_dialog.update_progress(percent)
247 except Exception as error:
248 progress_dialog.close_dialog()
249 dialog_ok(translation(90548), translation(90547) % error)
250 return
251
252
253def run_debrider_auth(client):
254 response = client.get_device_code()
255 if not response:
256 return
257
258 interval = int(response["interval"])
259 expires_in = int(response["expires_in"])
260 device_code = response["device_code"]
261 user_code = response["user_code"]
262 auth_url = response["verification_url"]
263 qr_code = make_qrcode(auth_url)
264 copy2clip(auth_url)
265
266 progress_dialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH)
267 progress_dialog.setup(translation(90596), qr_code, auth_url, user_code, DebridType.DB)
268 progress_dialog.show_dialog()
269
270 start_time = time()
271 while time() - start_time < expires_in:
272 if progress_dialog.iscanceled:
273 progress_dialog.close_dialog()
274 return
275 elapsed = time() - start_time
276 percent = int((elapsed / expires_in) * 100)
277 progress_dialog.update_progress(percent)
278 try:
279 auth_response = client.get_device_auth_status(device_code)
280 if "apikey" in auth_response:
281 client.token = auth_response["apikey"]
282 progress_dialog.close_dialog()
283 set_setting("debrider_token", client.token)
284 set_setting("debrider_authorized", "true")
285 client.initialize_headers()
286 dialog_ok(translation(90544), translation(90545))
287 return
288 ksleep(1000 * interval)
289 except Exception as error:
290 progress_dialog.close_dialog()
291 dialog_ok(translation(90546), translation(90547) % error)
292 return
plugin.video.jacktook/lib/services/debrid/download.py +118 -0
……@@ -0,0 +1,118 @@
1from lib.utils.general.utils import supported_video_extensions
2from lib.utils.kodi.utils import dialogyesno, notification, sleep as ksleep, translation
3from xbmcgui import DialogProgress
4
5
6DEBRID_ERROR_STATUS = ("magnet_error", "error", "virus", "dead")
7
8
9def run_realdebrid_download(client, magnet_url, pack=False):
10 interval = 5
11 cancelled = False
12 response = client.add_magnet_link(magnet_url)
13 if not response:
14 return
15
16 torrent_id = response["id"]
17 progress_dialog = DialogProgress()
18 torrent_info = client.get_torrent_info(torrent_id)
19 if not torrent_info:
20 return
21
22 status = torrent_info["status"]
23 if status == "magnet_conversion":
24 msg = translation(90638) % torrent_info["filename"]
25 progress_timeout = 100
26 progress_dialog.create(translation(90565))
27 while status == "magnet_conversion" and progress_timeout > 0:
28 progress_dialog.update(progress_timeout, msg)
29 if progress_dialog.iscanceled():
30 cancelled = True
31 break
32 progress_timeout -= interval
33 ksleep(1000 * interval)
34 torrent_info = client.get_torrent_info(torrent_id)
35 status = torrent_info["status"]
36 if any(error in status for error in DEBRID_ERROR_STATUS):
37 notification(translation(90639))
38 break
39 elif status == "downloaded":
40 notification(translation(90640))
41 return
42 elif status == "waiting_files_selection":
43 cancelled = _handle_waiting_file_selection(
44 client, torrent_id, torrent_info, progress_dialog, interval, pack
45 )
46
47 try:
48 progress_dialog.close()
49 except Exception:
50 pass
51
52 ksleep(500)
53 if cancelled:
54 response = dialogyesno(
55 translation(32029), translation(90564)
56 )
57 if response:
58 notification(translation(90641))
59 else:
60 client.delete_torrent(torrent_id)
61
62
63def _handle_waiting_file_selection(
64 client, torrent_id, torrent_info, progress_dialog, interval, pack
65):
66 files = torrent_info["files"]
67 extensions = supported_video_extensions()[:-1]
68 items = [
69 item
70 for item in files
71 for extension in extensions
72 if item["path"].lower().endswith(extension)
73 ]
74 try:
75 video = max(items, key=lambda item: item["bytes"])
76 file_id = video["id"]
77 except ValueError as error:
78 notification(error)
79 return False
80
81 client.select_files(torrent_id, str(file_id))
82 ksleep(2000)
83 torrent_info = client.get_torrent_info(torrent_id)
84 if not torrent_info:
85 return False
86
87 status = torrent_info["status"]
88 if status == "downloaded":
89 notification(translation(90642))
90 return False
91
92 file_size = round(float(video["bytes"]) / (1000**3), 2)
93 msg = translation(90643) % torrent_info["filename"]
94 progress_dialog.create(translation(90565))
95 progress_dialog.update(1, msg)
96 while status != "downloaded":
97 ksleep(1000 * interval)
98 torrent_info = client.get_torrent_info(torrent_id)
99 status = torrent_info["status"]
100 if status == "downloading":
101 msg2 = translation(90644) % (
102 file_size,
103 round(float(torrent_info["speed"]) / (1000**2), 2),
104 torrent_info["seeders"],
105 torrent_info["progress"],
106 )
107 else:
108 msg2 = status
109 progress_dialog.update(int(float(torrent_info["progress"])), msg + msg2)
110 try:
111 if progress_dialog.iscanceled():
112 return True
113 except Exception:
114 pass
115 if any(error in status for error in DEBRID_ERROR_STATUS):
116 notification(translation(90639))
117 break
118 return False
plugin.video.jacktook/lib/services/migrations.py +39 -0
……@@ -0,0 +1,39 @@
1from lib.utils.kodi.utils import (
2 get_setting,
3 set_setting,
4 kodilog,
5)
6
7
8def _migrate_trakt_credentials():
9 DEFAULT_CLIENT = (
10 "687c707dc7073be777a6dd6869ffd9034109d75dcda6599a7273f49a68266ff8"
11 )
12 DEFAULT_SECRET = (
13 "3191ae63a3cf60d767b1d6fc1e39b1dedc6f3346b826d62901f9f2c3c9925e26"
14 )
15
16 current_client = get_setting("trakt_client", "")
17 current_secret = get_setting("trakt_secret", "")
18
19 client_needs_reset = current_client != DEFAULT_CLIENT or current_client == ""
20 secret_needs_reset = current_secret != DEFAULT_SECRET or current_secret == ""
21
22 if not (client_needs_reset or secret_needs_reset):
23 kodilog("Trakt credentials already customized by user, skipping migration")
24 return
25
26 kodilog("Migrating Trakt credentials to force re-authentication")
27
28 if client_needs_reset:
29 set_setting("trakt_client", "")
30
31 if secret_needs_reset:
32 set_setting("trakt_secret", "")
33
34 set_setting("trakt_client", DEFAULT_CLIENT)
35 set_setting("trakt_secret", DEFAULT_SECRET)
36
37def run_migrations():
38 _migrate_trakt_credentials()
39 kodilog(f"Trakt migration complete")
plugin.video.jacktook/lib/services/preloader.py +80 -0
……@@ -0,0 +1,80 @@
1from lib.clients.tmdb.utils.utils import tmdb_get
2from threading import Thread
3from concurrent.futures import ThreadPoolExecutor
4from lib.utils.kodi.utils import kodilog, get_setting
5from lib.api.tmdbv3api.tmdb import TMDb
6
7
8class StartupPreloader:
9 def run(self):
10 Thread(target=self._preload).start()
11
12 def _preload(self):
13 tmdb = TMDb()
14 tmdb.api_key = get_setting("tmdb_api_key", "b70756b7083d9ee60f849d82d94a0d80")
15
16 # Helper to fetch seasons for a list of shows
17 def _deep_preload_tv(results):
18 try:
19 if not results:
20 return
21
22 # Handle list vs dict (TMDB response structure variants)
23 items = []
24 if hasattr(results, 'results'):
25 items = results.results
26 elif isinstance(results, dict):
27 items = results.get('results', [])
28 else:
29 try:
30 items = list(results)
31 except:
32 pass
33
34 if not isinstance(items, list):
35 items = list(items)
36
37 top_items = items[:5]
38
39 for item in top_items:
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
46 # 1. Fetch Show Details
47 details = tmdb_get("tv_details", tmdb_id)
48 if not details:
49 continue
50
51 # 2. Fetch Season 1
52 tmdb_get("season_details", {'id': tmdb_id, 'season': 1})
53
54 # 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)
56 if num_seasons > 1:
57 tmdb_get("season_details", {'id': tmdb_id, 'season': num_seasons})
58 except Exception as e:
59 pass
60 except Exception as e:
61 import traceback
62 kodilog(f"[StartupPreloader] CRASH in _deep_preload_tv: {e}")
63 kodilog(traceback.format_exc())
64
65 with ThreadPoolExecutor(max_workers=5) as executor:
66 # 1. Fetch main lists in parallel
67 executor.submit(tmdb_get, "trending_movie", 1)
68 f_trend_tv = executor.submit(tmdb_get, "trending_tv", 1)
69 executor.submit(tmdb_get, "popular_movie", 1)
70 f_pop_tv = executor.submit(tmdb_get, "popular_shows", 1)
71
72 # Get results (this waits for the specific future, but others run in parallel)
73 # We treat TV lists specially to trigger deep preload
74 trending_tv = f_trend_tv.result()
75 popular_tv = f_pop_tv.result()
76
77 # Submit deep preload tasks
78 # 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)
plugin.video.jacktook/lib/services/subtitle_server.py +436 -0
……@@ -0,0 +1,436 @@
1# -*- coding: utf-8 -*-
2"""
3Local web server for uploading subtitle files from a browser.
4
5Serves a web UI with drag & drop file upload and exposes an API endpoint
6to receive the subtitle file via HTTP POST.
7"""
8import os
9import socket
10import threading
11import json
12import uuid
13from datetime import datetime
14from http.server import HTTPServer, BaseHTTPRequestHandler
15
16from lib.utils.kodi.utils import ADDON_PROFILE_PATH, kodilog
17
18
19_WEB_DIR = os.path.join(
20 os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
21 "resources",
22 "web",
23)
24
25# Supported subtitle extensions
26SUBTITLE_EXTENSIONS = {'.srt', '.ass', '.ssa', '.sub', '.vtt', '.txt'}
27
28
29def _ensure_upload_dir():
30 """Create upload directory if it doesn't exist."""
31 upload_dir = os.path.join(ADDON_PROFILE_PATH, "Subtitles", "uploads")
32 os.makedirs(upload_dir, exist_ok=True)
33 return upload_dir
34
35
36def _is_valid_extension(filename):
37 """Check if file has a supported subtitle extension."""
38 ext = os.path.splitext(filename)[1].lower()
39 return ext in SUBTITLE_EXTENSIONS
40
41
42class SubtitleUploadHandler(BaseHTTPRequestHandler):
43 """Handles HTTP requests for subtitle upload."""
44
45 @property
46 def wrapper_server(self):
47 """Get the SubtitleUploadServer wrapper instance."""
48 # The wrapper is stored as an attribute on the HTTPServer
49 return getattr(self.server, 'wrapper_server', None)
50
51 def log_message(self, format, *args):
52 kodilog(f"[SubtitleServer] {format % args}")
53
54 def _send_json(self, data, status=200):
55 body = json.dumps(data).encode("utf-8")
56 self.send_response(status)
57 self.send_header("Content-Type", "application/json")
58 self.send_header("Content-Length", str(len(body)))
59 self.send_header("Access-Control-Allow-Origin", "*")
60 self.end_headers()
61 self.wfile.write(body)
62
63 def _send_html(self, html_content):
64 body = html_content.encode("utf-8")
65 self.send_response(200)
66 self.send_header("Content-Type", "text/html; charset=utf-8")
67 self.send_header("Content-Length", str(len(body)))
68 self.end_headers()
69 self.wfile.write(body)
70
71 def _send_error_page(self, message):
72 safe_message = self._html_escape(message)
73 error_html = f"""<!DOCTYPE html>
74<html>
75<head>
76 <meta charset="UTF-8">
77 <meta name="viewport" content="width=device-width, initial-scale=1.0">
78 <title>Upload Error</title>
79 <style>
80 body {{ font-family: sans-serif; background: #0d1117; color: #e6edf3;
81 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 padding: 30px; text-align: center; max-width: 400px; }}
84 h1 {{ color: #f85149; margin: 0 0 15px 0; }}
85 p {{ color: #8b949e; margin: 0; }}
86 .back {{ margin-top: 20px; color: #58a6ff; text-decoration: none; }}
87 </style>
88</head>
89<body>
90 <div class="error-box">
91 <h1>Upload Failed</h1>
92 <p>{safe_message}</p>
93 <a href="/" class="back">&larr; Go back</a>
94 </div>
95</body>
96</html>"""
97 self._send_html(error_html)
98
99 @staticmethod
100 def _html_escape(text):
101 """Escape HTML special characters to prevent XSS."""
102 return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;").replace("'", "&#39;")
103
104 def _send_success_page(self, filename):
105 safe_filename = self._html_escape(filename)
106 success_html = f"""<!DOCTYPE html>
107<html>
108<head>
109 <meta charset="UTF-8">
110 <meta name="viewport" content="width=device-width, initial-scale=1.0">
111 <title>Upload Successful</title>
112 <style>
113 body {{ font-family: sans-serif; background: #0d1117; color: #e6edf3;
114 display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }}
115 .success-box {{ background: #161b22; border: 1px solid #3fb950; border-radius: 12px;
116 padding: 30px; text-align: center; max-width: 400px; }}
117 h1 {{ color: #3fb950; margin: 0 0 15px 0; }}
118 p {{ color: #8b949e; margin: 0; }}
119 .filename {{ color: #e6edf3; font-weight: bold; margin-top: 10px; }}
120 .close {{ margin-top: 20px; color: #8b949e; font-size: 0.9rem; }}
121 </style>
122</head>
123<body>
124 <div class="success-box">
125 <h1>Upload Successful!</h1>
126 <p>File received:</p>
127 <p class="filename">{safe_filename}</p>
128 <p class="close">You can close this tab now.</p>
129 </div>
130</body>
131</html>"""
132 self._send_html(success_html)
133
134 def do_GET(self):
135 if self.path == "/" or self.path == "":
136 self._serve_index()
137 elif self.path == "/status":
138 self._serve_status()
139 else:
140 self.send_error(404)
141
142 def do_POST(self):
143 if self.path == "/upload":
144 self._handle_upload()
145 else:
146 self.send_error(404)
147
148 def do_OPTIONS(self):
149 self.send_response(200)
150 self.send_header("Access-Control-Allow-Origin", "*")
151 self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
152 self.send_header("Access-Control-Allow-Headers", "Content-Type")
153 self.end_headers()
154
155 def _serve_index(self):
156 index_path = os.path.join(_WEB_DIR, "upload.html")
157 try:
158 with open(index_path, "r", encoding="utf-8") as f:
159 self._send_html(f.read())
160 except FileNotFoundError:
161 self.send_error(500, "Upload page not found")
162
163 def _serve_status(self):
164 """Return current upload status."""
165 wrapper = self.wrapper_server
166 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 })
171
172 def _parse_multipart(self):
173 """
174 Parse multipart/form-data without cgi module.
175 Returns (filename, file_content) tuple or (None, error_message).
176 """
177 content_type = self.headers.get('Content-Type', '')
178
179 if not content_type.startswith('multipart/form-data'):
180 return None, "Invalid content type"
181
182 # Extract boundary from content-type
183 boundary_marker = 'boundary='
184 boundary_idx = content_type.find(boundary_marker)
185 if boundary_idx == -1:
186 return None, "No boundary in content type"
187
188 boundary = content_type[boundary_idx + len(boundary_marker):]
189 boundary = boundary.strip('"')
190 boundary_bytes = ('--' + boundary).encode('utf-8')
191 end_boundary_bytes = ('--' + boundary + '--').encode('utf-8')
192
193 content_length = int(self.headers.get('Content-Length', 0))
194 if content_length == 0:
195 return None, "No data received"
196
197 # Read raw POST data
198 post_data = self.rfile.read(content_length)
199
200 # Find first boundary
201 start_idx = post_data.find(boundary_bytes)
202 if start_idx == -1:
203 return None, "Invalid multipart data"
204
205 # Parse each part
206 pos = start_idx + len(boundary_bytes)
207
208 while pos < len(post_data):
209 # Check for end boundary
210 if post_data[pos:pos + 2] == b'--':
211 break
212
213 # Skip CRLF after boundary
214 if post_data[pos:pos + 2] == b'\r\n':
215 pos += 2
216 elif post_data[pos:pos + 1] == b'\n':
217 pos += 1
218
219 # Find headers end (double CRLF)
220 header_end = post_data.find(b'\r\n\r\n', pos)
221 if header_end == -1:
222 header_end = post_data.find(b'\n\n', pos)
223 header_end_len = 2
224 if header_end == -1:
225 continue
226 else:
227 header_end_len = 4
228
229 headers = post_data[pos:header_end].decode('utf-8', errors='replace')
230 pos = header_end + header_end_len
231
232 # Check if this is a file field
233 if 'Content-Disposition' not in headers:
234 continue
235
236 # Find next boundary
237 next_boundary = post_data.find(boundary_bytes, pos)
238 if next_boundary == -1:
239 next_boundary = post_data.find(end_boundary_bytes, pos)
240 if next_boundary == -1:
241 break
242
243 # Extract field data (remove trailing CRLF)
244 field_data = post_data[pos:next_boundary]
245 if field_data.endswith(b'\r\n'):
246 field_data = field_data[:-2]
247 elif field_data.endswith(b'\n'):
248 field_data = field_data[:-1]
249
250 # Parse Content-Disposition for filename
251 if 'filename="' in headers or "filename='" in headers:
252 # Extract filename
253 filename_start = headers.find('filename="')
254 if filename_start == -1:
255 filename_start = headers.find("filename='")
256 quote_char = "'"
257 else:
258 quote_char = '"'
259
260 if filename_start != -1:
261 filename_start += 10 # len('filename="')
262 filename_end = headers.find(quote_char, filename_start)
263 if filename_end != -1:
264 filename = headers[filename_start:filename_end]
265 return filename, field_data
266
267 pos = next_boundary
268
269 return None, "No file found in upload"
270
271 def _handle_upload(self):
272 """Handle multipart form data upload."""
273 try:
274 filename, file_content = self._parse_multipart()
275
276 if filename is None:
277 error_msg = file_content
278 kodilog(f"[SubtitleServer] Upload error: {error_msg}")
279 self._send_json({"error": error_msg}, 400)
280 return
281
282 filename = os.path.basename(filename)
283
284 # Validate extension
285 if not _is_valid_extension(filename):
286 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)
290 return
291
292 # Save the file with unique name to avoid collisions
293 upload_dir = _ensure_upload_dir()
294 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
295 unique_id = str(uuid.uuid4())[:8]
296 safe_filename = f"{timestamp}_{unique_id}_{filename}"
297 file_path = os.path.join(upload_dir, safe_filename)
298
299 with open(file_path, 'wb') as f:
300 f.write(file_content if isinstance(file_content, bytes) else file_content.encode('utf-8'))
301
302 kodilog(f"[SubtitleServer] File uploaded: {file_path}")
303
304 # Notify server instance
305 wrapper = self.wrapper_server
306 if wrapper:
307 wrapper.set_uploaded_file(file_path)
308 else:
309 kodilog("[SubtitleServer] Warning: wrapper_server not available")
310
311 # 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 })
319 else:
320 self._send_success_page(filename)
321
322 except Exception as e:
323 kodilog(f"[SubtitleServer] Upload error: {e}")
324 import traceback
325 kodilog(traceback.format_exc())
326 self._send_json({"error": str(e)}, 500)
327
328
329class SubtitleUploadServer:
330 """Threaded HTTP server for subtitle uploads."""
331
332 def __init__(self, port=8082):
333 self.port = port
334 self._server = None
335 self._thread = None
336 self._uploaded_file = None
337 self._upload_event = threading.Event()
338
339 def start(self):
340 if self._server:
341 return
342 try:
343 self._server = HTTPServer(("0.0.0.0", self.port), SubtitleUploadHandler)
344 # Store reference to this wrapper so handlers can access it
345 self._server.wrapper_server = self
346 self._thread = threading.Thread(
347 target=self._server.serve_forever, daemon=True
348 )
349 self._thread.start()
350 kodilog(f"[SubtitleServer] Started on port {self.port}")
351 except OSError:
352 fallback = find_available_port(preferred=self.port + 1)
353 self.port = fallback
354 try:
355 self._server = HTTPServer(("0.0.0.0", self.port), SubtitleUploadHandler)
356 self._server.wrapper_server = self
357 self._thread = threading.Thread(
358 target=self._server.serve_forever, daemon=True
359 )
360 self._thread.start()
361 kodilog(f"[SubtitleServer] Started on fallback port {self.port}")
362 except Exception as e:
363 kodilog(f"[SubtitleServer] Failed to start on fallback port: {e}")
364 self._server = None
365 except Exception as e:
366 kodilog(f"[SubtitleServer] Failed to start: {e}")
367 self._server = None
368
369 def stop(self):
370 if self._server:
371 try:
372 self._server.shutdown()
373 self._server.server_close()
374 except Exception as e:
375 kodilog(f"[SubtitleServer] Error stopping: {e}")
376 self._server = None
377 self._thread = None
378 kodilog("[SubtitleServer] Stopped")
379
380 def set_uploaded_file(self, file_path):
381 """Called by handler when file is uploaded."""
382 self._uploaded_file = file_path
383 self._upload_event.set()
384
385 def get_uploaded_file(self):
386 """Get the uploaded file path (None if not uploaded yet)."""
387 return self._uploaded_file
388
389 def wait_for_upload(self, timeout=None):
390 """Block until a file is uploaded or timeout. Returns True if file uploaded."""
391 return self._upload_event.wait(timeout)
392
393 @property
394 def is_running(self):
395 return self._server is not None
396
397
398def _is_port_available(port):
399 """Check if a port is available for binding."""
400 try:
401 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
402 s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
403 s.bind(("0.0.0.0", port))
404 s.close()
405 return True
406 except OSError:
407 return False
408
409
410def find_available_port(preferred=8082, max_attempts=10):
411 """Find an available port, starting from the preferred one."""
412 for offset in range(max_attempts):
413 port = preferred + offset
414 if _is_port_available(port):
415 return port
416 # Last resort: let the OS assign one
417 try:
418 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
419 s.bind(("0.0.0.0", 0))
420 port = s.getsockname()[1]
421 s.close()
422 return port
423 except OSError:
424 return preferred
425
426
427def get_local_ip():
428 """Get the machine's LAN IP address."""
429 try:
430 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
431 s.connect(("8.8.8.8", 80))
432 ip = s.getsockname()[0]
433 s.close()
434 return ip
435 except Exception:
436 return "127.0.0.1"
plugin.video.jacktook/lib/services/trakt_sync.py +387 -0
……@@ -0,0 +1,387 @@
1from time import monotonic
2
3import xbmc
4from lib.api.trakt.trakt import TraktAPI
5from lib.api.trakt.lists_cache import lists_cache
6from lib.api.trakt.trakt_cache import (
7 clear_trakt_calendar,
8 clear_trakt_favorites,
9 clear_trakt_hidden_data,
10 clear_trakt_list_contents_data,
11 clear_trakt_list_data,
12 clear_trakt_watchlist,
13 reset_activity,
14 trakt_watched_cache,
15)
16from lib.utils.kodi.utils import get_property_no_fallback, kodilog, get_setting
17
18
19PAUSE_SERVICES_PROP = "jacktook.pause_services"
20DEFAULT_SYNC_INTERVAL_MINUTES = 15
21WAIT_STEP_SECONDS = 5
22
23class TraktSyncService:
24 def __init__(self, api=None, monitor=None):
25 self.api = api or TraktAPI()
26 self.monitor = monitor or xbmc.Monitor()
27 self._cycle_context = None
28
29 def _format_buckets(self, buckets):
30 if not buckets:
31 return "none"
32 return ",".join(sorted(buckets))
33
34 def _get_cycle_context(self, force=False):
35 if self._cycle_context:
36 return self._cycle_context
37 return "startup" if force else "periodic"
38
39 def _log_sync_summary(
40 self,
41 cycle_context,
42 force,
43 outcome,
44 detected_buckets,
45 applied_buckets,
46 forced_defaults,
47 started_at,
48 ):
49 duration_ms = int((monotonic() - started_at) * 1000)
50 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 )
61 )
62
63 def _log_sync_failure(self, cycle_context, force, error):
64 kodilog(
65 "Trakt sync[%s]: failed force=%s error=%s"
66 % (cycle_context, force, error),
67 level=xbmc.LOGERROR,
68 )
69
70 def run(self):
71 if not self._is_trakt_available():
72 kodilog("Trakt sync[startup]: skipped because Trakt is disabled or unauthenticated")
73 return
74
75 kodilog(
76 "Starting Trakt Sync service (interval=%s minutes)"
77 % (self._get_sync_interval_seconds() // 60)
78 )
79
80 try:
81 self._cycle_context = "startup"
82 kodilog("Trakt sync[startup]: beginning initial sync (force=True)")
83 try:
84 self.sync_activities(force=True)
85 except Exception as e:
86 self._log_sync_failure("startup", True, e)
87 return
88
89 while not self.monitor.abortRequested():
90 if self._wait_for_next_cycle():
91 return
92 if not self._is_trakt_available():
93 kodilog("Trakt sync[periodic]: skipped cycle because Trakt is unavailable")
94 continue
95 if self._services_paused():
96 kodilog("Trakt sync[periodic]: skipped cycle because services are paused")
97 continue
98 self._cycle_context = "periodic"
99 kodilog("Trakt sync[periodic]: beginning cycle")
100 try:
101 self.sync_activities()
102 except Exception as e:
103 self._log_sync_failure("periodic", False, e)
104 return
105 except Exception as e:
106 kodilog(f"Error during Trakt Sync: {e}", level=xbmc.LOGERROR)
107 finally:
108 self._cycle_context = None
109
110 def _is_trakt_available(self):
111 return get_setting("trakt_enabled") and get_setting("is_trakt_auth")
112
113 def _services_paused(self):
114 return get_property_no_fallback(PAUSE_SERVICES_PROP) == "true"
115
116 def _get_sync_interval_seconds(self):
117 try:
118 interval = int(get_setting("trakt_sync_interval", DEFAULT_SYNC_INTERVAL_MINUTES))
119 except (TypeError, ValueError):
120 interval = DEFAULT_SYNC_INTERVAL_MINUTES
121 return max(interval, 1) * 60
122
123 def _wait_for_next_cycle(self):
124 remaining = self._get_sync_interval_seconds()
125 while remaining > 0:
126 if self.monitor.waitForAbort(min(WAIT_STEP_SECONDS, remaining)):
127 return True
128 remaining -= WAIT_STEP_SECONDS
129 return False
130
131 def sync_activities(self, force=False):
132 cycle_context = self._get_cycle_context(force=force)
133 started_at = monotonic()
134 forced_defaults = False
135 detected_buckets = set()
136 applied_buckets = set()
137
138 try:
139 latest_activities = self.api.sync.get_last_activities()
140 if not latest_activities:
141 self._log_sync_summary(
142 cycle_context,
143 force,
144 "no_data",
145 detected_buckets,
146 applied_buckets,
147 forced_defaults,
148 started_at,
149 )
150 return set()
151
152 previous_activities = reset_activity(latest_activities)
153 detected_buckets = self._get_changed_buckets(previous_activities, latest_activities)
154 applied_buckets = set(detected_buckets)
155 if force and not applied_buckets:
156 applied_buckets = {
157 "watched_movies",
158 "watched_shows",
159 "progress_movies",
160 "progress_episodes",
161 }
162 forced_defaults = True
163
164 if not applied_buckets:
165 self._log_sync_summary(
166 cycle_context,
167 force,
168 "no_changes",
169 detected_buckets,
170 applied_buckets,
171 forced_defaults,
172 started_at,
173 )
174 return applied_buckets
175
176 kodilog(
177 "Trakt sync: changed buckets = %s"
178 % ", ".join(sorted(applied_buckets))
179 )
180 self._apply_activity_changes(applied_buckets)
181 self._log_sync_summary(
182 cycle_context,
183 force,
184 "applied_changes",
185 detected_buckets,
186 applied_buckets,
187 forced_defaults,
188 started_at,
189 )
190 return applied_buckets
191 except Exception as e:
192 self._log_sync_failure(cycle_context, force, e)
193 raise
194
195 def _activity_changed(self, previous, latest, *path):
196 prev_value = previous
197 latest_value = latest
198 for key in path:
199 if not isinstance(prev_value, dict) or not isinstance(latest_value, dict):
200 return False
201 prev_value = prev_value.get(key)
202 latest_value = latest_value.get(key)
203 return bool(latest_value) and prev_value != latest_value
204
205 def _get_changed_buckets(self, previous, latest):
206 changes = set()
207
208 if self._activity_changed(previous, latest, "movies", "watched_at"):
209 changes.add("watched_movies")
210 if self._activity_changed(previous, latest, "episodes", "watched_at"):
211 changes.add("watched_shows")
212 if self._activity_changed(previous, latest, "movies", "paused_at"):
213 changes.add("progress_movies")
214 if self._activity_changed(previous, latest, "episodes", "paused_at"):
215 changes.add("progress_episodes")
216 if self._activity_changed(previous, latest, "movies", "collected_at"):
217 changes.add("collection_movies")
218 if self._activity_changed(previous, latest, "episodes", "collected_at"):
219 changes.add("collection_shows")
220 if self._activity_changed(previous, latest, "movies", "watchlisted_at"):
221 changes.add("watchlist_movies")
222 if self._activity_changed(previous, latest, "shows", "watchlisted_at"):
223 changes.add("watchlist_shows")
224 if self._activity_changed(previous, latest, "movies", "favorited_at"):
225 changes.add("favorites_movies")
226 if self._activity_changed(previous, latest, "shows", "favorited_at"):
227 changes.add("favorites_shows")
228 if self._activity_changed(previous, latest, "movies", "recommendations_at"):
229 changes.add("recommendations_movies")
230 if self._activity_changed(previous, latest, "shows", "recommendations_at"):
231 changes.add("recommendations_shows")
232 if self._activity_changed(previous, latest, "lists", "liked_at") or self._activity_changed(
233 previous, latest, "lists", "updated_at"
234 ):
235 changes.add("lists")
236 if self._activity_changed(previous, latest, "movies", "hidden_at"):
237 changes.add("hidden_movies")
238 if self._activity_changed(previous, latest, "shows", "hidden_at"):
239 changes.add("hidden_shows")
240
241 return changes
242
243 def _apply_activity_changes(self, changes):
244 if "watched_movies" in changes:
245 self.sync_watched_movies()
246 if "watched_shows" in changes:
247 self.sync_watched_shows()
248 if "progress_movies" in changes:
249 self.sync_movie_progress()
250 if "progress_episodes" in changes:
251 self.sync_episode_progress()
252
253 self.invalidate_cached_buckets(changes)
254
255 def sync_watched_movies(self):
256 movies = self.api.movies.get_watched_movies()
257 if not movies:
258 trakt_watched_cache.set_bulk_movie_watched([])
259 return
260
261 insert_list = []
262 for item in movies:
263 tmdb_id = item["movie"]["ids"].get("tmdb")
264 if not tmdb_id:
265 continue
266 insert_list.append(
267 (
268 "movie",
269 str(tmdb_id),
270 None,
271 None,
272 item["last_watched_at"],
273 item["movie"]["title"],
274 )
275 )
276
277 trakt_watched_cache.set_bulk_movie_watched(insert_list)
278 kodilog(f"Synced {len(insert_list)} watched movies from Trakt")
279
280 def sync_watched_shows(self):
281 shows = self.api.tv.get_watched_shows()
282 if not shows:
283 trakt_watched_cache.set_bulk_tvshow_watched([])
284 return
285
286 insert_list = []
287 for item in shows:
288 show_tmdb = item["show"]["ids"].get("tmdb")
289 if not show_tmdb:
290 continue
291 show_title = item["show"]["title"]
292 for season in item.get("seasons", []):
293 season_num = season["number"]
294 for episode in season.get("episodes", []):
295 insert_list.append(
296 (
297 "episode",
298 str(show_tmdb),
299 season_num,
300 episode["number"],
301 episode["last_watched_at"],
302 show_title,
303 )
304 )
305
306 trakt_watched_cache.set_bulk_tvshow_watched(insert_list)
307 kodilog(f"Synced {len(insert_list)} watched episodes from Trakt")
308
309 def sync_movie_progress(self):
310 paused_movies = self.api.scrobble.trakt_get_playback_progress("movies")
311 if not paused_movies:
312 trakt_watched_cache.set_bulk_movie_progress([])
313 return
314
315 insert_list = []
316 for item in paused_movies:
317 tmdb_id = item["movie"]["ids"].get("tmdb")
318 if not tmdb_id:
319 continue
320 insert_list.append(
321 (
322 "movie",
323 str(tmdb_id),
324 None,
325 None,
326 str(item.get("progress", 0)),
327 "0",
328 item.get("paused_at"),
329 item.get("id"),
330 item["movie"]["title"],
331 )
332 )
333
334 trakt_watched_cache.set_bulk_movie_progress(insert_list)
335 kodilog(f"Synced {len(insert_list)} movie progress items from Trakt")
336
337 def sync_episode_progress(self):
338 paused_episodes = self.api.scrobble.trakt_get_playback_progress("episodes")
339 if not paused_episodes:
340 trakt_watched_cache.set_bulk_tvshow_progress([])
341 return
342
343 insert_list = []
344 for item in paused_episodes:
345 show_tmdb = item["show"]["ids"].get("tmdb")
346 if not show_tmdb:
347 continue
348 insert_list.append(
349 (
350 "episode",
351 str(show_tmdb),
352 item["episode"]["season"],
353 item["episode"]["number"],
354 str(item.get("progress", 0)),
355 "0",
356 item.get("paused_at"),
357 item.get("id"),
358 f"{item['show']['title']} - {item['episode']['title']}",
359 )
360 )
361
362 trakt_watched_cache.set_bulk_tvshow_progress(insert_list)
363 kodilog(f"Synced {len(insert_list)} episode progress items from Trakt")
364
365 def invalidate_cached_buckets(self, changes):
366 if "collection_movies" in changes:
367 lists_cache.delete_prefix("trakt_movies_collection_")
368 if "collection_shows" in changes:
369 lists_cache.delete_prefix("trakt_tv_collection_")
370 if "watchlist_movies" in changes or "watchlist_shows" in changes:
371 clear_trakt_watchlist()
372 clear_trakt_calendar()
373 if "favorites_movies" in changes or "favorites_shows" in changes:
374 clear_trakt_favorites()
375 if "recommendations_movies" in changes:
376 lists_cache.delete_prefix("trakt_recommendations_movies")
377 if "recommendations_shows" in changes:
378 lists_cache.delete_prefix("trakt_recommendations_shows")
379 if "lists" in changes:
380 clear_trakt_list_data("my_lists")
381 clear_trakt_list_data("liked_lists")
382 clear_trakt_list_contents_data("my_lists")
383 clear_trakt_list_contents_data("liked_lists")
384 if "hidden_movies" in changes:
385 clear_trakt_hidden_data("movies")
386 if "hidden_shows" in changes:
387 clear_trakt_hidden_data("shows")
plugin.video.jacktook/lib/services/webserver.py +534 -0
……@@ -0,0 +1,534 @@
1# -*- coding: utf-8 -*-
2"""
3Local web server for managing Stremio addon sources from a phone/browser.
4
5Serves a web UI and exposes small JSON endpoints for CRUD operations
6on the Stremio custom addons stored in the Kodi addon cache.
7"""
8import json
9import os
10import socket
11import threading
12import ipaddress
13from datetime import timedelta
14from http.server import HTTPServer, BaseHTTPRequestHandler
15from urllib.parse import urlsplit, urlunsplit
16
17import requests
18
19from lib.db.cached import cache
20from lib.utils.general.utils import USER_AGENT_HEADER
21from lib.utils.kodi.utils import kodilog
22from lib.clients.stremio.constants import (
23 STREMIO_ADDONS_KEY,
24 STREMIO_ADDONS_CATALOGS_KEY,
25 STREMIO_TV_ADDONS_KEY,
26 STREMIO_USER_ADDONS,
27 encode_selected_ids,
28 decode_selected_ids,
29)
30from lib.api.stremio.addon_manager import build_addon_instance_key
31
32_CACHE_EXPIRY = timedelta(days=365 * 20)
33_WEB_DIR = os.path.join(
34 os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
35 "resources",
36 "web",
37)
38
39
40# ---------------------------------------------------------------------------
41# Sync helpers (no xbmcgui imports — safe for HTTP thread)
42# ---------------------------------------------------------------------------
43
44
45def _get_user_addons():
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 ]
52
53
54def _get_selected_ids():
55 """Return dict of selected addon IDs per category."""
56 result = {}
57 for key, label in [
58 (STREMIO_ADDONS_KEY, "stream"),
59 (STREMIO_ADDONS_CATALOGS_KEY, "catalog"),
60 (STREMIO_TV_ADDONS_KEY, "tv"),
61 ]:
62 result[label] = decode_selected_ids(cache.get(key))
63 return result
64
65
66def _addon_capabilities(manifest):
67 """Determine stream/catalog/tv capabilities from a manifest dict."""
68 def supports_searchable_stream(prefixes):
69 normalized = {str(prefix).rstrip(":") for prefix in (prefixes or []) if prefix}
70 return bool(normalized.intersection({"tt", "tmdb"}))
71
72 resources = manifest.get("resources", [])
73 types = manifest.get("types", [])
74 manifest_prefixes = manifest.get("idPrefixes", [])
75 is_stream = False
76 is_catalog = False
77 is_tv = False
78
79 for res in resources:
80 if isinstance(res, dict):
81 res_name = res.get("name")
82 res_types = res.get("types", types)
83 res_prefixes = res.get("idPrefixes", manifest_prefixes)
84
85 if res_name == "stream":
86 # 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):
90 is_stream = True
91
92 if any(t in res_types for t in ("tv", "channel")):
93 is_tv = True
94
95 if res_name == "catalog":
96 is_catalog = True
97
98 elif isinstance(res, str):
99 if res == "stream":
100 if any(t in types for t in ("movie", "series", "anime")) and supports_searchable_stream(manifest_prefixes):
101 is_stream = True
102 if any(t in types for t in ("tv", "channel")):
103 is_tv = True
104 if res == "catalog":
105 is_catalog = True
106
107 return {"stream": is_stream, "catalog": is_catalog, "tv": is_tv}
108
109
110def _normalize_manifest_url(url):
111 normalized = url.strip()
112 if not normalized:
113 return ""
114
115 if normalized.startswith("stremio://"):
116 normalized = normalized.replace("stremio://", "https://", 1)
117
118 parts = urlsplit(normalized)
119 path = parts.path or ""
120 if not path.endswith("/manifest.json"):
121 if path.endswith("/"):
122 path = f"{path}manifest.json"
123 else:
124 path = f"{path}/manifest.json"
125
126 return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment))
127
128
129def _is_local_or_private_host(url):
130 host = (urlsplit(url).hostname or "").lower()
131 if not host:
132 return False
133 if host == "localhost":
134 return True
135
136 try:
137 ip = ipaddress.ip_address(host)
138 return ip.is_private or ip.is_loopback
139 except ValueError:
140 return False
141
142
143def _fetch_manifest(url, initial_url):
144 def try_fetch(target_url):
145 try:
146 kodilog(f"[WebServer] Fetching Stremio manifest: {target_url}")
147 resp = requests.get(target_url, headers=USER_AGENT_HEADER, timeout=10)
148 resp.raise_for_status()
149 return resp, resp.json(), None
150 except Exception as ex:
151 return None, None, str(ex)
152
153 resp, manifest, error = try_fetch(url)
154
155 if error and url.startswith("https://"):
156 if initial_url.startswith("stremio://") or _is_local_or_private_host(url):
157 http_url = url.replace("https://", "http://", 1)
158 kodilog(f"[WebServer] HTTPS failed, trying HTTP fallback: {http_url}")
159 r2, m2, e2 = try_fetch(http_url)
160 if not e2:
161 return r2, m2, None
162
163 return resp, manifest, error
164
165
166def _sync_add_addon(url):
167 """Fetch manifest from URL, validate, store in cache. Returns (addon_dict, error)."""
168 url = (url or "").strip()
169 if not url:
170 return None, "Empty URL provided."
171
172 initial_url = url
173 url = _normalize_manifest_url(url)
174
175 resp, manifest, error = _fetch_manifest(url, initial_url)
176
177 if error or not resp or not manifest:
178 kodilog(f"[WebServer] Failed to add addon: {error}")
179 return None, f"Failed to fetch manifest: {error or 'unknown error'}"
180
181 addon_key = build_addon_instance_key(
182 {"manifest": manifest, "transportUrl": resp.url}
183 )
184
185 # Check duplicate
186 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 ):
191 return None, "This addon is already added."
192
193 caps = _addon_capabilities(manifest)
194
195 # Auto-select into categories
196 if caps["stream"]:
197 _add_to_selection(STREMIO_ADDONS_KEY, addon_key)
198 if caps["catalog"]:
199 _add_to_selection(STREMIO_ADDONS_CATALOGS_KEY, addon_key)
200 if caps["tv"]:
201 _add_to_selection(STREMIO_TV_ADDONS_KEY, addon_key)
202
203 custom_addon = {
204 "manifest": manifest,
205 "transportUrl": resp.url,
206 "transportName": "custom",
207 }
208 user_addons.append(custom_addon)
209 cache.set(STREMIO_USER_ADDONS, user_addons, _CACHE_EXPIRY)
210
211 return custom_addon, None
212
213
214def _sync_remove_addon(addon_id):
215 """Remove addon by manifest ID from all caches."""
216 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 ]
222 cache.set(STREMIO_USER_ADDONS, new_addons, _CACHE_EXPIRY)
223
224 for cache_key in [
225 STREMIO_ADDONS_KEY,
226 STREMIO_ADDONS_CATALOGS_KEY,
227 STREMIO_TV_ADDONS_KEY,
228 ]:
229 selected = cache.get(cache_key)
230 if selected:
231 keys = decode_selected_ids(selected)
232 keys = [k for k in keys if k != addon_id]
233 cache.set(cache_key, encode_selected_ids(keys), _CACHE_EXPIRY)
234
235
236def _sync_toggle_addon(addon_id, category, enabled):
237 """Add or remove addon_id from a selection category."""
238 key_map = {
239 "stream": STREMIO_ADDONS_KEY,
240 "catalog": STREMIO_ADDONS_CATALOGS_KEY,
241 "tv": STREMIO_TV_ADDONS_KEY,
242 }
243 cache_key = key_map.get(category)
244 if not cache_key:
245 return
246
247 keys = list(decode_selected_ids(cache.get(cache_key)))
248
249 if enabled and addon_id not in keys:
250 keys.append(addon_id)
251 elif not enabled and addon_id in keys:
252 keys.remove(addon_id)
253
254 cache.set(cache_key, encode_selected_ids(keys), _CACHE_EXPIRY)
255
256
257def _add_to_selection(cache_key, addon_id):
258 keys = list(decode_selected_ids(cache.get(cache_key)))
259 if addon_id not in keys:
260 keys.append(addon_id)
261 cache.set(cache_key, encode_selected_ids(keys), _CACHE_EXPIRY)
262
263
264def _validate_manifest(url):
265 """Fetch and validate a manifest URL. Returns (manifest_dict, error)."""
266 url = (url or "").strip()
267 if not url:
268 return None, "Empty URL"
269
270 initial_url = url
271 url = _normalize_manifest_url(url)
272
273 kodilog(f"[WebServer] Validating Stremio manifest: {url}")
274 _, manifest, error = _fetch_manifest(url, initial_url)
275 if error or not manifest:
276 kodilog(f"[WebServer] Manifest validation failed: {error}")
277 return None, str(error or "unknown error")
278
279 if not manifest.get("id") and not manifest.get("name"):
280 return None, "Manifest missing 'id' and 'name'."
281 return manifest, None
282
283
284# ---------------------------------------------------------------------------
285# HTTP request handler
286# ---------------------------------------------------------------------------
287
288
289class StremioRequestHandler(BaseHTTPRequestHandler):
290 """Handles web UI + JSON API requests."""
291
292 def log_message(self, format, *args):
293 kodilog(f"[WebServer] {format % args}")
294
295 def _send_json(self, data, status=200):
296 body = json.dumps(data).encode("utf-8")
297 self.send_response(status)
298 self.send_header("Content-Type", "application/json")
299 self.send_header("Content-Length", str(len(body)))
300 self.send_header("Access-Control-Allow-Origin", "*")
301 self.end_headers()
302 self.wfile.write(body)
303
304 def _send_html(self, html_content):
305 body = html_content.encode("utf-8")
306 self.send_response(200)
307 self.send_header("Content-Type", "text/html; charset=utf-8")
308 self.send_header("Content-Length", str(len(body)))
309 self.end_headers()
310 self.wfile.write(body)
311
312 def _read_body(self):
313 length = int(self.headers.get("Content-Length", 0))
314 return json.loads(self.rfile.read(length)) if length else {}
315
316 # --- Routes ---
317
318 def do_GET(self):
319 if self.path == "/" or self.path == "":
320 self._serve_index()
321 elif self.path == "/api/addons":
322 self._api_get_addons()
323 else:
324 self.send_error(404)
325
326 def do_POST(self):
327 if self.path == "/api/addons":
328 self._api_add_addon()
329 elif self.path == "/api/addons/validate":
330 self._api_validate()
331 else:
332 self.send_error(404)
333
334 def do_DELETE(self):
335 if self.path.startswith("/api/addons/"):
336 addon_id = self.path[len("/api/addons/") :]
337 self._api_remove_addon(addon_id)
338 else:
339 self.send_error(404)
340
341 def do_PUT(self):
342 if self.path.startswith("/api/addons/") and self.path.endswith("/toggle"):
343 addon_id = self.path[len("/api/addons/") : -len("/toggle")]
344 self._api_toggle_addon(addon_id)
345 else:
346 self.send_error(404)
347
348 def do_OPTIONS(self):
349 self.send_response(200)
350 self.send_header("Access-Control-Allow-Origin", "*")
351 self.send_header(
352 "Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"
353 )
354 self.send_header("Access-Control-Allow-Headers", "Content-Type")
355 self.end_headers()
356
357 # --- Handlers ---
358
359 def _serve_index(self):
360 index_path = os.path.join(_WEB_DIR, "index.html")
361 try:
362 with open(index_path, "r", encoding="utf-8") as f:
363 self._send_html(f.read())
364 except FileNotFoundError:
365 self.send_error(500, "Web UI not found")
366
367 def _api_get_addons(self):
368 addons = _get_user_addons()
369 selected = _get_selected_ids()
370 result = []
371 for a in addons:
372 manifest = a.get("manifest", {})
373 transport_url = a.get("transportUrl", "")
374 addon_key = build_addon_instance_key(a)
375 caps = _addon_capabilities(manifest)
376
377 logo = manifest.get("logo", "")
378 if logo and transport_url and not logo.startswith(("http://", "https://")):
379 from urllib.parse import urljoin
380
381 logo = urljoin(transport_url, logo)
382
383 result.append(
384 {
385 "id": addon_key,
386 "name": manifest.get("name", "Unknown"),
387 "description": manifest.get("description", ""),
388 "logo": logo,
389 "version": manifest.get("version", ""),
390 "types": manifest.get("types", []),
391 "transportUrl": transport_url,
392 "capabilities": caps,
393 "selected": {
394 "stream": addon_key in selected.get("stream", []),
395 "catalog": addon_key in selected.get("catalog", []),
396 "tv": addon_key in selected.get("tv", []),
397 },
398 }
399 )
400 self._send_json({"addons": result})
401
402 def _api_add_addon(self):
403 try:
404 data = self._read_body()
405 except Exception:
406 self._send_json({"error": "Invalid JSON"}, 400)
407 return
408 url = data.get("url", "").strip()
409 if not url:
410 self._send_json({"error": "URL is required"}, 400)
411 return
412 addon, error = _sync_add_addon(url)
413 if error or not addon:
414 self._send_json({"error": error}, 400)
415 return
416 manifest = addon.get("manifest", {})
417 transport_url = addon.get("transportUrl", "")
418 addon_key = build_addon_instance_key(addon)
419 caps = _addon_capabilities(manifest)
420
421 logo = manifest.get("logo", "")
422 if logo and transport_url and not logo.startswith(("http://", "https://")):
423 from urllib.parse import urljoin
424
425 logo = urljoin(transport_url, logo)
426
427 self._send_json(
428 {
429 "success": True,
430 "addon": {
431 "id": addon_key,
432 "name": manifest.get("name", "Unknown"),
433 "description": manifest.get("description", ""),
434 "logo": logo,
435 "version": manifest.get("version", ""),
436 "types": manifest.get("types", []),
437 "transportUrl": addon.get("transportUrl", ""),
438 "capabilities": caps,
439 "selected": caps,
440 },
441 }
442 )
443
444 def _api_remove_addon(self, addon_id):
445 from urllib.parse import unquote
446
447 addon_id = unquote(addon_id)
448 _sync_remove_addon(addon_id)
449 self._send_json({"success": True})
450
451 def _api_toggle_addon(self, addon_id):
452 from urllib.parse import unquote
453
454 addon_id = unquote(addon_id)
455 try:
456 data = self._read_body()
457 except Exception:
458 self._send_json({"error": "Invalid JSON"}, 400)
459 return
460 category = data.get("category")
461 enabled = data.get("enabled", False)
462 if category not in ("stream", "catalog", "tv"):
463 self._send_json({"error": "Invalid category"}, 400)
464 return
465 _sync_toggle_addon(addon_id, category, enabled)
466 self._send_json({"success": True})
467
468 def _api_validate(self):
469 try:
470 data = self._read_body()
471 except Exception:
472 self._send_json({"error": "Invalid JSON"}, 400)
473 return
474 url = data.get("url", "").strip()
475 if not url:
476 self._send_json({"error": "URL is required"}, 400)
477 return
478 manifest, error = _validate_manifest(url)
479 if error:
480 self._send_json({"error": error}, 400)
481 return
482 self._send_json({"valid": True, "manifest": manifest})
483
484
485# ---------------------------------------------------------------------------
486# Server lifecycle
487# ---------------------------------------------------------------------------
488
489
490class StremioWebServer:
491 """Threaded HTTP server that starts/stops on demand."""
492
493 def __init__(self, port=8081):
494 self.port = port
495 self._server = None
496 self._thread = None
497
498 def start(self):
499 if self._server:
500 return
501 try:
502 self._server = HTTPServer(("0.0.0.0", self.port), StremioRequestHandler)
503 self._thread = threading.Thread(
504 target=self._server.serve_forever, daemon=True
505 )
506 self._thread.start()
507 kodilog(f"[WebServer] Started on port {self.port}")
508 except Exception as e:
509 kodilog(f"[WebServer] Failed to start: {e}")
510 self._server = None
511
512 def stop(self):
513 if self._server:
514 self._server.shutdown()
515 self._server.server_close()
516 self._server = None
517 self._thread = None
518 kodilog("[WebServer] Stopped")
519
520 @property
521 def is_running(self):
522 return self._server is not None
523
524
525def get_local_ip():
526 """Get the machine's LAN IP address."""
527 try:
528 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
529 s.connect(("8.8.8.8", 80))
530 ip = s.getsockname()[0]
531 s.close()
532 return ip
533 except Exception:
534 return "127.0.0.1"
plugin.video.jacktook/lib/updater.py +405 -0
……@@ -0,0 +1,405 @@
1# -*- coding: utf-8 -*-
2import shutil
3import requests
4import os
5from zipfile import ZipFile, BadZipFile
6import xml.etree.ElementTree as ET
7import xbmc
8from xbmcvfs import translatePath as translate_path
9
10from lib.utils.kodi.utils import (
11 ADDON_VERSION,
12 notification,
13 dialog_ok,
14 dialogyesno,
15 close_all_dialog,
16 execute_builtin,
17 delete_file,
18 update_local_addons,
19 disable_enable_addon,
20 update_kodi_addons_db,
21 dialog_text,
22 dialog_select,
23 show_busy_dialog,
24 close_busy_dialog,
25 kodilog,
26 translation,
27)
28from lib.utils.general.utils import unzip
29
30
31# =========================
32# Constants
33# =========================
34ADDON_ID = "plugin.video.jacktook"
35ADDON_NAME = "Jacktook"
36HEADING = f"{ADDON_NAME} Updater"
37
38PACKAGES_DIR = translate_path("special://home/addons/packages/")
39HOME_ADDONS_DIR = translate_path("special://home/addons/")
40DESTINATION_DIR = translate_path(f"special://home/addons/{ADDON_ID}")
41CHANGELOG_PATH = translate_path(f"special://home/addons/{ADDON_ID}/CHANGELOG.md")
42
43# Self-update source, repointed from upstream to our own build host.
44#
45# This tree carries local patches (Stremio behaviorHints.proxyHeaders plumbing),
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.
50#
51# Served by the kodi-repo unit on stortron, which tracks the addon repo from the
52# pear-git forge; the last hop is plain HTTP because that is all Kodi speaks.
53UPDATE_HOST = "http://192.168.50.38:7221"
54BASE_REPO_URL = f"{UPDATE_HOST}/packages"
55BASE_ZIP_URL = f"{UPDATE_HOST}/zips"
56
57VERSION_FILE = f"{BASE_REPO_URL}/jacktook_version"
58CHANGELOG_FILE = f"{BASE_REPO_URL}/jacktook_changelog"
59
60UPDATE_ACTION_ASK = 0
61UPDATE_ACTION_NOTIFY = 1
62UPDATE_ACTION_NONE = 2
63
64
65# =========================
66# Helpers
67# =========================
68def http_get(url, stream=False):
69 """Make a GET request and return text or raw stream."""
70 try:
71 resp = requests.get(url, stream=stream)
72 resp.raise_for_status()
73 return resp.text if not stream else resp.raw
74 except requests.RequestException as e:
75 notification(f"HTTP Error: {e}")
76 return None
77
78
79def get_versions():
80 """Return (current_version, online_version) or (None, None) on failure."""
81 show_busy_dialog()
82 online_version = http_get(VERSION_FILE)
83 close_busy_dialog()
84 if not online_version:
85 return None, None
86 return ADDON_VERSION, online_version.strip()
87
88
89def version_less_than(v1, v2):
90 """Return True if v1 < v2 using numeric comparison."""
91 try:
92 import re
93
94 def normalize(v):
95 return [int(x) for x in re.sub(r"[^0-9.]", "", v).split(".")]
96
97 return normalize(v1) < normalize(v2)
98 except Exception:
99 return v1 < v2
100
101
102def _safe_remove_path(path):
103 if not os.path.lexists(path):
104 return
105
106 if os.path.islink(path):
107 os.unlink(path)
108 elif os.path.isdir(path):
109 shutil.rmtree(path)
110 else:
111 os.remove(path)
112
113
114def _read_addon_version_from_xml(xml_path):
115 tree = ET.parse(xml_path)
116 return tree.getroot().attrib.get("version")
117
118
119def _validate_downloaded_zip(zip_path, expected_version):
120 addon_xml = f"{ADDON_ID}/addon.xml"
121
122 try:
123 with ZipFile(zip_path) as zip_file:
124 names = zip_file.namelist()
125 if addon_xml not in names:
126 raise ValueError("addon.xml missing from update package")
127
128 xml_data = zip_file.read(addon_xml)
129 except (BadZipFile, KeyError, ValueError) as exc:
130 raise ValueError(str(exc))
131
132 try:
133 version = ET.fromstring(xml_data).attrib.get("version")
134 except ET.ParseError as exc:
135 raise ValueError(f"Invalid addon.xml: {exc}")
136
137 if version != expected_version:
138 raise ValueError(
139 "Package version mismatch: expected %s, found %s"
140 % (expected_version, version)
141 )
142
143
144def _validate_installed_version(destination_dir, expected_version):
145 addon_xml_path = os.path.join(destination_dir, "addon.xml")
146 if not os.path.exists(addon_xml_path):
147 raise ValueError("Installed addon.xml not found after update")
148
149 version = _read_addon_version_from_xml(addon_xml_path)
150 if version != expected_version:
151 raise ValueError(
152 "Installed version mismatch: expected %s, found %s"
153 % (expected_version, version)
154 )
155
156
157def get_changes(online_version=None):
158 """Display changelog (online if version passed, else local)."""
159 if online_version:
160 changelog = http_get(CHANGELOG_FILE)
161 if changelog:
162 dialog_text(translation(90592) % online_version, str(changelog))
163 else:
164 dialog_text(translation(90577), file=CHANGELOG_PATH)
165
166
167# =========================
168# Entry Point
169# =========================
170def updates_check_addon(automatic=False):
171 kodilog("Checking for updates...")
172 current_version, online_version = get_versions()
173 if not current_version or not online_version:
174 kodilog("Failed to fetch versions for update check.")
175 if not automatic:
176 dialog_ok(heading=HEADING, line1=translation(90578))
177 return
178
179 kodilog(f"Update check - Current: {current_version}, Online: {online_version}")
180
181 msg = translation(90580) % (current_version, online_version)
182
183 if current_version == online_version:
184 kodilog("No update available.")
185 if not automatic:
186 notification(heading=HEADING, message=translation(90579))
187 return
188
189 if version_less_than(current_version, online_version):
190 kodilog("Newer version available.")
191 if not automatic:
192 if not dialogyesno(
193 header=HEADING,
194 text=msg + translation(90581),
195 ):
196 return
197 update_addon(online_version)
198 else:
199 from lib.utils.kodi.settings import get_update_action
200
201 action = get_update_action()
202 if action == UPDATE_ACTION_ASK:
203 if dialogyesno(
204 header=HEADING,
205 text=msg + translation(90581),
206 ):
207 update_addon(online_version)
208 elif action == UPDATE_ACTION_NOTIFY:
209 notification(
210 heading=HEADING,
211 message=translation(90582) % online_version,
212 )
213 elif action == UPDATE_ACTION_NONE:
214 return
215
216
217# =========================
218# Core Update Logic
219# =========================
220
221
222def downgrade_addon_menu():
223 """Fetch available versions from GitHub and display them in a selection dialog."""
224 # Static index generated by the build script, shaped like the GitHub
225 # contents API response this used to consume: [{"name": "<zip>"}, ...].
226 repo_contents_url = f"{BASE_ZIP_URL}/{ADDON_ID}/index.json"
227 try:
228 resp = requests.get(repo_contents_url)
229 resp.raise_for_status()
230 contents = resp.json()
231 except Exception as e:
232 dialog_ok(heading=HEADING, line1=translation(90583) % e)
233 return
234
235 # Extract versions from zip filenames: plugin.video.jacktook-X.Y.Z.zip
236 versions = []
237 prefix = f"{ADDON_ID}-"
238 suffix = ".zip"
239 for item in contents:
240 name = item.get("name", "")
241 if name.startswith(prefix) and name.endswith(suffix):
242 version_str = name[len(prefix) : -len(suffix)]
243 versions.append(version_str)
244
245 kodilog(f"Available versions found: {versions}")
246
247 # Downgrade should only offer versions older than the installed one.
248 versions = [v for v in versions if version_less_than(v, ADDON_VERSION)]
249
250 if not versions:
251 dialog_ok(heading=HEADING, line1=translation(90584))
252 return
253
254 # Sort versions
255 try:
256 from pkg_resources import parse_version
257
258 versions.sort(key=parse_version, reverse=True)
259 except ImportError:
260 import re
261
262 def n(v):
263 return [int(x) for x in re.sub(r"[^0-9.]", "", v).split(".")]
264
265 versions.sort(key=n, reverse=True)
266
267 selected_index = dialog_select(
268 heading=translation(90585), _list=versions
269 )
270
271 if selected_index == -1:
272 return
273
274 selected_version = versions[selected_index]
275
276 if not dialogyesno(
277 header=HEADING,
278 text=translation(90586) % selected_version,
279 ):
280 return
281
282 update_addon(selected_version)
283
284
285def update_addon(new_version):
286 kodilog(f"Starting update to version: {new_version}")
287 close_all_dialog()
288 execute_builtin("ActivateWindow(Home)", True)
289 notification(heading=HEADING, message=translation(90587))
290
291 zip_name = f"{ADDON_ID}-{new_version}.zip"
292 zip_url = f"{BASE_ZIP_URL}/{ADDON_ID}/{zip_name}"
293 zip_path = os.path.join(PACKAGES_DIR, zip_name)
294 staging_dir = os.path.join(PACKAGES_DIR, f"{ADDON_ID}-staging")
295 staging_addon_dir = os.path.join(staging_dir, ADDON_ID)
296 backup_dir = os.path.join(PACKAGES_DIR, f"{ADDON_ID}-backup")
297 kodilog(f"Zip URL: {zip_url}")
298 kodilog(f"Zip Path: {zip_path}")
299
300 # Download new version
301 show_busy_dialog()
302 kodilog("Downloading zip file...")
303 raw_data = http_get(zip_url, stream=True)
304 close_busy_dialog()
305 if not raw_data:
306 kodilog("Error: Unable to download update.")
307 dialog_ok(heading=HEADING, line1=translation(90588))
308 return
309
310 try:
311 with open(zip_path, "wb") as f:
312 shutil.copyfileobj(raw_data, f)
313 kodilog("Zip file downloaded successfully.")
314 except Exception as e:
315 kodilog(f"Error saving update file: {e}")
316 dialog_ok(heading=HEADING, line1=translation(90589) % e)
317 return
318
319 try:
320 _validate_downloaded_zip(zip_path, new_version)
321 except ValueError as e:
322 kodilog(f"Error validating update package: {e}")
323 delete_file(zip_path)
324 dialog_ok(heading=HEADING, line1=translation(90590))
325 return
326
327 try:
328 _safe_remove_path(staging_dir)
329 os.makedirs(staging_dir)
330 except Exception as e:
331 kodilog(f"Error preparing staging directory: {e}")
332 delete_file(zip_path)
333 dialog_ok(heading=HEADING, line1=translation(90590))
334 return
335
336 try:
337 if not unzip(zip_path, staging_dir, staging_addon_dir):
338 raise ValueError("Staging extraction failed")
339
340 _validate_installed_version(staging_addon_dir, new_version)
341 except Exception as e:
342 kodilog(f"Error extracting update to staging: {e}")
343 delete_file(zip_path)
344 try:
345 _safe_remove_path(staging_dir)
346 except Exception:
347 pass
348 dialog_ok(
349 heading=HEADING, line1=translation(90590)
350 )
351 return
352
353 replaced_existing_install = False
354 try:
355 _safe_remove_path(backup_dir)
356
357 if os.path.lexists(DESTINATION_DIR):
358 kodilog(f"Backing up current installation from: {DESTINATION_DIR}")
359 shutil.move(DESTINATION_DIR, backup_dir)
360 replaced_existing_install = True
361
362 kodilog(f"Installing staged version to: {DESTINATION_DIR}")
363 shutil.move(staging_addon_dir, DESTINATION_DIR)
364 _validate_installed_version(DESTINATION_DIR, new_version)
365 except Exception as e:
366 kodilog(f"Error replacing addon version: {e}")
367
368 try:
369 if os.path.lexists(DESTINATION_DIR):
370 _safe_remove_path(DESTINATION_DIR)
371 if replaced_existing_install and os.path.lexists(backup_dir):
372 shutil.move(backup_dir, DESTINATION_DIR)
373 except Exception as restore_error:
374 kodilog(f"Error restoring previous addon version: {restore_error}")
375
376 delete_file(zip_path)
377 try:
378 _safe_remove_path(staging_dir)
379 except Exception:
380 pass
381 dialog_ok(heading=HEADING, line1=translation(90590))
382 return
383
384 delete_file(zip_path)
385 try:
386 _safe_remove_path(staging_dir)
387 _safe_remove_path(backup_dir)
388 except Exception as e:
389 kodilog(f"Error cleaning temporary update paths: {e}")
390
391 if dialogyesno(
392 header=HEADING,
393 text=translation(90591),
394 ):
395 get_changes()
396
397 # Refresh Kodi addon system
398 update_local_addons()
399 disable_enable_addon()
400 update_kodi_addons_db()
401 notification(heading=HEADING, message="Reloading Kodi profile to apply addon changes")
402 execute_builtin('LoadProfile(%s)' % xbmc.getInfoLabel("System.ProfileName"), True)
403
404 notification(heading=HEADING, message=translation(90593))
405 kodilog("Update process finished successfully.")
plugin.video.jacktook/lib/utils/clients/utils.py +116 -0
……@@ -0,0 +1,116 @@
1from typing import Any, Callable, Dict, Optional
2from lib.utils.kodi.utils import get_setting, notification, translation
3from lib.utils.general.utils import Indexer
4from lib.utils.kodi.settings import get_int_setting
5
6
7def validate_host(host: Optional[str], indexer: str) -> Optional[bool]:
8 if not host:
9 notification(f"{translation(30223)}: {indexer}")
10 return None
11 return True
12
13
14def validate_key(api_key: Optional[str], indexer: str) -> Optional[bool]:
15 if not api_key:
16 notification(f"{translation(30221)}: {indexer}")
17 return None
18 return True
19
20
21def update_dialog(title: str, message: str, dialog: Any, percent: int = 0) -> None:
22 if dialog:
23 dialog.update(percent, translation(90661) % title, message)
24
25
26def validate_credentials(
27 indexer: str, host: Optional[str], api_key: Optional[str] = None
28) -> bool:
29 """
30 Validates the host and API key for a given indexer.
31 """
32 if not validate_host(host, indexer):
33 return False
34 if api_key is not None and not validate_key(api_key, indexer):
35 return False
36 return True
37
38
39def build_jackett_client() -> Optional[object]:
40 from lib.clients.jackett import Jackett
41
42 host = str(get_setting("jackett_host", ""))
43 api_key = str(get_setting("jackett_apikey", ""))
44 port = str(get_setting("jackett_port", "9117"))
45
46 if not validate_credentials(Indexer.JACKETT, host, api_key):
47 return None
48 return Jackett(host, api_key, port, notification)
49
50
51def build_prowlarr_client() -> Optional[object]:
52 from lib.clients.prowlarr import Prowlarr
53
54 host = str(get_setting("prowlarr_host"))
55 api_key = str(get_setting("prowlarr_apikey"))
56 port = str(get_setting("prowlarr_port", "9696"))
57
58 if not validate_credentials(Indexer.PROWLARR, host, api_key):
59 return None
60 return Prowlarr(host, api_key, port, notification)
61
62
63def build_jackgram_client() -> Optional[object]:
64 from lib.clients.jackgram.client import Jackgram
65
66 host = str(get_setting("jackgram_host"))
67 token = str(get_setting("jackgram_token", ""))
68
69 if not validate_credentials(Indexer.JACKGRAM, host):
70 return None
71 return Jackgram(host, notification, token)
72
73
74def build_zilean_client() -> Optional[object]:
75 from lib.clients.zilean import Zilean
76
77 timeout = get_int_setting("zilean_timeout")
78 host = str(get_setting("zilean_host"))
79
80 if not validate_credentials(Indexer.ZILEAN, host):
81 return None
82 return Zilean(host, timeout, notification)
83
84
85def build_burst_client() -> object:
86 from lib.jacktook.client import Burst
87
88 return Burst(notification)
89
90
91def build_easynews_client() -> Optional[object]:
92 from lib.clients.easynews import Easynews
93
94 user = str(get_setting("easynews_user"))
95 password = str(get_setting("easynews_password"))
96 timeout = get_int_setting("easynews_timeout")
97
98 if not validate_credentials(Indexer.EASYNEWS, user, password):
99 return None
100 return Easynews(user, password, timeout, notification)
101
102
103CLIENT_BUILDERS: Dict[str, Callable[[], Optional[object]]] = {
104 Indexer.JACKETT: build_jackett_client,
105 Indexer.PROWLARR: build_prowlarr_client,
106 Indexer.JACKGRAM: build_jackgram_client,
107 Indexer.BURST: build_burst_client,
108 Indexer.EASYNEWS: build_easynews_client,
109}
110
111
112def get_client(indexer: str) -> Optional[object]:
113 builder = CLIENT_BUILDERS.get(indexer)
114 if not builder:
115 return None
116 return builder()
plugin.video.jacktook/lib/utils/debrid/debrid_utils.py +618 -0
……@@ -0,0 +1,618 @@
1import base64
2from concurrent.futures import ThreadPoolExecutor
3import copy
4import json
5import requests
6from threading import Lock
7from typing import Any, Dict, List, Optional, Tuple
8from urllib.parse import urljoin
9
10from lib.api.debrid.base import ProviderException
11from lib.db.cached import cache
12from lib.clients.debrid.alldebrid import AllDebridHelper
13from lib.clients.debrid.debrider import DebriderHelper
14from lib.clients.debrid.easydebrid import EasyDebridHelper
15from lib.clients.debrid.premiumize import PremiumizeHelper
16from lib.clients.debrid.torbox import TorboxHelper
17from lib.clients.debrid.realdebrid import RealDebridHelper
18from lib.clients.stremio.constants import STREMIO_ADDONS_KEY
19from lib.utils.kodi.utils import get_setting, kodilog, notification, translation
20from lib.utils.torrent.torrserver_utils import extract_torrent_metadata
21from lib.utils.general.utils import (
22 USER_AGENT_HEADER,
23 DebridType,
24 Indexer,
25 IndexerType,
26 get_cached,
27 get_info_hash_from_magnet,
28 info_hash_to_magnet,
29 is_ad_enabled,
30 is_debrider_enabled,
31 is_ed_enabled,
32 is_pm_enabled,
33 is_rd_enabled,
34 is_tb_enabled,
35 is_url,
36 set_cached,
37)
38from lib.domain.torrent import TorrentStream
39from lib.utils.kodi.logging import summarize_locator_for_log
40
41from xbmcgui import Dialog
42from xbmc import LOGDEBUG
43
44
45DEBRID_HELPERS = {
46 DebridType.RD: RealDebridHelper,
47 DebridType.PM: PremiumizeHelper,
48 DebridType.TB: TorboxHelper,
49 DebridType.DB: DebriderHelper,
50 DebridType.AD: AllDebridHelper,
51 DebridType.ED: EasyDebridHelper,
52}
53
54DEBRID_CHECKS = {
55 DebridType.RD: is_rd_enabled,
56 DebridType.TB: is_tb_enabled,
57 DebridType.PM: is_pm_enabled,
58 DebridType.DB: is_debrider_enabled,
59 DebridType.AD: is_ad_enabled,
60}
61
62PACK_DIRECT_DEBRID_TYPES = {DebridType.RD, DebridType.TB, DebridType.AD}
63
64SUPPORTED_CLOUD_TRANSFER_DEBRIDS = (
65 DebridType.RD,
66 DebridType.AD,
67 DebridType.TB,
68 DebridType.DB,
69)
70
71
72def _build_debrid_cache_scope() -> str:
73 flags = {
74 "torrent": bool(get_setting("torrent_enable")),
75 "stremio": bool(get_setting("stremio_enabled")),
76 "rd": bool(get_setting("real_debrid_enabled")),
77 "ad": bool(get_setting("alldebrid_enabled")),
78 "tb": bool(get_setting("torbox_enabled")),
79 "pm": bool(get_setting("premiumize_enabled")),
80 "db": bool(get_setting("debrider_enabled")),
81 "ed": bool(get_setting("easydebrid_enabled")),
82 }
83 selected_stream_addons = str(cache.get(STREMIO_ADDONS_KEY) or "")
84 return "{}|{}".format(
85 "|".join([f"{key}:{int(value)}" for key, value in sorted(flags.items())]),
86 selected_stream_addons,
87 )
88
89
90def get_debrid_helper(debrid_type: str):
91 helper_cls = DEBRID_HELPERS.get(debrid_type)
92 if not helper_cls:
93 notification(f"Unknown debrid type: {debrid_type}")
94 raise ValueError(f"Unknown debrid type: {debrid_type}")
95 return helper_cls()
96
97
98def get_enabled_cloud_transfer_debrids() -> List[str]:
99 return [
100 debrid_type
101 for debrid_type in SUPPORTED_CLOUD_TRANSFER_DEBRIDS
102 if DEBRID_CHECKS.get(debrid_type, lambda: False)()
103 ]
104
105
106def choose_debrid_for_transfer(preferred_debrid: str = "") -> Optional[str]:
107 enabled_debrids = get_enabled_cloud_transfer_debrids()
108 if not enabled_debrids:
109 notification(translation(90364))
110 return None
111
112 if preferred_debrid in enabled_debrids:
113 return preferred_debrid
114
115 if len(enabled_debrids) == 1:
116 return enabled_debrids[0]
117
118 selected = Dialog().select(translation(90363), enabled_debrids)
119 if selected < 0:
120 return None
121 return enabled_debrids[selected]
122
123
124def _is_torrent_ready_in_debrid(debrid_type: str, info_hash: str) -> bool:
125 """Check if a torrent is ready/cached in the specified debrid service."""
126 if not info_hash:
127 return False
128
129 try:
130 if debrid_type == DebridType.RD:
131 torrent_info = RealDebridHelper().client.get_available_torrent(info_hash)
132 if torrent_info and torrent_info.get("status") == "downloaded":
133 return True
134 elif debrid_type == DebridType.AD:
135 result = AllDebridHelper().client.add_magnet(info_hash_to_magnet(info_hash))
136 magnet = result.get("data", {}).get("magnets", [])[0]
137 if magnet and magnet.get("ready"):
138 return True
139 elif debrid_type == DebridType.TB:
140 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")):
144 return True
145 elif debrid_type == DebridType.DB:
146 info = DebriderHelper().get_info(info_hash)
147 if info and info.get("files"):
148 return True
149 except Exception as e:
150 kodilog(f"Error checking if torrent is ready in {debrid_type}: {e}")
151
152 return False
153
154
155def _add_to_realdebrid(info_hash: str, torrent_data: bytes, torrent_name: str):
156 if torrent_data:
157 kodilog("Debrid transfer path: uploading torrent file to RD")
158 return RealDebridHelper().add_torrent_file(torrent_data, torrent_name=torrent_name)
159 elif info_hash:
160 kodilog("Debrid transfer path: sending magnet/info_hash to RD")
161 return RealDebridHelper().add_magnet(info_hash)
162 notification(translation(90361))
163 return None
164
165
166def _add_to_alldebrid(info_hash: str, torrent_data: bytes, torrent_name: str):
167 if torrent_data:
168 kodilog("Debrid transfer path: uploading torrent file to AD")
169 return AllDebridHelper().add_torrent_file(torrent_data, torrent_name=torrent_name)
170 elif info_hash:
171 kodilog("Debrid transfer path: sending magnet/info_hash to AD")
172 return AllDebridHelper().client.add_magnet(info_hash_to_magnet(info_hash))
173 notification(translation(90361))
174 return None
175
176
177def _add_to_torbox(info_hash: str, torrent_data: bytes, torrent_name: str):
178 if torrent_data:
179 kodilog("Debrid transfer path: uploading torrent file to TB")
180 return TorboxHelper().add_torrent_file(torrent_data, torrent_name=torrent_name)
181 elif info_hash:
182 kodilog("Debrid transfer path: sending magnet/info_hash to TB")
183 return TorboxHelper().add_torbox_torrent(info_hash)
184 notification(translation(90361))
185 return None
186
187
188def _add_to_debrider(info_hash: str):
189 if not info_hash:
190 notification(translation(90361))
191 return None
192 kodilog("Debrid transfer path: sending magnet/info_hash to DB")
193 return DebriderHelper().add_magnet(info_hash)
194
195
196_DEBRID_ADDERS = {
197 DebridType.RD: _add_to_realdebrid,
198 DebridType.AD: _add_to_alldebrid,
199 DebridType.TB: _add_to_torbox,
200 DebridType.DB: _add_to_debrider,
201}
202
203
204def add_source_to_debrid(
205 info_hash: str,
206 preferred_debrid: str = "",
207 torrent_data: bytes = b"",
208 torrent_name: str = "",
209) -> Optional[str]:
210 debrid_type = choose_debrid_for_transfer(preferred_debrid)
211 if not debrid_type:
212 return None
213
214 try:
215 adder = _DEBRID_ADDERS.get(debrid_type)
216 if not adder:
217 raise ValueError(f"Unsupported debrid cloud transfer type: {debrid_type}")
218
219 if debrid_type == DebridType.DB:
220 result = adder(info_hash)
221 else:
222 result = adder(info_hash, torrent_data, torrent_name)
223
224 if not result:
225 raise ProviderException(f"Failed to add torrent to {debrid_type}: no response data")
226
227 kodilog(f"Debrid transfer succeeded: debrid={debrid_type}, result={result is not None}")
228
229 is_ready = _is_torrent_ready_in_debrid(debrid_type, info_hash)
230 if is_ready:
231 notification(translation(90362) % debrid_type)
232 else:
233 notification(translation(90694) % debrid_type)
234
235 return debrid_type
236 except Exception as exc:
237 kodilog(f"Failed to add source to debrid cloud: {exc}")
238 notification(str(exc))
239 return None
240
241
242def check_debrid_cached(
243 query: Optional[str],
244 results: List[TorrentStream],
245 mode: str,
246 media_type: str,
247 dialog: Dialog,
248 rescrape: bool,
249 episode: int = 1,
250) -> List[TorrentStream]:
251 kodilog("Checking debrid cached results...")
252
253 if not rescrape:
254 cached_results = get_cached_results(query, mode, media_type, episode)
255 if cached_results:
256 return cached_results
257
258 lock = Lock()
259 cached_results, uncached_results, direct_results = [], [], []
260
261 dialog.create("")
262 filter_results(results, direct_results)
263
264 if direct_results and not results:
265 kodilog("Only direct results found, returning them.")
266 return direct_results
267
268 check_functions = get_debrid_check_functions()
269 if not check_functions:
270 kodilog("No debrid services enabled for caching check.")
271 return direct_results
272
273 execute_debrid_checks(
274 check_functions, results, cached_results, uncached_results, dialog, lock
275 )
276
277 cached_results.extend(direct_results)
278 if should_include_uncached():
279 cached_results.extend(uncached_results)
280
281 kodilog(f"Cached results: {len(cached_results)}")
282
283 update_cache(query, mode, media_type, cached_results, episode)
284 return cached_results
285
286
287def get_cached_results(
288 query: Optional[str], mode: str, media_type: str, episode: int
289) -> Optional[List[TorrentStream]]:
290 if query:
291 cache_scope = _build_debrid_cache_scope()
292 params = (
293 (episode, "deb", cache_scope)
294 if mode == "tv" or media_type == "tv"
295 else ("deb", cache_scope)
296 )
297 cached_results = get_cached(query, params=params)
298 return cached_results if isinstance(cached_results, list) else None
299 return None
300
301
302def get_debrid_check_functions() -> List:
303 check_functions = []
304 for debrid_type, is_enabled in DEBRID_CHECKS.items():
305 if is_enabled():
306 check_functions.append(get_debrid_helper(debrid_type).check_cached)
307 return check_functions
308
309
310def execute_debrid_checks(
311 check_functions: List,
312 results: List[TorrentStream],
313 cached_results: list,
314 uncached_results: list,
315 dialog: Dialog,
316 lock: Lock,
317):
318 with ThreadPoolExecutor(
319 max_workers=int(get_setting("thread_number", 6))
320 ) as executor:
321 futures = [
322 executor.submit(
323 fn,
324 results,
325 cached_results,
326 uncached_results,
327 len(results),
328 dialog,
329 lock,
330 )
331 for fn in check_functions
332 ]
333 for future in futures:
334 try:
335 future.result()
336 except Exception as exc:
337 kodilog(f"Debrid cache check failed: {exc}")
338
339
340def should_include_uncached() -> bool:
341 return any([is_tb_enabled(), is_pm_enabled(), is_ed_enabled()]) and bool(
342 get_setting("show_uncached")
343 )
344
345
346def update_cache(
347 query: Optional[str],
348 mode: str,
349 media_type: str,
350 cached_results: List[TorrentStream],
351 episode: int,
352):
353 if query:
354 cache_scope = _build_debrid_cache_scope()
355 params = (
356 (episode, "deb", cache_scope)
357 if mode == "tv" or media_type == "tv"
358 else ("deb", cache_scope)
359 )
360 set_cached(cached_results, query, params=params)
361
362
363def get_source_status(res: TorrentStream) -> str:
364 is_cached = res.isCached
365 added_label = f"{res.debridType} Added".strip() if res.debridType else "Added"
366
367 if res.isPack:
368 if is_cached:
369 label = f"[B]Cached-[Pack][/B]"
370 elif res.addedToDebrid:
371 label = f"[B]{added_label}-[Pack][/B]"
372 else:
373 label = f"[B]Download-[Pack][/B]"
374 else:
375 if is_cached:
376 label = f"[B]Cached[/B]"
377 elif res.addedToDebrid:
378 label = f"[B]{added_label}[/B]"
379 else:
380 label = f"[B]Download[/B]"
381 return label
382
383
384def get_pack_info(debrid_type, info_hash):
385 return get_debrid_helper(debrid_type).get_pack_info(info_hash)
386
387
388def filter_results(
389 results: List[TorrentStream], direct_results: List[TorrentStream]
390) -> None:
391 filtered_results = []
392 dropped_results = []
393
394 for res in copy.deepcopy(results):
395 info_hash = extract_info_hash(res)
396 if info_hash:
397 res.infoHash = info_hash
398 filtered_results.append(res)
399 elif res.indexer in [Indexer.TELEGRAM, Indexer.EASYNEWS] or res.type in [
400 IndexerType.STREMIO_DEBRID,
401 IndexerType.DIRECT,
402 ]:
403 direct_results.append(res)
404 else:
405 dropped_results.append(res)
406
407 results[:] = filtered_results
408
409
410def extract_info_hash(res: TorrentStream) -> Optional[str]:
411 try:
412 if res.infoHash:
413 return res.infoHash.lower()
414
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()
420
421 if res.url and res.url.startswith("magnet:?"):
422 info_hash = get_info_hash_from_magnet(res.url)
423 if info_hash:
424 return info_hash.lower()
425 except Exception as e:
426 kodilog(f"Error extracting info hash from TorrentStream: {e}")
427
428 return None
429
430
431def get_torrent_data_from_uri(uri: str) -> Tuple[bytes, str, str, str]:
432 kodilog(f"get_torrent_data_from_uri: Checking URI: {uri}", level=LOGDEBUG)
433 torrent_data = b""
434 magnet = ""
435 info_hash = ""
436 torrent_url = ""
437
438 if not is_url(uri):
439 return torrent_data, magnet, info_hash, torrent_url
440
441 try:
442 current_uri = uri
443 for redirect_count in range(5):
444 res = requests.get(
445 current_uri,
446 allow_redirects=False,
447 timeout=10,
448 headers=USER_AGENT_HEADER,
449 )
450 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 )
455 )
456
457 if 300 <= res.status_code < 400:
458 location = res.headers.get("Location", "")
459 if not location:
460 break
461
462 kodilog(
463 "get_torrent_data_from_uri: Redirect {} -> {}".format(
464 summarize_locator_for_log(current_uri),
465 summarize_locator_for_log(location),
466 )
467 )
468
469 if location.startswith("magnet:?"):
470 magnet = location
471 info_hash = get_info_hash_from_magnet(magnet).lower()
472 return torrent_data, magnet, info_hash, torrent_url
473
474 current_uri = urljoin(current_uri, location)
475 continue
476
477 if res.status_code == 200:
478 kodilog(
479 "get_torrent_data_from_uri: Processing content from {}".format(
480 summarize_locator_for_log(current_uri)
481 )
482 )
483 if res.url.startswith("magnet:"):
484 magnet = str(res.url or "")
485 info_hash = get_info_hash_from_magnet(magnet).lower()
486 return torrent_data, magnet, info_hash, torrent_url
487
488 magnet = extract_torrent_metadata(res.content)
489 kodilog(
490 "get_torrent_data_from_uri: Extracted magnet: {}".format(
491 summarize_locator_for_log(magnet)
492 )
493 )
494 if magnet:
495 torrent_data = bytes(res.content or b"")
496 info_hash = get_info_hash_from_magnet(magnet).lower()
497 torrent_url = current_uri
498 kodilog(
499 "get_torrent_data_from_uri: Preserving torrent URL for playback: {}".format(
500 summarize_locator_for_log(torrent_url)
501 )
502 )
503 return torrent_data, str(magnet or ""), str(info_hash or ""), str(
504 torrent_url or ""
505 )
506
507 break
508 except Exception as e:
509 kodilog(f"get_torrent_data_from_uri: Exception occurred for uri: {uri}: {e}")
510 return torrent_data, str(magnet or ""), str(info_hash or ""), str(torrent_url or "")
511
512
513def get_magnet_from_uri(uri: str) -> Tuple[str, str, str]:
514 _, magnet, info_hash, torrent_url = get_torrent_data_from_uri(uri)
515 return magnet, info_hash, torrent_url
516
517
518def get_debrid_direct_url(debrid_type, data) -> Optional[Dict[str, Any]]:
519 info_hash = data.get("info_hash", "")
520 try:
521 return get_debrid_helper(debrid_type).get_link(info_hash, data)
522 except ValueError as e:
523 kodilog(f"Unknown debrid type: {debrid_type}: {e}")
524 return None
525
526
527def get_debrid_pack_direct_url(debrid_type, data) -> Optional[Dict[str, Any]]:
528 if debrid_type not in PACK_DIRECT_DEBRID_TYPES:
529 kodilog(f"Unknown debrid type for pack link: {debrid_type}")
530 return None
531
532 return get_debrid_helper(debrid_type).get_pack_link(data)
533
534
535def is_supported_debrid_type(debrid_type: str) -> bool:
536 return debrid_type in DEBRID_HELPERS
537
538
539def process_external_cache(data: dict, debrid: str, token: str, url: str):
540 try:
541 imdb_id = data.get("imdb_id")
542 season = data.get("season")
543 episode = data.get("episode")
544 mode = data.get("mode", data.get("media_type", "movie"))
545
546 if "torrentio" in url:
547 service = "torrentio"
548 elif "mediafusion" in url:
549 service = "mediafusion"
550 elif "comet" in url:
551 service = "comet"
552 else:
553 return None
554
555 if not imdb_id:
556 raise ValueError("Missing IMDb ID in input data")
557
558 if service == "mediafusion":
559 base_link = f"https://mediafusion.elfhosted.com/{debrid}={token}"
560 params = {
561 "enable_catalogs": False,
562 "max_streams_per_resolution": 99,
563 "torrent_sorting_priority": [],
564 "certification_filter": ["Disable"],
565 "nudity_filter": ["Disable"],
566 "streaming_provider": {
567 "token": token,
568 "service": debrid,
569 "only_show_cached_streams": True,
570 },
571 }
572 headers = {
573 "encoded_user_data": base64.b64encode(
574 json.dumps(params).encode("utf-8")
575 ).decode("utf-8")
576 }
577
578 elif service == "comet":
579 params = {
580 "maxResultsPerResolution": 0,
581 "maxSize": 0,
582 "cachedOnly": True,
583 "removeTrash": True,
584 "resultFormat": ["title", "size"],
585 "debridService": debrid,
586 "debridApiKey": token,
587 "debridStreamProxyPassword": "",
588 "languages": {"required": [], "exclude": [], "preferred": []},
589 "resolutions": {},
590 "options": {
591 "remove_ranks_under": -10000000000,
592 "allow_english_in_languages": False,
593 "remove_unknown_languages": False,
594 },
595 }
596 params_encoded = base64.b64encode(
597 json.dumps(params).encode("utf-8")
598 ).decode("utf-8")
599 base_link = f"https://comet.elfhosted.com/{params_encoded}"
600 headers = {}
601
602 else: # torrentio fallback
603 base_link = f"https://torrentio.strem.fun/{debrid}={token}"
604 headers = {"User-Agent": "Mozilla/5.0"}
605
606 # Choose endpoint based on media type
607 if mode == "tv":
608 endpoint = f"/stream/series/{imdb_id}:{season}:{episode}.json"
609 else:
610 endpoint = f"/stream/movie/{imdb_id}.json"
611
612 url = f"{base_link}{endpoint}"
613 response = requests.get(url, headers=headers, timeout=9)
614 response.raise_for_status()
615 return response
616 except Exception as e:
617 kodilog(f"Error checking cache for {url}: {e}")
618 return None
plugin.video.jacktook/lib/utils/debrid/qrcode_utils.py +83 -0
……@@ -0,0 +1,83 @@
1from os import path
2
3from lib.jacktook.utils import ADDON_PATH, kodilog
4from lib.utils.kodi.utils import ADDON_PROFILE_PATH
5from lib.vendor.segno import make as segnomake
6import xbmcvfs
7import xbmcgui
8
9
10def make_qrcode(url):
11 if not url:
12 return
13 try:
14 if not path.exists(ADDON_PROFILE_PATH):
15 xbmcvfs.mkdir(ADDON_PROFILE_PATH)
16
17 art_path = path.join(ADDON_PROFILE_PATH, "qr.png")
18 kodilog(f"Creating QR code for URL: {art_path}")
19 qrcode = segnomake(url, micro=False)
20 qrcode.save(art_path, scale=20)
21 return art_path
22 except Exception as e:
23 kodilog("Error creating QR code: %s", e)
24
25
26class QRProgressDialogLib(xbmcgui.WindowDialog):
27 def __init__(self, title, qr_path, auth_url, user_code):
28 super().__init__()
29 # Use skin base res (Kodi scales this to actual screen)
30 screen_w, screen_h = 1280, 720
31
32 box_w, box_h = 600, 400
33 box_x = (screen_w - box_w) // 2 # 340
34 box_y = (screen_h - box_h) // 2 # 160
35
36 # Background
37 self.bg = xbmcgui.ControlImage(
38 box_x,
39 box_y,
40 box_w,
41 box_h,
42 path.join(ADDON_PATH, "resources", "img", "texture.png"),
43 )
44 self.addControl(self.bg)
45
46 # Title
47 self.title_lbl = xbmcgui.ControlLabel(
48 box_x + 20, box_y + 20, box_w - 40, 30, f"[B]{title}[/B]"
49 )
50 self.addControl(self.title_lbl)
51
52 # QR
53 self.qr = xbmcgui.ControlImage(box_x + 30, box_y + 70, 200, 200, qr_path)
54 self.addControl(self.qr)
55
56 # Instructions
57 self.text_lbl = xbmcgui.ControlLabel(
58 box_x + 250,
59 box_y + 70,
60 box_w - 280,
61 120,
62 f"Go to:\n[COLOR cyan]{auth_url}[/COLOR]\n\n"
63 f"Enter code:\n[COLOR seagreen][B]{user_code}[/B][/COLOR]",
64 )
65 self.addControl(self.text_lbl)
66
67 # Progress bar
68 self.progress = xbmcgui.ControlProgress(box_x + 20, box_y + 300, box_w - 40, 30)
69 self.addControl(self.progress)
70
71 # Cancel button
72 self.cancel_btn = xbmcgui.ControlButton(
73 box_x + box_w - 140, box_y + box_h - 40, 120, 30, "Cancel"
74 )
75 self.addControl(self.cancel_btn)
76
77 def setProgress(self, percent, message=""):
78 self.progress.setPercent(percent)
79 if message:
80 self.text_lbl.setLabel(message)
81
82 def isCanceled(self):
83 return self.getFocusId() == self.cancel_btn.getId()
plugin.video.jacktook/lib/utils/general/items_menus.py +364 -0
……@@ -0,0 +1,364 @@
1from lib.utils.kodi.utils import translation, get_setting
2from lib.utils.views.continue_watching import has_continue_watching_items
3
4
5trakt_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"},
15]
16
17
18trakt_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"},
26]
27
28
29trakt_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"},
39]
40
41
42trakt_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"},
50]
51
52
53tv_items = [
54 {
55 "name": translation(90028),
56 "mode": "tv",
57 "api": "tmdb",
58 "query": "tmdb_trending",
59 "icon": "tmdb.png",
60 },
61 {
62 "name": translation(90064),
63 "mode": "tv",
64 "api": "tmdb",
65 "query": "tmdb_popular",
66 "icon": "tmdb.png",
67 },
68 {
69 "name": translation(90086),
70 "mode": "tv",
71 "api": "tmdb",
72 "query": "tmdb_airing_today",
73 "icon": "tmdb.png",
74 },
75 {
76 "name": translation(90025),
77 "mode": "tv",
78 "api": "tmdb",
79 "query": "tmdb_genres",
80 "icon": "tmdb.png",
81 },
82 {
83 "name": translation(90026),
84 "mode": "tv",
85 "api": "tmdb",
86 "query": "tmdb_calendar",
87 "icon": "tmdb.png",
88 },
89 {
90 "name": translation(90027),
91 "mode": "tv",
92 "api": "tmdb",
93 "query": "tmdb_years",
94 "icon": "tmdb.png",
95 },
96 {
97 "name": translation(90065),
98 "mode": "tv",
99 "api": "tmdb",
100 "query": "tmdb_lang",
101 "icon": "tmdb.png",
102 },
103 {
104 "name": translation(90066),
105 "mode": "tv",
106 "api": "tmdb",
107 "query": "tmdb_networks",
108 "icon": "tmdb.png",
109 },
110 {
111 "name": translation(90078),
112 "mode": "tv",
113 "submode": "people_menu",
114 "api": "tmdb",
115 "query": "tmdb_people",
116 "icon": "tmdb.png",
117 },
118 {
119 "name": translation(90073),
120 "mode": "tv",
121 "api": "mdblist",
122 "query": "mdblist",
123 "icon": "mdblist.png",
124 },
125 {
126 "name": translation(90292),
127 "icon": "trakt.png",
128 "action": "trakt_group_menu",
129 "params": {"mode": "tv", "group": "library"},
130 },
131 {
132 "name": translation(90293),
133 "icon": "trakt.png",
134 "action": "trakt_group_menu",
135 "params": {"mode": "tv", "group": "discovery"},
136 },
137]
138
139
140movie_items = [
141 {
142 "name": translation(90028),
143 "mode": "movies",
144 "api": "tmdb",
145 "query": "tmdb_trending",
146 "icon": "tmdb.png",
147 },
148 {
149 "name": translation(90064),
150 "mode": "movies",
151 "api": "tmdb",
152 "query": "tmdb_popular",
153 "icon": "tmdb.png",
154 },
155 {
156 "name": translation(90025),
157 "mode": "movies",
158 "api": "tmdb",
159 "query": "tmdb_genres",
160 "icon": "tmdb.png",
161 },
162 {
163 "name": translation(90027),
164 "mode": "movies",
165 "api": "tmdb",
166 "query": "tmdb_years",
167 "icon": "tmdb.png",
168 },
169 {
170 "name": translation(90065),
171 "mode": "movies",
172 "api": "tmdb",
173 "query": "tmdb_lang",
174 "icon": "tmdb.png",
175 },
176 {
177 "name": translation(90067),
178 "mode": "movies",
179 "api": "tmdb",
180 "query": "tmdb_collections",
181 "icon": "tmdb.png",
182 },
183 {
184 "name": translation(90078),
185 "mode": "movies",
186 "submode": "people_menu",
187 "api": "tmdb",
188 "query": "tmdb_people",
189 "icon": "tmdb.png",
190 },
191 {
192 "name": translation(90073),
193 "mode": "movies",
194 "api": "mdblist",
195 "query": "mdblist",
196 "icon": "mdblist.png",
197 },
198 {
199 "name": translation(90292),
200 "icon": "trakt.png",
201 "action": "trakt_group_menu",
202 "params": {"mode": "movies", "group": "library"},
203 },
204 {
205 "name": translation(90293),
206 "icon": "trakt.png",
207 "action": "trakt_group_menu",
208 "params": {"mode": "movies", "group": "discovery"},
209 },
210]
211
212
213anime_items = [
214 {
215 "name": translation(90037),
216 "mode": "anime",
217 "category": "Anime_Popular",
218 "api": "tmdb",
219 "icon": "tmdb.png",
220 },
221 {
222 "name": translation(90038),
223 "mode": "anime",
224 "category": "Anime_Popular_Recent",
225 "api": "tmdb",
226 "icon": "tmdb.png",
227 },
228 {
229 "name": translation(90039),
230 "mode": "anime",
231 "category": "Anime_On_The_Air",
232 "api": "tmdb",
233 "icon": "tmdb.png",
234 },
235 {
236 "name": translation(90042),
237 "mode": "anime",
238 "category": "Anime_Top_Rated",
239 "api": "tmdb",
240 "icon": "tmdb.png",
241 },
242 {
243 "name": translation(90040),
244 "mode": "anime",
245 "category": "Anime_Years",
246 "api": "tmdb",
247 "icon": "tmdb.png",
248 },
249 {
250 "name": translation(90041),
251 "mode": "anime",
252 "category": "Anime_Genres",
253 "api": "tmdb",
254 "icon": "tmdb.png",
255 },
256 {
257 "name": translation(90028),
258 "mode": "anime",
259 "category": "Anime_Trending",
260 "api": "trakt",
261 "icon": "trakt.png",
262 },
263 {
264 "name": translation(90154),
265 "mode": "anime",
266 "category": "Anime_Trending_Recent",
267 "api": "trakt",
268 "icon": "trakt.png",
269 },
270 {
271 "name": translation(90043),
272 "mode": "anime",
273 "category": "Anime_Most_Watched",
274 "api": "trakt",
275 "icon": "trakt.png",
276 },
277 {
278 "name": translation(90030),
279 "mode": "anime",
280 "category": "Anime_Favorited",
281 "api": "trakt",
282 "icon": "trakt.png",
283 },
284]
285
286animation_items = [
287 {
288 "name": translation(90496),
289 "mode": "cartoon",
290 "category": "Cartoons_Popular",
291 "api": "tmdb",
292 "icon": "tmdb.png",
293 },
294 {
295 "name": translation(90497),
296 "mode": "animation",
297 "category": "Animation_Popular",
298 "api": "tmdb",
299 "icon": "tmdb.png",
300 },
301]
302
303root_menu_items = [
304 {
305 "name": 90006,
306 "icon": "search.png",
307 "action": "search_menu",
308 },
309 {
310 "name": 90200,
311 "icon": "continue_watching.png",
312 "action": "continue_watching_menu",
313 "condition": has_continue_watching_items,
314 }, # Continue Watching
315 {"name": 90007, "icon": "tv.png", "action": "tv_shows_items"},
316 {"name": 90008, "icon": "movies.png", "action": "movies_items"},
317 {"name": 90009, "icon": "anime.png", "action": "anime_menu"},
318 {"name": 90010, "icon": "tv.png", "action": "tv_menu"},
319 {"name": 90201, "icon": "library.png", "action": "library_menu"}, # Library
320 {"name": 90012, "icon": "magnet2.png", "action": "torrents"},
321 {
322 "name": 90013,
323 "icon": "telegram.png",
324 "action": "telegram_menu",
325 "condition": lambda: get_setting("show_telegram_menu"),
326 },
327 {"name": 90014, "icon": "cloud2.png", "action": "cloud"},
328 {"name": 90015, "icon": "download2.png", "action": "downloads_menu"},
329 {
330 "name": 90016,
331 "icon": "settings.png",
332 "action": "settings_menu",
333 },
334]
335
336history_menu_items = [
337 {"name": 90019, "icon": "history.png", "action": "files_history"},
338 {"name": 90020, "icon": "history.png", "action": "titles_history"},
339 {"name": 90021, "icon": "history.png", "action": "titles_calendar"},
340]
341
342library_menu_items = [
343 {
344 "name": 90202,
345 "icon": "tv.png",
346 "action": "library_shows",
347 "context_menu": [
348 {"label": 90690, "action": "library_shows", "params": {"clear": 1}}
349 ],
350 }, # My Shows
351 {
352 "name": 90203,
353 "icon": "movies.png",
354 "action": "library_movies",
355 "context_menu": [
356 {"label": 90690, "action": "library_movies", "params": {"clear": 1}}
357 ],
358 }, # My Movies
359 {
360 "name": 90021,
361 "icon": "history.png",
362 "action": "library_calendar",
363 }, # Upcoming Episodes
364]
plugin.video.jacktook/lib/utils/general/processors.py +293 -0
……@@ -0,0 +1,293 @@
1from enum import Enum
2import re
3from typing import List, Dict, Optional
4
5from lib.utils.kodi.utils import get_setting, kodilog
6from lib.clients.base import TorrentStream
7
8
9class Quality(Enum):
10 LOW = ("480p", "[B][COLOR orange]480p[/COLOR][/B]")
11 MEDIUM = ("720p", "[B][COLOR orange]720p[/COLOR][/B]")
12 HIGH = ("1080p", "[B][COLOR blue]1080p[/COLOR][/B]")
13 ULTRA = ("2160", "[B][COLOR yellow]4k[/COLOR][/B]")
14 UNKNOWN = ("N/A", "[B][COLOR yellow]N/A[/COLOR][/B]")
15
16
17class SourceCategory(Enum):
18 BLURAY_UHD = (
19 "BluRay/UHD",
20 [
21 "BluRay",
22 "BluRay REMUX",
23 "BRRip",
24 "BDRip",
25 "UHDRip",
26 "REMUX",
27 "BLURAY",
28 "DolbyVision",
29 "HDR10",
30 ],
31 )
32 WEB_HD = (
33 "WEB/HD",
34 [
35 "WEB-DL",
36 "WEB-DLRip",
37 "WEBRip",
38 "HDRip",
39 "WEBMux",
40 "AMZN",
41 ],
42 )
43 DVD_TV_SAT = (
44 "DVD/TV/SAT",
45 ["DVD", "DVDRip", "HDTV", "SATRip", "TVRip", "PPVRip", "PDTV", "DVDR", "DVD-R"],
46 )
47 CAM_SCREENER = (
48 "CAM/Screener",
49 ["CAM", "CAMRip", "SCREENER", "TeleSync", "TeleCine", "SCR"],
50 )
51 UNKNOWN = ("Unknown", [None])
52
53
54class SortField(Enum):
55 SEEDS = "seeders"
56 SIZE = "size"
57 DATE = "publishDate"
58 QUALITY = "quality"
59 CACHED = "isCached"
60
61
62class BaseProcessBuilder:
63 def __init__(self, results: List[TorrentStream]):
64 self.results: List[TorrentStream] = results
65
66 def get_season_pack_patterns(self, season_num: int) -> list:
67 season_fill = f"{int(season_num):02}"
68 return [
69 rf"\.S{season_num}\.",
70 rf"\.S{season_fill}\.",
71 rf"\sS{season_num}\s",
72 rf"\sS{season_fill}\s",
73 rf"\.{season_num}\.season",
74 r"total\.season",
75 r"season",
76 r"the\.complete",
77 r"complete",
78 r"integrale",
79 rf"Saison {season_num}" r"S(\d{2})E(\d{2})-(\d{2})",
80 rf"\.season\.{season_num}\.",
81 rf"\.season{season_num}\.",
82 rf"\.season\.{season_fill}\.",
83 rf"s1 to {season_num}",
84 rf"s1 to s{season_num}",
85 rf"s01 to {season_fill}",
86 rf"s01 to s{season_fill}",
87 rf"s1 thru {season_num}",
88 rf"s1 thru s{season_num}",
89 rf"s01 thru {season_fill}",
90 rf"s01 thru s{season_fill}",
91 rf"\[S{season_fill}\]",
92 rf"\[S{season_num}\]" rf"\(S{season_fill}\)",
93 rf"\(S{season_num}\)",
94 rf"\[Season {season_num}\]",
95 rf"\[Season {season_fill}\]",
96 ]
97
98 def get_results(self) -> List[TorrentStream]:
99 return self.results
100
101
102class PostProcessBuilder(BaseProcessBuilder):
103 def __init__(self, results: List[TorrentStream]):
104 super().__init__(results)
105
106 @staticmethod
107 def _resolve_sort_field(sort_by: str) -> Optional[SortField]:
108 if not sort_by:
109 return None
110
111 normalized = str(sort_by).strip()
112 if not normalized or normalized.lower() == "none":
113 return None
114
115 member = SortField.__members__.get(normalized.upper())
116 if member:
117 return member
118
119 for field in SortField:
120 if field.value.casefold() == normalized.casefold():
121 return field
122
123 return None
124
125 def check_season_pack(self, season: int) -> "PostProcessBuilder":
126 season_patterns = self.get_season_pack_patterns(season)
127 for res in self.results:
128 res.isPack = bool(re.search("|".join(season_patterns), res.title))
129 return self
130
131 def sort_results(self) -> "PostProcessBuilder":
132 sort_by = get_setting("indexers_sort_by")
133 sort_field = self._resolve_sort_field(sort_by)
134 if sort_field:
135 self.results = sorted(
136 self.results,
137 key=lambda r: getattr(r, sort_field.value, 0),
138 reverse=True,
139 )
140 return self
141
142 def limit_results(self) -> "PostProcessBuilder":
143 limit = int(get_setting("indexers_total_results", 10))
144 self.results = self.results[:limit]
145 return self
146
147
148class PreProcessBuilder(BaseProcessBuilder):
149 def __init__(self, results: List[TorrentStream]):
150 super().__init__(results)
151
152 def remove_duplicates(self) -> "PreProcessBuilder":
153 seen_values: List[str] = []
154 unique_results: List[TorrentStream] = []
155 for res in self.results:
156 key = (
157 getattr(res, "infoHash", None)
158 or getattr(res, "title", None)
159 or getattr(res, "guid", None)
160 )
161 if key and key not in seen_values:
162 unique_results.append(res)
163 seen_values.append(key)
164 self.results = unique_results
165 return self
166
167 def filter_season_packs(self, season_num: int) -> List[TorrentStream]:
168 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 ]
174
175 def filter_sources(
176 self, episode_name: str, episode_num: int, season_num: int
177 ) -> "PreProcessBuilder":
178
179 include_season_packs = get_setting("include_season_packs")
180 season_pack_results: List[TorrentStream] = []
181 if include_season_packs:
182 kodilog("Including season packs in filtering")
183 season_pack_results = self.filter_season_packs(season_num)
184
185 episode_fill = f"{int(episode_num):02}"
186 season_fill = f"{int(season_num):02}"
187 str_season = str(int(season_num))
188 patterns = [
189 rf"S{season_fill}E{episode_fill}", # SXXEXX format
190 rf"S{str_season}E{episode_fill}", # SXEXX format (non-padded season)
191 rf"{season_fill}x{episode_fill}", # XXxXX format (zero-padded)
192 rf"{str_season}x{episode_fill}", # XxXX format (non-padded season)
193 rf"\.S{season_fill}E{episode_fill}", # .SXXEXX format
194 rf"\sS{season_fill}E{episode_fill}\s", # season and episode surrounded by spaces
195 r"Cap\.", # match "Cap."
196 ]
197 if episode_name:
198 patterns.append(re.escape(episode_name))
199
200 episode_results = [
201 res
202 for res in self.results
203 if re.search("|".join(patterns), res.title, re.IGNORECASE)
204 ]
205 self.results = season_pack_results + episode_results
206 return self
207
208 def filter_by_quality(self) -> "PreProcessBuilder":
209 quality_buckets: Dict[Quality, List[TorrentStream]] = {
210 quality: [] for quality in Quality
211 }
212 for res in self.results:
213 title = res.title
214 matched_quality = False
215 for quality in Quality:
216 if quality.value[0] in title:
217 res.quality = quality.value[1]
218 quality_buckets[quality].append(res)
219 matched_quality = True
220 break
221 if not matched_quality:
222 res.quality = Quality.UNKNOWN.value[1]
223 quality_buckets[Quality.UNKNOWN].append(res)
224
225 self.results = (
226 quality_buckets[Quality.ULTRA]
227 + quality_buckets[Quality.HIGH]
228 + quality_buckets[Quality.MEDIUM]
229 + quality_buckets[Quality.LOW]
230 )
231 if get_setting("unknown_quality_enabled", True):
232 self.results += quality_buckets[Quality.UNKNOWN]
233 return self
234
235 def filter_by_source(self) -> "PreProcessBuilder":
236 """
237 Categorize torrents into SourceCategory buckets and filter them
238 depending on Kodi settings (quality_filter group).
239 """
240 source_buckets: Dict[SourceCategory, List[TorrentStream]] = {
241 cat: [] for cat in SourceCategory
242 }
243
244 for res in self.results:
245 title = res.title.upper()
246 matched_category = None
247 for cat in SourceCategory:
248 for keyword in cat.value[1]:
249 if keyword and keyword.upper() in title:
250 source_buckets[cat].append(res)
251 matched_category = cat
252 break
253 if matched_category:
254 break
255 if not matched_category:
256 source_buckets[SourceCategory.UNKNOWN].append(res)
257
258 allowed_results: List[TorrentStream] = []
259 if get_setting("bluray_hd_enabled", True):
260 allowed_results.extend(source_buckets[SourceCategory.BLURAY_UHD])
261 if get_setting("web_hd_enabled", True):
262 allowed_results.extend(source_buckets[SourceCategory.WEB_HD])
263 if get_setting("dvd_tv_enabled", True):
264 allowed_results.extend(source_buckets[SourceCategory.DVD_TV_SAT])
265 if get_setting("cam_screener_enabled", True):
266 allowed_results.extend(source_buckets[SourceCategory.CAM_SCREENER])
267 if get_setting("unknown_enabled", True):
268 allowed_results.extend(source_buckets[SourceCategory.UNKNOWN])
269
270 self.results = allowed_results
271 return self
272
273 def filter_by_size(self) -> "PreProcessBuilder":
274 min_size = int(get_setting("minimum_size") or 0)
275 max_size = int(get_setting("maximum_size") or 100000)
276
277 def parse_size(res: TorrentStream) -> Optional[int]:
278 size_bytes = getattr(res, "size", None)
279 if not size_bytes:
280 return None
281 try:
282 return int(size_bytes) // (1024 * 1024) # Convert bytes → MB
283 except (ValueError, TypeError):
284 return None
285
286 filtered = []
287 for res in self.results:
288 size = parse_size(res)
289 if size is None or min_size <= size <= max_size:
290 filtered.append(res)
291
292 self.results = filtered
293 return self
plugin.video.jacktook/lib/utils/general/utils.py +1744 -0
……@@ -0,0 +1,1744 @@
1import os
2import re
3import hashlib
4import unicodedata
5import json
6from email.utils import parsedate_to_datetime
7from enum import Enum
8from urllib.parse import unquote
9import requests
10from typing import Dict, List
11from zipfile import ZipFile
12from concurrent.futures import ThreadPoolExecutor, as_completed
13from datetime import datetime, timedelta
14
15from lib.api.fanart.fanart import get_fanart
16from lib.clients.subtitle.utils import get_language_code
17from lib.gui.qr_progress_dialog import QRProgressDialog
18from lib.api.trakt.trakt_cache import trakt_watched_cache, trakt_cache
19from lib.api.trakt.lists_cache import lists_cache
20from lib.api.trakt.main_cache import main_cache
21from lib.utils.debrid.qrcode_utils import make_qrcode
22from lib.utils.general.processors import PostProcessBuilder, PreProcessBuilder
23from lib.clients.base import TorrentStream
24from lib.api.tvdbapi.tvdbapi import TVDBAPI
25from lib.db.cached import cache
26from lib.clients.stremio.constants import (
27 STREMIO_ADDONS_KEY,
28 STREMIO_ADDONS_CATALOGS_KEY,
29 STREMIO_TV_ADDONS_KEY,
30 STREMIO_USER_ADDONS,
31)
32from lib.db.pickle_db import PickleDatabase
33from lib.utils.kodi.utils import (
34 ADDON_HANDLE,
35 ADDON_PATH,
36 EPISODES_TYPE,
37 MOVIES_TYPE,
38 SEASONS_TYPE,
39 SHOWS_TYPE,
40 TITLES_TYPE,
41 build_url,
42 container_refresh,
43 copy2clip,
44 dialog_text,
45 get_jacktorr_setting,
46 get_setting,
47 get_setting_fresh,
48 kodilog,
49 make_list_item,
50 notification,
51 sleep,
52 translatePath,
53 translation,
54)
55
56from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled
57from lib.vendor.torf._magnet import Magnet
58
59from collections import deque
60
61from xbmcgui import Dialog
62from xbmcgui import DialogProgressBG
63from xbmcplugin import addDirectoryItem, setContent, setPluginCategory
64from xbmc import getSupportedMedia
65import xbmc
66
67
68pickle_db = PickleDatabase()
69
70PROVIDER_COLOR_MIN_BRIGHTNESS = 128
71
72URL_REGEX = r"^(?!\/)(rtmps?:\/\/|mms:\/\/|rtsp:\/\/|https?:\/\/|ftp:\/\/)?([^\/:]+:[^\/@]+@)?(www\.)?(?=[^\/:\s]+\.[^\/:\s]+)([^\/:\s]+\.[^\/:\s]+)(:\d+)?(\/[^#\s]*[\s\S]*)?(\?[^#\s]*)?(#.*)?$"
73
74USER_AGENT_HEADER = {
75 "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36"
76}
77
78USER_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
80TMDB_POSTER_URL = "http://image.tmdb.org/t/p/w780"
81
82MEDIA_FUSION_DEFAULT_KEY = "eJwBYACf_4hAkZJe85krAoD5hN50-2M0YuyGmgswr-cis3uap4FNnLMvSfOc4e1IcejWJmykujTnWAlQKRi9cct5k3IRqhu-wFBnDoe_QmwMjJI3FnQtFNp2u3jDo23THEEgKXHYqTMrLos="
83
84dialog_update = {"count": -1, "percent": 50}
85
86UNDESIRED_QUALITIES = (
87 "SD",
88 "CAM",
89 "TELE",
90 "SYNC",
91 "TS",
92 "HDTV",
93 "HDCAM",
94 "HDTS",
95 "HDTC",
96 "HDTVRip",
97)
98
99video_extensions = (
100 ".001",
101 ".3g2",
102 ".3gp",
103 ".asf",
104 ".asx",
105 ".avc",
106 ".avi",
107 ".avs",
108 ".bdm",
109 ".bdmv",
110 ".bin",
111 ".bivx",
112 ".dat",
113 ".divx",
114 ".dv",
115 ".dvr-ms",
116 ".evo",
117 ".f4v",
118 ".fli",
119 ".flv",
120 ".h264",
121 ".img",
122 ".iso",
123 ".m2t",
124 ".m2ts",
125 ".m2v",
126 ".m3u8",
127 ".m4v",
128 ".mk3d",
129 ".mkv",
130 ".mov",
131 ".mp4",
132 ".mpeg",
133 ".mpg",
134 ".mpl",
135 ".mpls",
136 ".mts",
137 ".nrg",
138 ".nuv",
139 ".ogm",
140 ".ogv",
141 ".pva",
142 ".qt",
143 ".rcv",
144 ".rec",
145 ".rmvb",
146 ".sdp",
147 ".svq3",
148 ".tp",
149 ".trp",
150 ".ts",
151 ".ty",
152 ".udf",
153 ".vc1",
154 ".vdr",
155 ".viv",
156 ".vob",
157 ".vp3",
158 ".webm",
159 ".wmv",
160 ".xvid",
161)
162
163non_direct_exts = {
164 ".zip",
165 ".rar",
166 ".001",
167 ".002",
168 ".strm",
169 ".ifo",
170 ".vob",
171 ".bdat",
172 ".pvr",
173 ".dvr-ms",
174 ".disc",
175 ".nrg",
176 ".img",
177 ".bin",
178 ".iso",
179}
180
181
182class Enum:
183 @classmethod
184 def values(cls):
185 return [value for name, value in vars(cls).items() if not name.startswith("_")]
186
187
188class DebridType(Enum):
189 RD = "RealDebrid"
190 PM = "Premiumize"
191 TB = "Torbox"
192 ED = "EasyDebrid"
193 DB = "Debrider"
194 AD = "AllDebrid"
195
196
197class IndexerType(Enum):
198 TORRENT = "Torrent"
199 DIRECT = "Direct"
200 DEBRID = "Debrid"
201 STREMIO_DEBRID = "Stremio"
202
203
204class Indexer(Enum):
205 PROWLARR = "Prowlarr"
206 STREMIO = "Stremio"
207 JACKETT = "Jackett"
208 TORRENTIO = "Torrentio"
209 PEERFLIX = "Peerflix"
210 MEDIAFUSION = "MediaFusion"
211 JACKGRAM = "Jackgram"
212 TELEGRAM = "Telegram"
213 ELHOSTED = "Elfhosted"
214 BURST = "Burst"
215 ZILEAN = "Zilean"
216 EASYNEWS = "Easynews"
217
218
219class SearchVariant(Enum):
220 DEFAULT = "default"
221 TITLE_YEAR = "title_year"
222 ORIGINAL_TITLE = "original_title"
223 ORIGINAL_TITLE_YEAR = "original_title_year"
224
225
226class Players(Enum):
227 JACKTORR = "Jacktorr"
228 TORREST = "Torrest"
229 ELEMENTUM = "Elementum"
230 JACKGRAM = "Jackgram"
231
232
233class Anime(Enum):
234 SEARCH = "Anime_Search"
235 POPULAR = "Anime_Popular"
236 POPULAR_RECENT = "Anime_Popular_Recent"
237 TRENDING = "Anime_Trending"
238 TRENDING_RECENT = "Anime_Trending_Recent"
239 AIRING = "Anime_On_The_Air"
240 MOST_WATCHED = "Anime_Most_Watched"
241 FAVORITED = "Anime_Favorited"
242 YEARS = "Anime_Years"
243 GENRES = "Anime_Genres"
244 TOP_RATED = "Anime_Top_Rated"
245
246
247class Animation(Enum):
248 POPULAR = "Animation_Popular"
249
250
251class Cartoons(Enum):
252 SEARCH = "Cartoons_Search"
253 POPULAR = "Cartoons_Popular"
254 POPULAR_RECENT = "Cartoons_Popular_Recent"
255 YEARS = "Cartoons_Years"
256
257
258torrent_clients = [
259 Players.JACKTORR,
260 Players.TORREST,
261 Players.ELEMENTUM,
262]
263
264torrent_indexers = [
265 Indexer.PROWLARR,
266 Indexer.JACKETT,
267 Indexer.TORRENTIO,
268 Indexer.PEERFLIX,
269 Indexer.ELHOSTED,
270 Indexer.BURST,
271]
272
273
274PROVIDER_COLORS = {
275 "debrid": "FF1E90FF", # dodger blue
276 "direct": "FFFFA500", # orange
277 "stremio": "FF8A2BE2", # blue-violet
278 "torrent": "FFFFD700", # gold
279}
280
281
282class DialogListener:
283 def __init__(self):
284 self._dialog = DialogProgressBG()
285
286 @property
287 def dialog(self):
288 return self._dialog
289
290 def __enter__(self):
291 return self
292
293 def __exit__(self, exc_type, exc_val, exc_tb):
294 try:
295 self._dialog.close()
296 except:
297 pass
298
299
300def is_debrid_activated():
301 return (
302 get_setting_fresh("real_debrid_enabled")
303 or get_setting_fresh("premiumize_enabled")
304 or get_setting_fresh("torbox_enabled")
305 or get_setting_fresh("debrider_enabled")
306 or get_setting_fresh("alldebrid_enabled")
307 )
308
309
310def check_debrid_enabled(debrid_type):
311 if debrid_type == DebridType.RD:
312 return is_rd_enabled()
313 elif debrid_type == DebridType.PM:
314 return is_pm_enabled()
315 elif debrid_type == DebridType.TB:
316 return is_tb_enabled()
317 elif debrid_type == DebridType.DB:
318 return is_debrider_enabled()
319 elif debrid_type == DebridType.AD:
320 return is_ad_enabled()
321 else:
322 kodilog(f"Unknown debrid type: {debrid_type}", level=xbmc.LOGERROR)
323
324
325def is_rd_enabled():
326 return get_setting_fresh("real_debrid_enabled")
327
328
329def is_ad_enabled():
330 return get_setting_fresh("alldebrid_enabled")
331
332
333def is_pm_enabled():
334 return get_setting_fresh("premiumize_enabled")
335
336
337def is_debrider_enabled():
338 return get_setting_fresh("debrider_enabled")
339
340
341def is_tb_enabled():
342 return get_setting_fresh("torbox_enabled")
343
344
345def is_ed_enabled():
346 return get_setting("easydebrid_enabled")
347
348
349def build_list_item(label, icon="", poster_path=""):
350 item = make_list_item(label=label)
351 item.setArt(
352 {
353 "poster": poster_path,
354 "icon": os.path.join(ADDON_PATH, "resources", "img", icon),
355 "thumb": os.path.join(ADDON_PATH, "resources", "img", icon),
356 "fanart": os.path.join(ADDON_PATH, "fanart.png"),
357 }
358 )
359 return item
360
361
362def make_listing(data):
363 title = data.get("title")
364 ids = data.get("ids", {})
365 tv_data = data.get("tv_data", {})
366 mode = data.get("mode", "")
367 mode = "episode" if mode == "tv" else "movies"
368
369 list_item = make_list_item(label=title)
370 list_item.setContentLookup(False)
371 list_item.setProperty("IsPlayable", "true")
372
373 data["episode"] = tv_data.get("episode", data.get("episode", ""))
374 data["season"] = tv_data.get("season", data.get("season", ""))
375 data["name"] = tv_data.get("name", "")
376 data["id"] = ids.get("tmdb_id")
377 data["imdb_id"] = ids.get("imdb_id")
378
379 set_media_infoTag(list_item, data=data, mode=mode, detailed=True)
380
381 return list_item
382
383
384def set_media_infoTag(list_item, data, fanart_data={}, mode="video", detailed=False):
385 mode = "movies" if mode in ("movie", "movies") else mode
386 info_tag = list_item.getVideoInfoTag()
387
388 _set_basic_info(info_tag, data)
389 _set_media_type(info_tag, mode)
390 _set_identification(info_tag, data)
391 _set_artwork(list_item, data, fanart_data)
392 _set_released_info(info_tag, data)
393 _set_trakt_ui_state(list_item, info_tag, data, mode)
394
395 if mode == "tv" or mode == "season" or mode == "episode":
396 _set_show_info(info_tag, data, mode)
397
398 if detailed:
399 _set_detailed_info(info_tag, data, mode)
400
401
402def _set_basic_info(info_tag, data):
403 info_tag.setTitle(data.get("title", data.get("name", "")))
404 info_tag.setOriginalTitle(
405 data.get("original_title", data.get("original_name", data.get("title", "")))
406 )
407 info_tag.setPlot(truncate_text(data.get("overview") or data.get("biography", "No overview")))
408
409
410def _set_media_type(info_tag, mode):
411 if mode == "movies":
412 info_tag.setMediaType("movie")
413 elif mode == "tv":
414 info_tag.setMediaType("tvshow")
415 elif mode == "season":
416 info_tag.setMediaType("season")
417 elif mode == "episode":
418 info_tag.setMediaType("episode")
419 else:
420 info_tag.setMediaType("video")
421
422
423def _set_identification(info_tag, data):
424 if "imdb_id" in data:
425 info_tag.setIMDBNumber(data["imdb_id"])
426 if "id" in data:
427 unique_ids = {
428 "tmdb": str(data.get("id", "")),
429 "imdb": data.get("imdb_id", ""),
430 "tvdb": data.get("tvdb_id", ""),
431 }
432 unique_ids = {k: str(v) for k, v in unique_ids.items() if v}
433 if unique_ids:
434 info_tag.setUniqueIDs(unique_ids, "tmdb")
435
436
437def _set_artwork(list_item, data, fanart_data):
438 set_listitem_artwork(list_item, data, fanart_data)
439
440
441def _set_released_info(info_tag, data):
442 if "first_air_date" in data:
443 first_air_date = data["first_air_date"]
444 if first_air_date:
445 info_tag.setFirstAired(first_air_date)
446 info_tag.setYear(int(first_air_date.split("-")[0]))
447 elif "air_date" in data:
448 info_tag.setFirstAired(data["air_date"])
449 elif "release_date" in data:
450 release_date = data["release_date"]
451 if release_date:
452 info_tag.setPremiered(release_date)
453 info_tag.setYear(int(release_date.split("-")[0]))
454
455 if "runtime" in data:
456 runtime = data.get("runtime")
457 if runtime:
458 info_tag.setDuration(runtime * 60)
459
460
461def _set_detailed_info(info_tag, data, mode):
462 # Rating
463 info_tag.setRating(
464 data.get("vote_average", data.get("rating", 0)),
465 votes=data.get("vote_count", data.get("votes", 0)),
466 )
467 info_tag.setUserRating(int(float(data.get("popularity", 0))))
468
469 # Classification
470 genres = data.get("genre_ids", data.get("genres", []))
471 final_genres = extract_genres(genres, mode)
472 info_tag.setGenres(final_genres)
473
474 # Countries
475 countries = list(data.get("origin_country", data.get("countries", [])))
476 if isinstance(countries, str):
477 countries = [countries]
478 info_tag.setCountries(countries)
479
480 # Cast & crew
481 info_tag.setCast(get_cast_and_crew(data))
482
483
484def _get_valid_media_id(*raw_ids):
485 for raw_id in raw_ids:
486 if raw_id in (None, ""):
487 continue
488 return str(raw_id)
489 return ""
490
491
492def _coerce_int(value):
493 try:
494 return int(value)
495 except (TypeError, ValueError):
496 return None
497
498
499def _get_runtime_seconds(data):
500 runtime = data.get("runtime")
501 if runtime in (None, ""):
502 return None
503
504 try:
505 runtime_seconds = int(float(runtime) * 60)
506 except (TypeError, ValueError):
507 return None
508
509 if runtime_seconds <= 0:
510 return None
511
512 return runtime_seconds
513
514
515def _is_valid_trakt_progress(progress):
516 try:
517 progress = float(progress)
518 except (TypeError, ValueError):
519 return False
520
521 return 0 < progress <= 100
522
523
524def _set_trakt_progress_state(list_item, info_tag, data, progress):
525 if not _is_valid_trakt_progress(progress):
526 return
527
528 progress = float(progress)
529
530 list_item.setProperty("PercentPlayed", str(progress))
531
532 total = _get_runtime_seconds(data)
533 if total is None:
534 return
535
536 current = total * progress / 100
537 info_tag.setResumePoint(current, total)
538
539
540def _set_trakt_ui_state(list_item, info_tag, data, mode):
541 normalized_mode = "movies" if mode in ("movie", "movies") else mode
542
543 if normalized_mode == "movies":
544 tmdb_id = _get_valid_media_id(data.get("tmdb_id"), data.get("id"))
545 if not tmdb_id:
546 return
547
548 is_watched = trakt_watched_cache.get_watched_status("movie", tmdb_id)
549 if is_watched:
550 info_tag.setPlaycount(1)
551 return
552
553 progress = trakt_watched_cache.get_progress("movie", tmdb_id)
554 _set_trakt_progress_state(list_item, info_tag, data, progress)
555 return
556
557 if normalized_mode != "episode":
558 return
559
560 tmdb_id = _get_valid_media_id(data.get("tmdb_id"))
561 season = _coerce_int(data.get("season"))
562 episode = _coerce_int(data.get("episode"))
563 if not tmdb_id or season is None or episode is None:
564 return
565
566 is_watched = trakt_watched_cache.get_watched_status(
567 "episode", tmdb_id, season, episode
568 )
569 if is_watched:
570 info_tag.setPlaycount(1)
571 return
572
573 progress = trakt_watched_cache.get_progress("episode", tmdb_id, season, episode)
574 _set_trakt_progress_state(list_item, info_tag, data, progress)
575
576
577def _set_show_info(info_tag, data, mode):
578 if "seasons" in data:
579 seasons = list(data["seasons"])
580 if isinstance(seasons, list):
581 named_seasons = [
582 (season.get("season_number", 0), season.get("name", ""))
583 for season in seasons
584 ]
585 info_tag.addSeasons(named_seasons)
586 else:
587 info_tag.addSeason(seasons.get("season_number", 0), seasons.get("name", ""))
588
589 if mode == "tv" or mode == "season":
590 info_tag.setTvShowTitle(data.get("title", data.get("name", "")))
591 season = _coerce_int(data.get("season", data.get("season_number", 0)))
592 if season is not None:
593 info_tag.setSeason(season)
594 elif mode == "episode":
595 info_tag.setTvShowTitle(data.get("title", data.get("name", "")))
596 season = _coerce_int(data.get("season", data.get("season_number", 0)))
597 episode = _coerce_int(data.get("episode", data.get("episode_number", 0)))
598 if season is not None:
599 info_tag.setSeason(season)
600 if episode is not None:
601 info_tag.setEpisode(episode)
602
603
604def extract_genres(genres, media_type="movies"):
605 genre_list = []
606 path = "movie_genres" if media_type == "movies" else "show_genres"
607
608 from lib.clients.tmdb.utils.utils import tmdb_get
609
610 genre_response = tmdb_get(path=path)
611 if not genre_response or "genres" not in genre_response:
612 kodilog(f"Failed to fetch genres for {media_type}", level=xbmc.LOGDEBUG)
613 return genre_list
614
615 genre_mapping = {g["id"]: g["name"] for g in genre_response.get("genres", [])}
616
617 for g in genres:
618 if isinstance(g, dict) and "name" in g: # Case: { "id": 28, "name": "Action" }
619 genre_list.append(g["name"])
620 elif isinstance(g, int):
621 genre_list.append(genre_mapping.get(g, str(g)))
622 elif isinstance(g, str):
623 try:
624 genre_id = int(g)
625 genre_list.append(genre_mapping.get(genre_id, g))
626 except ValueError:
627 genre_list.append(g)
628
629 return genre_list
630
631
632def get_cast_and_crew(data):
633 """Return combined list of xbmc.Actor objects for cast and crew."""
634 cast_list = []
635
636 casts = extract_cast(data)
637 crew = extract_crew(data)
638
639 # Build actors
640 cast_list.extend(build_actor(c, is_cast=True) for c in casts)
641 cast_list.extend(build_actor(c, is_cast=False) for c in crew)
642
643 return cast_list
644
645
646def extract_cast(data):
647 if hasattr(data, "to_dict"):
648 data = data.to_dict()
649
650 credits = data.get("credits", {})
651 if isinstance(credits, dict) and "cast" in credits:
652 return credits.get("cast", []) or []
653
654 casts = data.get("casts", {})
655 if isinstance(casts, dict) and "cast" in casts:
656 return casts.get("cast", []) or []
657
658 if "cast" in data:
659 return data.get("cast", []) or []
660
661 if "actors" in data:
662 return data.get("actors", []) or []
663
664 return []
665
666
667def extract_crew(data):
668 if hasattr(data, "to_dict"):
669 data = data.to_dict()
670
671 credits = data.get("credits", {})
672 if isinstance(credits, dict) and "crew" in credits:
673 return credits.get("crew", []) or []
674
675 if "crew" in data:
676 return data.get("crew", []) or []
677
678 return []
679
680
681def build_actor(member, is_cast=True):
682 """Build an xbmc.Actor from a cast or crew member dict."""
683 if is_cast:
684 return xbmc.Actor(
685 name=member.get("name", "Unknown"),
686 role=member.get("character", "Unknown"),
687 thumbnail=(
688 f"http://image.tmdb.org/t/p/w185{member['profile_path']}"
689 if member.get("profile_path")
690 else ""
691 ),
692 order=member.get("order", 0),
693 )
694 else:
695 return xbmc.Actor(
696 name=member.get("name", "Unknown"),
697 role=member.get("job", "Unknown"),
698 thumbnail=(
699 f"http://image.tmdb.org/t/p/w185{member['profile_path']}"
700 if member.get("profile_path")
701 else ""
702 ),
703 order=0, # Crew usually has no ordering
704 )
705
706
707def set_listitem_artwork(list_item, data, fanart_data):
708 thumb_sources = [
709 (data.get("poster_path"), "w780"),
710 (data.get("still_path"), "w780"),
711 ]
712 poster_sources = [
713 (data.get("poster_path"), "w780"),
714 (data.get("still_path"), "w780"),
715 (data.get("profile_path"), "w780"),
716 ]
717 fanart_sources = [
718 (data.get("backdrop_path"), "w1280"),
719 (data.get("still_path"), "w1280"),
720 ]
721
722 clear_logo = [(extract_tmdb_logo_url(data), "original")]
723
724 def first_valid(sources, fallback_key=""):
725 for path, size in sources:
726 url = tmdb_url(path, size)
727 if url:
728 return url
729 if fallback_key:
730 return data.get(fallback_key, "") or fanart_data.get(fallback_key, "")
731 return ""
732
733 list_item.setArt(
734 {
735 "thumb": first_valid(thumb_sources),
736 "poster": first_valid(poster_sources, "poster"),
737 "fanart": first_valid(fanart_sources, "fanart"),
738 "icon": first_valid(poster_sources),
739 "banner": first_valid(fanart_sources, "banner"),
740 "clearart": first_valid(fanart_sources, "clearart"),
741 "clearlogo": first_valid(clear_logo, "clearlogo"),
742 "tvshow.clearart": first_valid(fanart_sources, "clearart"),
743 "tvshow.clearlogo": first_valid(clear_logo, "clearlogo"),
744 "tvshow.landscape": first_valid(fanart_sources, "landscape"),
745 "tvshow.banner": first_valid(fanart_sources, "banner"),
746 }
747 )
748
749
750def extract_tmdb_logo_url(data):
751 images = data.get("images", {}) or {}
752 logos = images.get("logos", []) or []
753 if logos:
754 file_path = logos[0].get("file_path")
755 if file_path:
756 return file_path
757
758
759def tmdb_url(path, size):
760 return f"http://image.tmdb.org/t/p/{size}{path}" if path else ""
761
762
763def build_media_metadata(ids, mode: str) -> dict:
764 metadata = {
765 "poster": "",
766 "fanart": "",
767 "clearlogo": "",
768 "overview": "",
769 "clearart": "",
770 "keyart": "",
771 "banner": "",
772 "landscape": "",
773 "title": "",
774 "original_title": "",
775 "genres": [],
776 "countries": [],
777 "year": None,
778 "runtime": None,
779 "tmdb_id": 0,
780 "tvdb_id": 0,
781 "vote_average": 0,
782 "vote_count": 0,
783 "popularity": 0,
784 "cast": [],
785 }
786
787 tmdb_id = ids.get("tmdb_id", "")
788 tvdb_id = ids.get("tvdb_id", "")
789 imdb_id = ids.get("imdb_id", "")
790
791 if not tmdb_id and imdb_id:
792 from lib.clients.tmdb.utils.utils import tmdb_get
793
794 res = tmdb_get("find_by_imdb_id", imdb_id)
795 if mode == "movies" and getattr(res, "movie_results", []):
796 tmdb_id = str(res.movie_results[0]["id"])
797 ids["tmdb_id"] = tmdb_id
798 elif mode in ("tv", "episode", "season", "anime") and getattr(
799 res, "tv_results", []
800 ):
801 tmdb_id = str(res.tv_results[0]["id"])
802 ids["tmdb_id"] = tmdb_id
803
804 metadata["tmdb_id"] = str(tmdb_id)
805 metadata["tvdb_id"] = str(tvdb_id)
806
807 tmdb_logo_path = None
808
809 # TMDB details
810 if tmdb_id:
811 from lib.clients.tmdb.utils.utils import get_tmdb_media_details
812
813 details = get_tmdb_media_details(tmdb_id, mode)
814 poster_path = getattr(details, "poster_path", "")
815 metadata["poster"] = f"{TMDB_POSTER_URL}{poster_path}" if poster_path else ""
816 metadata["overview"] = getattr(details, "overview", "")
817 metadata["title"] = getattr(details, "title", getattr(details, "name", ""))
818 metadata["original_title"] = getattr(
819 details, "original_title", getattr(details, "original_name", "")
820 )
821 metadata["year"] = (
822 int(str(getattr(details, "release_date", "0"))[:4])
823 if getattr(details, "release_date", None)
824 else None
825 )
826 metadata["runtime"] = getattr(details, "runtime", None)
827 metadata["vote_average"] = getattr(details, "vote_average", 0)
828 metadata["vote_count"] = getattr(details, "vote_count", 0)
829 metadata["popularity"] = getattr(details, "popularity", 0)
830 metadata["cast"] = extract_cast(details)
831 tmdb_logo_path = extract_tmdb_logo_url(details) or ""
832 if tmdb_logo_path:
833 metadata["clearlogo"] = tmdb_url(tmdb_logo_path, "original")
834
835 backdrop_path = getattr(details, "backdrop_path", "")
836 if backdrop_path:
837 metadata["fanart"] = tmdb_url(backdrop_path, "original")
838
839 # Fanart details
840 if tmdb_id or tvdb_id:
841 fanart_details = get_fanart_details(tvdb_id=tvdb_id, tmdb_id=tmdb_id, mode=mode)
842
843 if not tmdb_logo_path and fanart_details.get("clearlogo"):
844 metadata["clearlogo"] = fanart_details.get("clearlogo")
845
846 for key in ("fanart", "clearart", "keyart", "banner", "landscape"):
847 if fanart_details.get(key):
848 metadata[key] = fanart_details.get(key)
849
850 return metadata
851
852
853def set_watched_file(data):
854 title = data.get("title", "")
855 is_torrent = data.get("is_torrent", False)
856 is_direct = data.get("type", "") == IndexerType.DIRECT
857 is_stremio = data.get("type", "") == IndexerType.STREMIO_DEBRID
858
859 if is_direct:
860 color = get_random_color("Direct", formatted=False)
861 title = f"[B][COLOR {color}][Direct][/COLOR][/B] - {title}"
862 elif is_stremio:
863 color = get_random_color("Stremio", formatted=False)
864 title = f"[B][COLOR {color}][Stremio Debrid][/COLOR][/B] - {title}"
865 elif is_torrent:
866 color = get_random_color("Torrent", formatted=False)
867 title = f"[B][COLOR {color}][Torrent][/COLOR][/B] - {title}"
868 else:
869 color = get_random_color("Cached", formatted=False)
870 title = f"[B][COLOR {color}][Cached][/COLOR][/B] - {title}"
871
872 data["timestamp"] = datetime.now().strftime("%a, %d %b %Y %I:%M %p")
873
874 pickle_db.set_item(key="jt:lfh", subkey=title, value=data)
875
876
877def set_watched_title(title, ids, mode, tg_data="", media_type="", library_data=None):
878 payload = {
879 "timestamp": datetime.now().strftime("%a, %d %b %Y %I:%M %p"),
880 "ids": ids,
881 "mode": media_type if mode == "multi" else mode,
882 "tg_data": tg_data,
883 }
884 if library_data:
885 payload.update(library_data)
886
887 pickle_db.set_item(key="jt:lth", subkey=title, value=payload)
888
889 library_payload = {
890 "title": title,
891 "ids": ids,
892 "mode": media_type if mode == "multi" else mode,
893 "tg_data": tg_data,
894 "timestamp": payload["timestamp"],
895 }
896 if library_data:
897 library_payload.update(library_data)
898 add_to_library(library_payload)
899
900
901def add_to_library(data):
902 title = data.get("title")
903 if not title:
904 return
905
906 pickle_db.set_item(key="jt:lib", subkey=title, value=data)
907 cache.delete("library_view|tv")
908 cache.delete("library_view|movies")
909
910
911def remove_from_library(title):
912 pickle_db.delete_item(key="jt:lib", subkey=title)
913 cache.delete("library_view|tv")
914 cache.delete("library_view|movies")
915
916
917def is_in_library(title):
918 return pickle_db.get_item("jt:lib", title) is not None
919
920
921def 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 )
925 cached = cache.get(identifier)
926 if cached:
927 return cached
928
929 from lib.clients.tmdb.utils.utils import LANGUAGES
930
931 try:
932 language_index = int(get_setting("language", 18))
933 lang = LANGUAGES[language_index].split("-")[0].strip()
934 except Exception:
935 lang = "en"
936
937 if mode == "tv":
938 data = get_fanart(mode, lang, tvdb_id)
939 else:
940 data = get_fanart(mode, lang, tmdb_id)
941
942 if data:
943 hours = get_cache_expiration() if is_cache_enabled() else 0
944 cache.set(identifier, data, timedelta(hours=hours))
945
946 return data or {}
947
948
949def get_cached_results(query, mode, media_type, episode, cache_scope=""):
950 if mode == "tv" or media_type == "tv" or mode == "anime":
951 return get_cached(query, params=(episode, "index", cache_scope))
952 return get_cached(query, params=("index", cache_scope))
953
954
955def cache_results(results, query, mode, media_type, episode, cache_scope=""):
956 if mode == "tv" or media_type == "tv" or mode == "anime":
957 set_cached(results, query, params=(episode, "index", cache_scope))
958 else:
959 set_cached(results, query, params=("index", cache_scope))
960
961
962def get_cached(path, params={}):
963 identifier = "{}|{}".format(path, params)
964 return cache.get(identifier)
965
966
967def set_cached(data, path, params={}):
968 identifier = "{}|{}".format(path, params)
969 cache.set(
970 identifier,
971 data,
972 timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0),
973 )
974
975
976def db_get(name, func, path, params):
977 identifier = "{}|{}".format(path, params)
978 data = cache.get(identifier)
979 if not data:
980 if name == "search_client":
981 data = func()
982 cache.set(
983 identifier,
984 data,
985 timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0),
986 )
987 return data
988
989
990def tvdb_get(path, params={}):
991 identifier = "{}|{}".format(path, params)
992 data = cache.get(identifier)
993 if data:
994 return data
995 if path == "get_imdb_id":
996 data = TVDBAPI().get_imdb_id(params)
997 cache.set(
998 identifier,
999 data,
1000 timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0),
1001 )
1002 return data
1003
1004
1005def set_pluging_category(heading: str):
1006 setPluginCategory(ADDON_HANDLE, heading)
1007
1008
1009def set_content_type(mode, media_type="movies"):
1010 if mode == "season":
1011 setContent(ADDON_HANDLE, SEASONS_TYPE)
1012 elif mode == "episode":
1013 setContent(ADDON_HANDLE, EPISODES_TYPE)
1014 elif mode == "files":
1015 setContent(ADDON_HANDLE, "files")
1016 elif mode in ("tv", "anime") or media_type == "tv":
1017 setContent(ADDON_HANDLE, SHOWS_TYPE)
1018 elif mode == "movies" or media_type == "movies":
1019 setContent(ADDON_HANDLE, MOVIES_TYPE)
1020 else:
1021 setContent(ADDON_HANDLE, TITLES_TYPE)
1022
1023
1024def get_provider_color(provider_name, formatted=True):
1025 key = provider_name.lower()
1026
1027 if key in PROVIDER_COLORS:
1028 color = PROVIDER_COLORS[key]
1029 else:
1030 color = get_random_color(provider_name, formatted=False)
1031
1032 if formatted:
1033 return f"[B][COLOR {color}]{provider_name}[/COLOR][/B]"
1034 else:
1035 return color
1036
1037
1038# This method was taken from script.elementum.jackett addon
1039def get_random_color(provider_name, formatted=True):
1040 hash = hashlib.sha256(provider_name.encode("utf")).hexdigest()
1041 colors = []
1042
1043 spec = 10
1044 for i in range(0, 3):
1045 offset = spec * i
1046 rounded = round(
1047 int(hash[offset : offset + spec], 16) / int("F" * spec, 16) * 255
1048 )
1049 colors.append(int(max(rounded, PROVIDER_COLOR_MIN_BRIGHTNESS)))
1050
1051 while (sum(colors) / 3) < PROVIDER_COLOR_MIN_BRIGHTNESS:
1052 for i in range(0, 3):
1053 colors[i] += 10
1054
1055 for i in range(0, 3):
1056 colors[i] = f"{colors[i]:02x}"
1057
1058 color_format = "FF" + "".join(colors).upper()
1059
1060 if formatted:
1061 return f"[B][COLOR {color_format}]{provider_name}[/COLOR][/B]"
1062 else:
1063 return color_format
1064
1065
1066def get_colored_languages(languages):
1067 if not languages:
1068 return ""
1069 return " ".join(get_random_color(lang) for lang in languages)
1070
1071
1072def execute_thread_pool(results, func, *args, **kwargs):
1073 thread_number = get_setting("thread_number", 8)
1074 with ThreadPoolExecutor(max_workers=int(thread_number)) as executor:
1075 futures = [executor.submit(func, res, *args, **kwargs) for res in results]
1076 for future in as_completed(futures):
1077 try:
1078 future.result()
1079 except Exception as e:
1080 kodilog(f"Thread pool error: {e}")
1081
1082
1083def execute_thread_pool_collection(results, func, *args, **kwargs):
1084 thread_number = get_setting("thread_number", 6)
1085 collected = []
1086
1087 def wrapper(res):
1088 try:
1089 result = func(res, *args, **kwargs)
1090 if result is not None:
1091 collected.append(result)
1092 except Exception as e:
1093 kodilog(f"Thread pool error: {e}")
1094
1095 with ThreadPoolExecutor(max_workers=int(thread_number)) as executor:
1096 executor.map(wrapper, results)
1097
1098 return collected
1099
1100
1101def paginate_list(data, page_size=10):
1102 for i in range(0, len(data), page_size):
1103 yield data[i : i + page_size]
1104
1105
1106def clear_cache_on_update():
1107 clear_history_by_type(update=True)
1108
1109
1110def clear_all_cache():
1111 cache.clean_all()
1112
1113
1114def clear_trakt_db_cache():
1115 trakt_cache.clear_all()
1116 trakt_watched_cache.clear_all()
1117 lists_cache.delete_all_lists()
1118 main_cache.delete_all()
1119 notification(translation(30244))
1120
1121
1122def clear_tmdb_cache():
1123 prefixes = [
1124 "search_%",
1125 "movie_%",
1126 "tv_%",
1127 "season_%",
1128 "episode_%",
1129 "discover_%",
1130 "trending_%",
1131 "popular_%",
1132 "person_%",
1133 "find_%",
1134 "anime_%",
1135 "collection_%",
1136 "get_imdb_id%",
1137 "latest_%",
1138 ]
1139 cache.delete("movie_genres|None")
1140 cache.delete("show_genres|None")
1141
1142 for prefix in prefixes:
1143 cache.delete_like(f"{prefix}|%")
1144 notification(translation(30240))
1145
1146
1147def clear_database_cache():
1148 clear_history_by_type("all")
1149
1150
1151def clear_stremio_cache():
1152 cache.delete(STREMIO_ADDONS_KEY)
1153 cache.delete(STREMIO_ADDONS_CATALOGS_KEY)
1154 cache.delete(STREMIO_TV_ADDONS_KEY)
1155 cache.delete(STREMIO_USER_ADDONS)
1156
1157 cache.delete_like("search_catalog%")
1158 cache.delete_like("list_catalog%")
1159 cache.delete_like("list_stremio_%")
1160 notification(translation(30244))
1161
1162
1163def clear_debrid_cache():
1164 cache.delete_like("%|%deb%")
1165 cache.delete_like("real_debrid%")
1166 cache.delete_like("premiumize%")
1167 cache.delete_like("all_debrid%")
1168 cache.delete_like("torbox%")
1169 cache.delete_like("debrider%")
1170 notification(translation(30244))
1171
1172
1173def clear_mdblist_cache():
1174 cache.delete("get_user_lists|None")
1175 cache.delete("top_mdbd_lists|None")
1176
1177 cache.delete_like("get_user_lists%")
1178 cache.delete_like("get_list_items%")
1179 cache.delete_like("top_mdbd_lists%")
1180 cache.delete_like("search_lists%")
1181 notification(translation(30244))
1182
1183
1184def clear_history_by_type(type="all", update=False):
1185 if update:
1186 msg = translation(90110)
1187 else:
1188 msg = translation(90111)
1189 confirmed = Dialog().yesno(translation(90112), msg)
1190 if confirmed:
1191 keys = []
1192 if type == "lth":
1193 keys = ["jt:lth"]
1194 elif type == "lfh":
1195 keys = ["jt:lfh"]
1196 else:
1197 keys = ["jt:lth", "jt:lfh"]
1198
1199 for key in keys:
1200 pickle_db.set_key(key, {})
1201
1202 container_refresh()
1203
1204
1205def limit_results(results):
1206 limit = int(get_setting("indexers_total_results", 10))
1207 return results[:limit]
1208
1209
1210def get_description_length():
1211 return int(get_setting("indexers_desc_length", 0))
1212
1213
1214def truncate_text(text, max_length=None):
1215 if max_length is None:
1216 max_length = get_description_length()
1217 if not max_length or max_length <= 0:
1218 return text
1219 if not text:
1220 return text
1221 if len(text) <= max_length:
1222 return text
1223 return text[:max_length].rstrip() + "..."
1224
1225
1226def remove_duplicate(results):
1227 seen_values = []
1228 result_dict = []
1229 for res in results:
1230 if res not in seen_values:
1231 result_dict.append(res)
1232 seen_values.append(res)
1233 return result_dict
1234
1235
1236def unzip(zip_location, destination_location, destination_check):
1237 try:
1238 zipfile = ZipFile(zip_location)
1239 zipfile.extractall(path=destination_location)
1240 if os.path.exists(destination_check):
1241 status = True
1242 else:
1243 status = False
1244 except:
1245 status = False
1246 return status
1247
1248
1249def pre_process(
1250 results: List[TorrentStream],
1251 mode: str,
1252 episode_name: str,
1253 episode: int,
1254 season: int,
1255 skip_episode_filter: bool = False,
1256) -> List[TorrentStream]:
1257 builder = PreProcessBuilder(results).remove_duplicates()
1258 if mode == "tv" and not skip_episode_filter:
1259 builder.filter_sources(episode_name, episode, season)
1260 builder.filter_by_source()
1261 builder.filter_by_quality()
1262 if get_setting("filter_size_enabled"):
1263 builder.filter_by_size()
1264 results = builder.get_results()
1265 return results
1266
1267
1268def post_process(results: List[TorrentStream], season: int = 0) -> List[TorrentStream]:
1269 return (
1270 PostProcessBuilder(results)
1271 .check_season_pack(season)
1272 .sort_results()
1273 .limit_results()
1274 .get_results()
1275 )
1276
1277
1278def filter_debrid_episode(
1279 files, episode_num: int, season_num: int, strict: bool = True
1280) -> List[Dict]:
1281 str_season, str_episode = str(season_num), str(episode_num)
1282 season_fill, episode_fill = str_season.zfill(2), str_episode.zfill(2)
1283 ep_plus_1 = str(episode_num + 1).zfill(2)
1284 ep_minus_1 = str(episode_num - 1).zfill(2)
1285
1286 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 )
1294
1295 # Normalize filenames for matching
1296 def normalize_title(title):
1297 title = unquote(title).replace("'", "")
1298 title = re.sub(r"[^A-Za-z0-9-]+", ".", title)
1299 return title.lower()
1300
1301 string_list = []
1302
1303 # SXXEYY variants and S2 - 11 / S02.11
1304 for s, e in [
1305 (season_fill, episode_fill),
1306 (str_season, episode_fill),
1307 (season_fill, str_episode),
1308 (str_season, str_episode),
1309 ]:
1310 string_list.append(rf"s{s}[.-]?e{e}")
1311 string_list.append(rf"s{s}[.\s-]?{e}") # S2 - 11 or S02.11
1312
1313 # Season X Episode Y or SxE patterns
1314 for s, e in [
1315 (season_fill, episode_fill),
1316 (str_season, episode_fill),
1317 (season_fill, str_episode),
1318 (str_season, str_episode),
1319 ]:
1320 string_list.append(rf"(season[.-]?{s}[.-]?episode[.-]?{e})")
1321 string_list.append(rf"{s}[x.]?{e}") # 2x11 or 02.11
1322
1323 # Episode ±1
1324 string_list.append(rf"s{season_fill}e{ep_minus_1}[.-]?e{episode_fill}")
1325 string_list.append(rf"s{season_fill}e{episode_fill}[.-]?e{ep_plus_1}")
1326
1327 # Episode-only patterns
1328 string_list.append(rf"episode[.-]?{episode_fill}")
1329 string_list.append(rf"[.-]ep[.-]?{episode_fill}")
1330 string_list.append(r"cap\.")
1331
1332 # Optional strict negative lookahead to avoid false positives like 10x11 matching 1x01
1333 if strict:
1334 lookahead = rf"^(?=.*\.e?0*{episode_fill}\.)(?:(?!((?:s|season)[.-]?\d+[.-x]?(?:ep?|episode)[.-]?\d+)|\d+x\d+).)*$"
1335 string_list.append(lookahead)
1336
1337 combined_pattern = "|".join(string_list)
1338 try:
1339 regex = re.compile(combined_pattern, re.IGNORECASE)
1340 except re.error as e:
1341 kodilog(f"Regex compilation failed: {e}")
1342 return files
1343
1344 filtered = [f for f in files if regex.search(normalize_title(get_filename(f)))]
1345 return filtered
1346
1347
1348def clean_auto_play_undesired(results: List[TorrentStream]) -> List[TorrentStream]:
1349 return [
1350 r
1351 for r in results
1352 if not getattr(r, "isPack", False)
1353 and not any(u.lower() in r.title.lower() for u in UNDESIRED_QUALITIES)
1354 ]
1355
1356
1357def is_torrent_url(uri):
1358 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
1366
1367
1368def supported_video_extensions():
1369 media_types = getSupportedMedia("video").split("|")
1370 return [ext for ext in media_types if ext not in non_direct_exts]
1371
1372
1373def add_next_button(func_name, page=1, **kwargs):
1374 list_item = make_list_item(label="Next")
1375 list_item.setArt(
1376 {"icon": os.path.join(ADDON_PATH, "resources", "img", "nextpage.png")}
1377 )
1378
1379 page += 1
1380 addDirectoryItem(
1381 ADDON_HANDLE,
1382 build_url(func_name, page=page, **kwargs),
1383 list_item,
1384 isFolder=True,
1385 )
1386
1387
1388def is_video(s):
1389 return s.lower().endswith(video_extensions)
1390
1391
1392def get_info_hash_from_magnet(magnet):
1393 return Magnet.from_string(magnet).infohash
1394
1395
1396def is_magnet_link(link):
1397 if link.startswith("magnet:?"):
1398 return link
1399
1400
1401def is_url(url):
1402 return bool(re.match(URL_REGEX, url))
1403
1404
1405def info_hash_to_magnet(info_hash):
1406 return f"magnet:?xt=urn:btih:{info_hash}"
1407
1408
1409def get_state_string(state):
1410 if 0 <= state <= 9:
1411 return translation(30650 + state)
1412 return translation(30660)
1413
1414
1415def get_service_host():
1416 return get_jacktorr_setting("service_host")
1417
1418
1419def get_username():
1420 return get_jacktorr_setting("service_login")
1421
1422
1423def get_password():
1424 return get_jacktorr_setting("service_password")
1425
1426
1427def ssl_enabled():
1428 return bool(get_jacktorr_setting("ssl_connection"))
1429
1430
1431def get_port():
1432 return get_jacktorr_setting("service_port")
1433
1434
1435def unicode_flag_to_country_code(unicode_flag):
1436 if len(unicode_flag) != 2:
1437 return "Invalid flag Unicode"
1438
1439 first_letter = unicodedata.name(unicode_flag[0]).replace(
1440 "REGIONAL INDICATOR SYMBOL LETTER ", ""
1441 )
1442 second_letter = unicodedata.name(unicode_flag[1]).replace(
1443 "REGIONAL INDICATOR SYMBOL LETTER ", ""
1444 )
1445
1446 country_code = first_letter.lower() + second_letter.lower()
1447 return country_code
1448
1449
1450def debrid_dialog_update(type, total, dialog, lock):
1451 with lock:
1452 dialog_update["count"] += 1
1453 dialog_update["percent"] += 2
1454
1455 dialog.update(
1456 dialog_update.get("percent"),
1457 f"Jacktook [COLOR FFFF6B00]Debrid-{type}[/COLOR]",
1458 f"Checking: {dialog_update.get('count')}/{total}",
1459 )
1460
1461
1462def get_public_ip():
1463 public_ip = cache.get("public_ip")
1464 if public_ip:
1465 return public_ip
1466
1467 urls = ["https://ipconfig.io/ip", "https://api.ipify.org/"]
1468
1469 for url in urls:
1470 try:
1471 response = requests.get(url, timeout=5)
1472 response.raise_for_status()
1473 public_ip = response.text.strip()
1474 cache.set(
1475 "public_ip",
1476 public_ip,
1477 timedelta(hours=get_cache_expiration()),
1478 )
1479 return public_ip
1480 except requests.RequestException as e:
1481 kodilog(f"Error getting public IP from {url}: {e}")
1482
1483 return None
1484
1485
1486def export_to_kodi_paste(log_content):
1487 try:
1488 response = requests.post(
1489 "https://paste.kodi.tv/documents",
1490 data=log_content,
1491 headers=USER_AGENT_HEADER,
1492 )
1493 kodilog(f"Paste.kodi.tv response: {response.status_code} - {response.text}")
1494 if response.status_code == 200 and response.text:
1495 paste_id = response.json().get("key", {})
1496 if paste_id:
1497 return f"https://paste.kodi.tv/{paste_id}"
1498 return None
1499 except Exception as e:
1500 kodilog(f"Error exporting logs to paste.kodi.tv: {e}")
1501 return None
1502
1503
1504def show_log_export_dialog(params):
1505 log_file = params.get("log_file")
1506 kodi_log_path = os.path.join(translatePath("special://logpath"), log_file)
1507 if os.path.exists(kodi_log_path):
1508 try:
1509 with open(kodi_log_path, "r", encoding="utf-8", errors="ignore") as f:
1510 lines = deque(f, maxlen=500)
1511
1512 content = "".join(reversed(lines))
1513
1514 # Ask user if they want to export
1515 from xbmcgui import Dialog
1516
1517 dialog = Dialog()
1518 choice = dialog.select(
1519 translation(90566),
1520 [translation(90567), translation(90568)],
1521 )
1522 if choice == 1:
1523 paste_url = export_to_kodi_paste(content)
1524 qr_code = make_qrcode(paste_url)
1525 copy2clip(paste_url)
1526 progressDialog = QRProgressDialog("qr_dialog.xml", ADDON_PATH)
1527 progressDialog.setup(
1528 translation(90569),
1529 qr_code,
1530 paste_url,
1531 is_debrid=False,
1532 )
1533 if paste_url:
1534 count = 20
1535 progressDialog.show_dialog()
1536 while not progressDialog.iscanceled and count >= 0:
1537 try:
1538 count -= 1
1539 progressDialog.update_progress(count)
1540 except:
1541 pass
1542 sleep(1000 * count)
1543 else:
1544 notification(translation(90601))
1545 else:
1546 dialog_text(translation(90566), content)
1547 except Exception as e:
1548 notification(translation(90602) % e)
1549 else:
1550 notification(translation(90603))
1551
1552
1553def extract_publish_date(date):
1554 if not date:
1555 return ""
1556 match = re.search(r"\d{4}-\d{2}-\d{2}", date)
1557 return match.group() if match else ""
1558
1559
1560def extract_release_group(title):
1561 if not title:
1562 return ""
1563 # Remove file extension if present
1564 title = re.sub(r"\.[a-z0-9]{3,4}$", "", title, flags=re.IGNORECASE)
1565 # Search for -GroupName at the end (common in scene releases)
1566 match = re.search(r"-([a-zA-Z0-9]+)$", title)
1567 if match:
1568 return match.group(1)
1569 # Search for [GroupName] at the beginning (common in anime/p2p)
1570 match = re.search(r"^\[([a-zA-Z0-9]+)\]", title)
1571 if match:
1572 return match.group(1)
1573 return ""
1574
1575
1576def translate_weekday(weekday_name, lang="eng"):
1577 sub_language = str(get_setting("subtitle_language"))
1578 if sub_language and sub_language.lower() != "none":
1579 lang = get_language_code(sub_language)
1580 return WEEKDAY_TRANSLATIONS.get(lang, WEEKDAY_TRANSLATIONS["eng"]).get(
1581 weekday_name, weekday_name
1582 )
1583
1584
1585WEEKDAY_TRANSLATIONS = {
1586 "eng": {
1587 "Monday": "Monday",
1588 "Tuesday": "Tuesday",
1589 "Wednesday": "Wednesday",
1590 "Thursday": "Thursday",
1591 "Friday": "Friday",
1592 "Saturday": "Saturday",
1593 "Sunday": "Sunday",
1594 },
1595 "spa": {
1596 "Monday": "Lunes",
1597 "Tuesday": "Martes",
1598 "Wednesday": "Miércoles",
1599 "Thursday": "Jueves",
1600 "Friday": "Viernes",
1601 "Saturday": "Sábado",
1602 "Sunday": "Domingo",
1603 },
1604 "por": {
1605 "Monday": "Segunda-feira",
1606 "Tuesday": "Terça-feira",
1607 "Wednesday": "Quarta-feira",
1608 "Thursday": "Quinta-feira",
1609 "Friday": "Sexta-feira",
1610 "Saturday": "Sábado",
1611 "Sunday": "Domingo",
1612 },
1613}
1614
1615
1616def parse_time(item):
1617 ts = item[1].get("timestamp")
1618 if ts:
1619 try:
1620 normalized = (
1621 unicodedata.normalize("NFKC", ts)
1622 .replace("a. m.", "AM")
1623 .replace("p. m.", "PM")
1624 .replace("a.m.", "AM")
1625 .replace("p.m.", "PM")
1626 .replace("a. m", "AM")
1627 .replace("p. m", "PM")
1628 )
1629 normalized = re.sub(r"\s+", " ", normalized).strip()
1630 day_replacements = {
1631 "lun": "Mon",
1632 "mar": "Tue",
1633 "mie": "Wed",
1634 "mié": "Wed",
1635 "jue": "Thu",
1636 "vie": "Fri",
1637 "sab": "Sat",
1638 "sáb": "Sat",
1639 "dom": "Sun",
1640 }
1641 month_replacements = {
1642 "ene": "Jan",
1643 "feb": "Feb",
1644 "mar": "Mar",
1645 "abr": "Apr",
1646 "may": "May",
1647 "jun": "Jun",
1648 "jul": "Jul",
1649 "ago": "Aug",
1650 "sep": "Sep",
1651 "oct": "Oct",
1652 "nov": "Nov",
1653 "dic": "Dec",
1654 }
1655
1656 if "," in normalized:
1657 weekday_part, remainder = normalized.split(",", 1)
1658 weekday_key = weekday_part.strip().lower()
1659 normalized = "{},{}".format(
1660 day_replacements.get(weekday_key, weekday_part.strip()), remainder
1661 )
1662
1663 normalized = re.sub(
1664 r"(?<=,\s\d{2}\s)([A-Za-zÁÉÍÓÚáéíóú]{3})(?=\s\d{4})",
1665 lambda match: month_replacements.get(match.group(1).lower(), match.group(1)),
1666 normalized,
1667 )
1668
1669 try:
1670 return datetime.strptime(normalized, "%a, %d %b %Y %I:%M %p")
1671 except Exception:
1672 parsed = parsedate_to_datetime(normalized)
1673 if parsed:
1674 return parsed.replace(tzinfo=None) if parsed.tzinfo else parsed
1675 except Exception as e:
1676 kodilog(e)
1677 return datetime.min
1678 return datetime.min
1679
1680
1681def format_season_episode(season, episode):
1682 def normalize(value):
1683 if value in (None, ""):
1684 return None
1685 if isinstance(value, str):
1686 value = value.strip()
1687 if not value:
1688 return None
1689 try:
1690 numeric_value = float(value)
1691 except (TypeError, ValueError):
1692 return str(value)
1693 if numeric_value.is_integer():
1694 return int(numeric_value)
1695 return str(value)
1696
1697 season_value = normalize(season)
1698 episode_value = normalize(episode)
1699
1700 if isinstance(season_value, int) and isinstance(episode_value, int):
1701 return "S{:02d}E{:02d}".format(season_value, episode_value)
1702 if season_value is not None and episode_value is not None:
1703 return "S{}E{}".format(season_value, episode_value)
1704 if season_value is not None:
1705 if isinstance(season_value, int):
1706 return "S{:02d}".format(season_value)
1707 return "S{}".format(season_value)
1708 if episode_value is not None:
1709 if isinstance(episode_value, int):
1710 return "E{:02d}".format(episode_value)
1711 return "E{}".format(episode_value)
1712 return ""
1713
1714
1715def safe_json_loads(value, default=None):
1716 """
1717 Safely load JSON string. Returns default if parsing fails or value is None/empty.
1718 """
1719 if default is None:
1720 default = {}
1721
1722 if not value:
1723 return default
1724
1725 try:
1726 return json.loads(value)
1727 except (json.JSONDecodeError, TypeError, ValueError):
1728 return default
1729
1730
1731def normalize_tv_data(tv_data):
1732 if not isinstance(tv_data, dict):
1733 return {}
1734
1735 normalized = dict(tv_data)
1736
1737 for key in ("season", "episode"):
1738 value = _coerce_int(normalized.get(key))
1739 if value is None:
1740 normalized.pop(key, None)
1741 else:
1742 normalized[key] = value
1743
1744 return normalized
plugin.video.jacktook/lib/utils/kodi/kodi_formats.py +71 -0
……@@ -0,0 +1,71 @@
1video_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')
7
8music_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')
13
14picture_extensions = (
15 '.apng', '.bmp', '.cbz', '.gif', '.ico', '.jp2', '.jpeg', '.jpg', '.pcx', '.png', '.tga', '.tif', '.tiff', '.webp')
16
17subtitle_extensions = (
18 '.aqt', '.ass', '.idx', '.jss', '.rt', '.smi', '.srt', '.ssa', '.sub', '.text', '.txt', '.utf', '.utf-8', '.utf8')
19
20text_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')
48
49
50def _contains_extension(s, extensions):
51 return s.lower().endswith(extensions)
52
53
54def is_video(s):
55 return _contains_extension(s, video_extensions)
56
57
58def is_music(s):
59 return _contains_extension(s, music_extensions)
60
61
62def is_picture(s):
63 return _contains_extension(s, picture_extensions)
64
65
66def is_subtitle(s):
67 return _contains_extension(s, subtitle_extensions)
68
69
70def is_text(s):
71 return _contains_extension(s, text_extensions)
plugin.video.jacktook/lib/utils/kodi/last_files_actions.py +44 -0
……@@ -0,0 +1,44 @@
1from lib.utils.kodi.utils import (
2 action_url_run,
3 kodi_play_media,
4 translation,
5)
6import json
7
8
9def add_last_files_context_menu(data):
10 from lib.utils.general.utils import IndexerType
11
12 menu_items = []
13
14 if data.get("type") != IndexerType.STREMIO_DEBRID:
15 menu_items.append(
16 (
17 translation(90084),
18 action_url_run(
19 "resolve_for_pack_selection",
20 data=json.dumps(data),
21 ),
22 )
23 )
24
25 menu_items.extend(
26 [
27 (
28 translation(90083),
29 action_url_run(
30 name="download_video",
31 data=json.dumps(data),
32 ),
33 ),
34 (
35 translation(90082),
36 kodi_play_media(
37 "resolve_for_subtitles",
38 data=json.dumps(data),
39 ),
40 ),
41 ]
42 )
43
44 return menu_items
plugin.video.jacktook/lib/utils/kodi/logging.py +46 -0
……@@ -0,0 +1,46 @@
1import re
2from urllib.parse import urlsplit
3
4import xbmc
5
6
7_ERROR_PREFIX_RE = re.compile(
8 r"^(?:\[[^\]]+\]\s*)?(?:error|failed|traceback|exception|crash)\b",
9 re.IGNORECASE,
10)
11
12
13def _resolve_log_level(message, level):
14 if level is not None:
15 return level
16 if isinstance(message, BaseException):
17 return xbmc.LOGERROR
18 if _ERROR_PREFIX_RE.match(str(message)):
19 return xbmc.LOGERROR
20 return xbmc.LOGDEBUG
21
22
23def kodilog(message, level=None):
24 xbmc.log("[###JACKTOOKLOG###] " + str(message), _resolve_log_level(message, level))
25
26
27def summarize_locator_for_log(value):
28 text = str(value or "").strip()
29 if not text:
30 return ""
31
32 magnet_match = re.search(r"btih:([A-Fa-f0-9]{8,40})", text)
33 if text.startswith("magnet:?"):
34 if magnet_match:
35 return "magnet:{}".format(magnet_match.group(1).lower()[:12])
36 return "magnet"
37
38 if re.fullmatch(r"[A-Fa-f0-9]{40}", text):
39 return "infohash:{}".format(text.lower()[:12])
40
41 if text.startswith(("http://", "https://")):
42 parts = urlsplit(text)
43 path = parts.path or "/"
44 return "{}://{}{}".format(parts.scheme, parts.netloc, path)
45
46 return text[:80]
plugin.video.jacktook/lib/utils/kodi/settings.py +57 -0
……@@ -0,0 +1,57 @@
1from .utils import get_setting, get_property, ADDON_ID
2import xbmc
3
4
5EMPTY_USER = "unknown_user"
6
7
8def addon_settings():
9 return xbmc.executebuiltin(f"Addon.OpenSettings({ADDON_ID})")
10
11
12def auto_play_enabled():
13 return get_setting("auto_play")
14
15
16def get_int_setting(setting):
17 return int(get_setting(setting))
18
19
20def update_delay(default=45):
21 return default
22
23
24def is_cache_enabled():
25 return get_setting("cache_enabled")
26
27
28def cache_clear_update():
29 return get_setting("clear_cache_update")
30
31
32def get_cache_expiration():
33 return get_int_setting("cache_expiration")
34
35
36def get_jackett_timeout():
37 return get_int_setting("jackett_timeout")
38
39
40def get_prowlarr_timeout():
41 return get_int_setting("prowlarr_timeout")
42
43
44def trakt_client():
45 return get_setting("trakt_client", "")
46
47
48def trakt_secret():
49 return get_setting("trakt_secret", "")
50
51
52def trakt_lists_sort_order(setting):
53 return int(get_setting("trakt_sort_%s" % setting, "0"))
54
55
56def get_update_action():
57 return int(get_setting("update_action", "0"))
plugin.video.jacktook/lib/utils/kodi/settings_backup.py +523 -0
……@@ -0,0 +1,523 @@
1import json
2import os
3from datetime import datetime, timedelta, timezone
4import xml.etree.ElementTree as ET
5
6import requests
7import xbmcgui
8
9from lib.clients.stremio.constants import (
10 STREMIO_ADDONS_CATALOGS_KEY,
11 STREMIO_ADDONS_KEY,
12 STREMIO_TV_ADDONS_KEY,
13 STREMIO_USER_ADDONS,
14 decode_selected_ids,
15 encode_selected_ids,
16)
17from lib.api.stremio.addon_manager import build_addon_instance_key
18from lib.db.cached import cache
19from lib.db.pickle_db import PickleDatabase
20from lib.utils.general.utils import (
21 clear_all_cache,
22 clear_debrid_cache,
23 clear_mdblist_cache,
24 clear_stremio_cache,
25 clear_tmdb_cache,
26 clear_trakt_db_cache,
27)
28from lib.utils.kodi.utils import (
29 ADDON,
30 ADDON_ID,
31 ADDON_PATH,
32 ADDON_PROFILE_PATH,
33 ADDON_VERSION,
34 dialog_ok,
35 get_cached_setting_property,
36 get_setting,
37 notification,
38 set_cached_setting_property,
39 set_setting,
40 translation,
41 translatePath,
42)
43
44
45BACKUP_VERSION = 1
46CUSTOM_ADDON_EXPIRY = timedelta(days=365 * 20)
47SETTINGS_XML_PATH = os.path.join(ADDON_PATH, "resources", "settings.xml")
48EXPORT_FILENAME_TEMPLATE = "jacktook-settings-backup-{timestamp}.json"
49RESTORE_SOURCE_FILE = "file"
50RESTORE_SOURCE_URL = "url"
51
52CUSTOM_SELECTION_KEYS = {
53 "stream": STREMIO_ADDONS_KEY,
54 "catalog": STREMIO_ADDONS_CATALOGS_KEY,
55 "tv": STREMIO_TV_ADDONS_KEY,
56}
57
58SENSITIVE_SETTING_IDS = {
59 "real_debrid_token",
60 "alldebrid_token",
61 "debrider_token",
62 "torbox_token",
63 "premiumize_token",
64 "easynews_password",
65 "stremio_pass",
66 "jackett_apikey",
67 "prowlarr_apikey",
68 "jackgram_token",
69 "deepl_api_key",
70 "fanart_client_key",
71 "tmdb_api_key",
72 "mdblist_api_key",
73 "trakt_secret",
74 "webdav_password",
75}
76
77IDENTITY_SETTING_IDS = {
78 "stremio_email",
79 "easynews_user",
80 "webdav_username",
81 "real_debrid_user",
82 "alldebrid_user",
83 "torbox_user",
84 "trakt_user",
85}
86
87AUTH_STATE_SETTING_IDS = {
88 "real_debid_authorized",
89 "alldebrid_authorized",
90 "debrider_authorized",
91 "stremio_loggedin",
92 "is_trakt_auth",
93}
94
95SCRUBBED_SETTING_IDS = (
96 SENSITIVE_SETTING_IDS | IDENTITY_SETTING_IDS | AUTH_STATE_SETTING_IDS
97)
98
99EXTRA_DYNAMIC_SETTINGS = {
100 "trakt_token": "",
101 "trakt_refresh": "",
102 "trakt_expires": "",
103}
104
105SENSITIVE_DYNAMIC_SETTING_IDS = set(EXTRA_DYNAMIC_SETTINGS)
106
107
108def _stringify_setting_value(value):
109 if value is None:
110 return ""
111 if isinstance(value, bool):
112 return "true" if value else "false"
113 return str(value)
114
115
116def _load_settings_schema(settings_xml_path=SETTINGS_XML_PATH):
117 root = ET.parse(settings_xml_path).getroot()
118 schema = {}
119 for setting in root.findall(".//setting"):
120 setting_id = setting.get("id")
121 setting_type = setting.get("type")
122 if not setting_id or setting_type == "action":
123 continue
124 default_node = setting.find("default")
125 default_value = ""
126 if default_node is not None:
127 default_value = default_node.text or ""
128 schema[setting_id] = {
129 "type": setting_type or "string",
130 "default": default_value,
131 }
132 return schema
133
134
135def _setting_clear_value(setting_id, schema_entry):
136 if setting_id in AUTH_STATE_SETTING_IDS:
137 return "false"
138 if setting_id == "trakt_user":
139 return schema_entry.get("default") or "unknown_user"
140 return schema_entry.get("default", "")
141
142
143def _custom_addon_key(addon):
144 return build_addon_instance_key(addon) or None
145
146
147def _get_custom_stremio_addons(user_addons=None):
148 user_addons = user_addons if user_addons is not None else (cache.get(STREMIO_USER_ADDONS) or [])
149 custom_addons = []
150 for addon in user_addons:
151 if addon.get("transportName") != "custom":
152 continue
153 if not _custom_addon_key(addon):
154 continue
155 custom_addons.append(addon)
156 return custom_addons
157
158
159def _get_custom_selection_map(custom_addons):
160 custom_keys = {
161 addon_key for addon_key in (_custom_addon_key(addon) for addon in custom_addons) if addon_key
162 }
163 selections = {}
164 for label, cache_key in CUSTOM_SELECTION_KEYS.items():
165 selected_keys = decode_selected_ids(cache.get(cache_key))
166 selections[label] = [key for key in selected_keys if key in custom_keys]
167 return selections
168
169
170def build_backup_payload(strip_sensitive=False, settings_xml_path=SETTINGS_XML_PATH):
171 schema = _load_settings_schema(settings_xml_path)
172 settings_payload = {}
173 for setting_id in schema:
174 if strip_sensitive and setting_id in SCRUBBED_SETTING_IDS:
175 continue
176 settings_payload[setting_id] = ADDON.getSetting(setting_id)
177
178 cache_payload = {}
179 if not strip_sensitive:
180 custom_addons = _get_custom_stremio_addons()
181 cache_payload["custom_stremio_addons"] = custom_addons
182 cache_payload["custom_stremio_selections"] = _get_custom_selection_map(custom_addons)
183
184 dynamic_settings_payload = {}
185 for setting_id, default_value in EXTRA_DYNAMIC_SETTINGS.items():
186 if strip_sensitive and setting_id in SENSITIVE_DYNAMIC_SETTING_IDS:
187 continue
188 dynamic_settings_payload[setting_id] = (
189 get_cached_setting_property(setting_id) or default_value
190 )
191
192 return {
193 "backup_version": BACKUP_VERSION,
194 "addon_id": ADDON_ID,
195 "addon_version": ADDON_VERSION,
196 "exported_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
197 "strip_sensitive": bool(strip_sensitive),
198 "custom_stremio_addons_included": not bool(strip_sensitive),
199 "settings": settings_payload,
200 "dynamic_settings": dynamic_settings_payload,
201 "cache": cache_payload,
202 }
203
204
205def _ensure_backup_payload(payload):
206 if not isinstance(payload, dict):
207 raise ValueError("Backup payload must be an object")
208
209 settings_payload = payload.get("settings")
210 dynamic_settings_payload = payload.get("dynamic_settings", {})
211 cache_payload = payload.get("cache", {})
212 if not isinstance(settings_payload, dict):
213 raise ValueError("Backup settings must be an object")
214 if not isinstance(dynamic_settings_payload, dict):
215 raise ValueError("Backup dynamic settings must be an object")
216 if not isinstance(cache_payload, dict):
217 raise ValueError("Backup cache must be an object")
218
219 custom_addons = cache_payload.get("custom_stremio_addons", [])
220 if not isinstance(custom_addons, list):
221 raise ValueError("Custom Stremio addons must be a list")
222
223 custom_selections = cache_payload.get("custom_stremio_selections", {})
224 if not isinstance(custom_selections, dict):
225 raise ValueError("Custom Stremio selections must be an object")
226
227 return settings_payload, dynamic_settings_payload, cache_payload
228
229
230def _normalize_custom_addons(custom_addons):
231 normalized = []
232 seen_keys = set()
233 for addon in custom_addons:
234 if not isinstance(addon, dict):
235 continue
236 addon = dict(addon)
237 addon["transportName"] = "custom"
238 addon_key = _custom_addon_key(addon)
239 if not addon_key or addon_key in seen_keys:
240 continue
241 seen_keys.add(addon_key)
242 normalized.append(addon)
243 return normalized
244
245
246def _filtered_custom_selection_map(cache_payload, custom_addons):
247 custom_keys = {
248 addon_key for addon_key in (_custom_addon_key(addon) for addon in custom_addons) if addon_key
249 }
250 raw_selections = cache_payload.get("custom_stremio_selections", {})
251 filtered = {}
252 for label in CUSTOM_SELECTION_KEYS:
253 values = raw_selections.get(label, [])
254 if not isinstance(values, list):
255 values = []
256 filtered[label] = [value for value in values if value in custom_keys]
257 return filtered
258
259
260def apply_backup_payload(payload, settings_xml_path=SETTINGS_XML_PATH):
261 schema = _load_settings_schema(settings_xml_path)
262 settings_payload, dynamic_settings_payload, cache_payload = _ensure_backup_payload(payload)
263 strip_sensitive = bool(payload.get("strip_sensitive"))
264
265 for setting_id, value in settings_payload.items():
266 if setting_id not in schema:
267 continue
268 set_setting(setting_id, _stringify_setting_value(value))
269
270 for setting_id, default_value in EXTRA_DYNAMIC_SETTINGS.items():
271 if strip_sensitive:
272 set_cached_setting_property(setting_id, default_value)
273 continue
274 set_cached_setting_property(
275 setting_id,
276 _stringify_setting_value(dynamic_settings_payload.get(setting_id, default_value)),
277 )
278
279 if strip_sensitive:
280 for setting_id in SCRUBBED_SETTING_IDS:
281 schema_entry = schema.get(setting_id)
282 if not schema_entry:
283 continue
284 set_setting(setting_id, _setting_clear_value(setting_id, schema_entry))
285
286 current_user_addons = list(cache.get(STREMIO_USER_ADDONS) or [])
287 current_custom_addons = _get_custom_stremio_addons(current_user_addons)
288 current_custom_keys = {
289 addon_key
290 for addon_key in (_custom_addon_key(addon) for addon in current_custom_addons)
291 if addon_key
292 }
293 non_custom_addons = [
294 addon for addon in current_user_addons if addon.get("transportName") != "custom"
295 ]
296
297 restored_custom_addons = []
298 restored_selection_map = {label: [] for label in CUSTOM_SELECTION_KEYS}
299 if not strip_sensitive:
300 restored_custom_addons = _normalize_custom_addons(
301 cache_payload.get("custom_stremio_addons", [])
302 )
303 restored_selection_map = _filtered_custom_selection_map(
304 cache_payload, restored_custom_addons
305 )
306
307 cache.set(
308 STREMIO_USER_ADDONS,
309 non_custom_addons + restored_custom_addons,
310 CUSTOM_ADDON_EXPIRY,
311 )
312
313 for label, cache_key in CUSTOM_SELECTION_KEYS.items():
314 selected_keys = decode_selected_ids(cache.get(cache_key))
315 selected_keys = [key for key in selected_keys if key not in current_custom_keys]
316 for backup_key in restored_selection_map[label]:
317 if backup_key not in selected_keys:
318 selected_keys.append(backup_key)
319 cache.set(cache_key, encode_selected_ids(selected_keys), CUSTOM_ADDON_EXPIRY)
320
321
322def reset_all_settings(settings_xml_path=SETTINGS_XML_PATH):
323 schema = _load_settings_schema(settings_xml_path)
324 for setting_id, schema_entry in schema.items():
325 set_setting(setting_id, _setting_clear_value(setting_id, schema_entry))
326
327 for setting_id, default_value in EXTRA_DYNAMIC_SETTINGS.items():
328 set_cached_setting_property(setting_id, default_value)
329
330 current_user_addons = list(cache.get(STREMIO_USER_ADDONS) or [])
331 current_custom_addons = _get_custom_stremio_addons(current_user_addons)
332 current_custom_keys = {
333 addon_key
334 for addon_key in (_custom_addon_key(addon) for addon in current_custom_addons)
335 if addon_key
336 }
337 non_custom_addons = [
338 addon for addon in current_user_addons if addon.get("transportName") != "custom"
339 ]
340 cache.set(STREMIO_USER_ADDONS, non_custom_addons, CUSTOM_ADDON_EXPIRY)
341
342 for cache_key in CUSTOM_SELECTION_KEYS.values():
343 selected_keys = decode_selected_ids(cache.get(cache_key))
344 selected_keys = [key for key in selected_keys if key not in current_custom_keys]
345 cache.set(cache_key, encode_selected_ids(selected_keys), CUSTOM_ADDON_EXPIRY)
346
347
348def _clear_factory_reset_database_state():
349 pickle_db = PickleDatabase()
350 for key in ("jt:watch", "jt:lth", "jt:lfh", "jt:lib"):
351 pickle_db.set_key(key, {}, commit=False)
352 for key in (
353 "search_query",
354 "search_catalog_query",
355 "anime_query",
356 "collection_search_query",
357 ):
358 pickle_db.set_key(key, "", commit=False)
359 pickle_db.commit()
360
361
362def factory_reset(settings_xml_path=SETTINGS_XML_PATH):
363 reset_all_settings(settings_xml_path=settings_xml_path)
364 clear_all_cache()
365 clear_trakt_db_cache()
366 clear_tmdb_cache()
367 clear_stremio_cache()
368 clear_debrid_cache()
369 clear_mdblist_cache()
370 cache.clear_list(key="multi")
371 cache.clear_list(key="direct")
372 _clear_factory_reset_database_state()
373
374
375def _get_export_path():
376 folder = xbmcgui.Dialog().browse(
377 0,
378 translation(90268),
379 "files",
380 "",
381 False,
382 False,
383 "",
384 )
385 if not folder:
386 return None
387
388 timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
389 return os.path.join(translatePath(folder), EXPORT_FILENAME_TEMPLATE.format(timestamp=timestamp))
390
391
392def export_settings_backup(params=None):
393 export_path = _get_export_path()
394 if not export_path:
395 return
396
397 payload = build_backup_payload(bool(get_setting("settings_backup_strip_sensitive", False)))
398
399 try:
400 os.makedirs(os.path.dirname(export_path), exist_ok=True)
401 with open(export_path, "w", encoding="utf-8") as backup_file:
402 json.dump(payload, backup_file, indent=2, sort_keys=True)
403 notification(translation(90265))
404 except Exception:
405 dialog_ok(translation(90267), translation(90273), export_path)
406
407
408def _get_restore_path():
409 restore_path = xbmcgui.Dialog().browse(
410 1,
411 translation(90269),
412 "files",
413 ".json",
414 False,
415 False,
416 ADDON_PROFILE_PATH,
417 )
418 if not restore_path:
419 notification(translation(90272))
420 return None
421 return translatePath(restore_path)
422
423
424def _get_restore_source():
425 selected_index = xbmcgui.Dialog().select(
426 translation(90346),
427 [translation(90347), translation(90348)],
428 )
429 if selected_index == 0:
430 return RESTORE_SOURCE_FILE
431 if selected_index == 1:
432 return RESTORE_SOURCE_URL
433 notification(translation(90277))
434 return None
435
436
437def _get_restore_url():
438 restore_url = xbmcgui.Dialog().input(
439 heading=translation(90349),
440 type=xbmcgui.INPUT_ALPHANUM,
441 )
442 restore_url = (restore_url or "").strip()
443 if not restore_url:
444 notification(translation(90350))
445 return None
446 return restore_url
447
448
449def _load_backup_payload_from_file(restore_path):
450 with open(restore_path, encoding="utf-8") as backup_file:
451 return json.load(backup_file)
452
453
454def _load_backup_payload_from_url(restore_url):
455 response = requests.get(restore_url, timeout=15)
456 response.raise_for_status()
457 return response.json()
458
459
460def restore_settings_backup(params=None):
461 restore_source = _get_restore_source()
462 if not restore_source:
463 return
464
465 restore_target = (
466 _get_restore_path() if restore_source == RESTORE_SOURCE_FILE else _get_restore_url()
467 )
468 if not restore_target:
469 return
470
471 try:
472 confirmed = xbmcgui.Dialog().yesno(
473 translation(90275),
474 translation(90276),
475 )
476 if not confirmed:
477 notification(translation(90277))
478 return
479
480 if restore_source == RESTORE_SOURCE_FILE:
481 payload = _load_backup_payload_from_file(restore_target)
482 else:
483 payload = _load_backup_payload_from_url(restore_target)
484 apply_backup_payload(payload)
485 notification(translation(90266))
486 except ValueError:
487 dialog_ok(translation(90267), translation(90270))
488 except requests.RequestException:
489 dialog_ok(translation(90267), translation(90351), restore_target)
490 except Exception:
491 dialog_ok(translation(90267), translation(90274), restore_target)
492
493
494def reset_all_settings_action(params=None):
495 confirmed = xbmcgui.Dialog().yesno(
496 translation(90278),
497 translation(90279),
498 )
499 if not confirmed:
500 notification(translation(90280))
501 return
502
503 try:
504 reset_all_settings()
505 notification(translation(90281))
506 except Exception:
507 dialog_ok(translation(90282), translation(90283))
508
509
510def factory_reset_action(params=None):
511 confirmed = xbmcgui.Dialog().yesno(
512 translation(90285),
513 translation(90286),
514 )
515 if not confirmed:
516 notification(translation(90287))
517 return
518
519 try:
520 factory_reset()
521 notification(translation(90288))
522 except Exception:
523 dialog_ok(translation(90289), translation(90290))
plugin.video.jacktook/lib/utils/kodi/utils.py +775 -0
……@@ -0,0 +1,775 @@
1#!/usr/bin/env python3
2from datetime import date, datetime, timedelta
3import json
4import os
5import re
6import xml.etree.ElementTree as ET
7import sys
8import time
9from typing import Any, Union
10
11from urllib.parse import quote, urlencode
12from lib.db.cached import cache
13from lib.utils.kodi.logging import kodilog
14
15import xbmc
16import xbmcaddon
17import xbmcgui
18
19from xbmcgui import Window, ListItem
20from xbmcplugin import addDirectoryItems, setResolvedUrl, endOfDirectory
21from xbmcvfs import (
22 translatePath as translate_path,
23 delete as xbmc_delete,
24 listdir,
25 File as xbmcvfs_File,
26)
27
28
29_URL = sys.argv[0]
30
31MOVIES_TYPE = "movies"
32SHOWS_TYPE = "tvshows"
33SEASONS_TYPE = "seasons"
34EPISODES_TYPE = "episodes"
35TITLES_TYPE = "titles"
36
37TORREST_ADDON_ID = "plugin.video.torrest"
38JACKTORR_ADDON_ID = "plugin.video.jacktorr"
39ELEMENTUM_ADDON_ID = "plugin.video.elementum"
40YOUTUBE_ADDON_ID = "plugin.video.youtube"
41JACKTOOK_BURST_ADOON_ID = "script.jacktook.burst"
42
43
44def _get_jacktorr_addon():
45 try:
46 if xbmc.getCondVisibility(f"System.HasAddon({JACKTORR_ADDON_ID})"):
47 return xbmcaddon.Addon(JACKTORR_ADDON_ID)
48 except Exception:
49 pass
50 return None
51
52
53JACKTORR_ADDON = _get_jacktorr_addon()
54
55
56ADDON = xbmcaddon.Addon()
57try:
58 ADDON_HANDLE = int(sys.argv[1])
59except IndexError:
60 ADDON_HANDLE = 0
61
62ADDON_PATH = ADDON.getAddonInfo("path")
63ADDON_PROFILE_PATH = translate_path(ADDON.getAddonInfo("profile"))
64IMAGES_PATH = f"{ADDON_PATH}/resources/images/"
65DEFAULT_LOGO = f"{IMAGES_PATH}tv.png"
66ADDON_ICON = ADDON.getAddonInfo("icon")
67ADDON_ID = ADDON.getAddonInfo("id")
68ADDON_VERSION = ADDON.getAddonInfo("version")
69ADDON_NAME = ADDON.getAddonInfo("name")
70PLAYLIST = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
71
72CHANGELOG_PATH = translate_path(
73 "special://home/addons/plugin.video.jacktook/CHANGELOG.md"
74)
75
76progressDialog = xbmcgui.DialogProgress()
77
78
79def get_jacktorr_setting(value, default=None):
80 if not JACKTORR_ADDON:
81 notification(translation(30253))
82 return
83 value = JACKTORR_ADDON.getSetting(value)
84 if not value:
85 return default
86 if isinstance(value, str):
87 if value.lower() == "true":
88 return True
89 elif value.lower() == "false":
90 return False
91 return value
92
93
94def _setting_cache_prop(setting_id: str) -> str:
95 return f"jacktook.setting.{setting_id}"
96
97
98def _settings_snapshot_prop() -> str:
99 return "jacktook.settings"
100
101
102def _load_settings_snapshot():
103 raw = Window(10000).getProperty(_settings_snapshot_prop())
104 if not raw:
105 return None
106 try:
107 return json.loads(raw)
108 except Exception:
109 return None
110
111
112def _profile_settings_xml_path() -> str:
113 return os.path.join(ADDON_PROFILE_PATH, "settings.xml")
114
115
116def rebuild_settings_snapshot():
117 settings_dict = None
118 try:
119 profile_xml = _profile_settings_xml_path()
120 if os.path.exists(profile_xml):
121 root = ET.parse(profile_xml).getroot()
122 settings_dict = {
123 item.get("id"): (item.text or "")
124 for item in root.iter("setting")
125 if item.get("id")
126 }
127 else:
128 settings_dict = {}
129 Window(10000).setProperty(_settings_snapshot_prop(), json.dumps(settings_dict))
130 except Exception:
131 return None
132 return settings_dict
133
134
135def get_cached_setting_property(setting_id: str):
136 settings_dict = _load_settings_snapshot()
137 if settings_dict is None:
138 settings_dict = rebuild_settings_snapshot()
139 if settings_dict is None:
140 return None
141 return settings_dict.get(setting_id, "")
142
143
144def set_cached_setting_property(setting_id: str, value: Any):
145 settings_dict = _load_settings_snapshot() or {}
146 settings_dict[setting_id] = str(value)
147 Window(10000).setProperty(_settings_snapshot_prop(), json.dumps(settings_dict))
148
149
150def clear_cached_setting_property(setting_id: str):
151 settings_dict = _load_settings_snapshot() or {}
152 if setting_id in settings_dict:
153 del settings_dict[setting_id]
154 Window(10000).setProperty(_settings_snapshot_prop(), json.dumps(settings_dict))
155
156
157def clear_cached_settings():
158 Window(10000).clearProperty(_settings_snapshot_prop())
159
160
161def _normalize_setting_value(val, default=None):
162 if not val:
163 return default
164 if isinstance(val, str):
165 if val.lower() == "true":
166 return True
167 if val.lower() == "false":
168 return False
169 return val
170
171
172def get_setting(id, default=None):
173 val = get_cached_setting_property(id)
174 if val:
175 return _normalize_setting_value(val, default)
176
177 val = ADDON.getSetting(id)
178 if val:
179 set_cached_setting_property(id, val)
180 return _normalize_setting_value(val, default)
181
182 return default
183
184
185def get_setting_fresh(id, default=None):
186 settings_dict = rebuild_settings_snapshot()
187 if settings_dict is not None:
188 val = settings_dict.get(id, "")
189 if val != "":
190 return _normalize_setting_value(val, default)
191
192 val = ADDON.getSetting(id)
193 if val:
194 set_cached_setting_property(id, val)
195 return _normalize_setting_value(val, default)
196 clear_cached_setting_property(id)
197 return default
198
199
200def set_setting(id, value):
201 ADDON.setSetting(id, value)
202 set_cached_setting_property(id, value)
203
204
205def get_property_no_fallback(prop: str):
206 return Window(10000).getProperty(prop)
207
208
209def get_property(prop: str):
210 value = Window(10000).getProperty(prop)
211 kodilog(f"Get property: {prop} = {value}", xbmc.LOGDEBUG)
212 if not value:
213 value = cache.get(prop)
214 kodilog(f"Get property from cache: {prop} = {value}", xbmc.LOGDEBUG)
215 if not value:
216 return None
217 return value
218
219
220def set_property_no_fallback(prop: str, value: Any):
221 Window(10000).setProperty(prop, value)
222
223
224def set_property(prop: str, value: Any):
225 Window(10000).setProperty(prop, value)
226 cache.set(prop, value, timedelta(days=30))
227
228
229def clear_property(prop):
230 return Window(10000).clearProperty(prop)
231
232
233def burst_addon_settings():
234 close_all_dialog()
235 xbmc.executebuiltin("Addon.OpenSettings(%s)" % JACKTOOK_BURST_ADOON_ID)
236
237
238def get_kodi_version():
239 build = xbmc.getInfoLabel("System.BuildVersion")
240 kodi_version = int(build.split()[0][:2])
241 return kodi_version
242
243
244def is_torrest_addon():
245 return xbmc.getCondVisibility(f"System.HasAddon({TORREST_ADDON_ID})")
246
247
248def is_jacktorr_addon():
249 return xbmc.getCondVisibility(f"System.HasAddon({JACKTORR_ADDON_ID})")
250
251
252def is_jacktorr_addon_enabled():
253 try:
254 if not xbmc.getCondVisibility(f"System.HasAddon({JACKTORR_ADDON_ID})"):
255 return False
256 addon = xbmcaddon.Addon(JACKTORR_ADDON_ID)
257 # If the addon is disabled, this will raise RuntimeError
258 return True
259 except RuntimeError:
260 # Addon exists but is disabled
261 return False
262
263
264def is_youtube_addon_enabled():
265 try:
266 if not xbmc.getCondVisibility(f"System.HasAddon({YOUTUBE_ADDON_ID})"):
267 return False
268 addon = xbmcaddon.Addon(YOUTUBE_ADDON_ID)
269 # If the addon is disabled, this will raise RuntimeError
270 return True
271 except RuntimeError:
272 # Addon exists but is disabled
273 return False
274
275
276def is_elementum_addon():
277 return xbmc.getCondVisibility(f"System.HasAddon({ELEMENTUM_ADDON_ID})")
278
279
280def is_burst_addon():
281 return xbmc.getCondVisibility(f"System.HasAddon({JACKTOOK_BURST_ADOON_ID})")
282
283
284def enable_addon(addon_id: str):
285 """
286 Enable a Kodi addon using JSON-RPC.
287 Returns True if successful, False otherwise.
288 """
289 request = {
290 "jsonrpc": "2.0",
291 "method": "Addons.SetAddonEnabled",
292 "params": {"addonid": addon_id, "enabled": True},
293 "id": 1,
294 }
295
296 try:
297 result = xbmc.executeJSONRPC(json.dumps(request))
298 response = json.loads(result)
299 return "error" not in response
300 except Exception as e:
301 xbmc.log(f"Failed to enable addon {addon_id}: {e}", level=xbmc.LOGERROR)
302 return False
303
304
305def translation(id_value):
306 return ADDON.getLocalizedString(id_value)
307
308
309def logger(message, level=xbmc.LOGINFO):
310 xbmc.log("[JACKTOOK] " + str(message), level)
311
312
313def get_url(**kwargs):
314 return "{}?{}".format(_URL, urlencode(kwargs))
315
316
317def set_art(list_item, artwork_url):
318 if artwork_url:
319 list_item.setArt({"poster": artwork_url, "thumb": artwork_url})
320
321
322def slugify(text):
323 text = text.lower()
324 text = re.sub(r"\[.*?\]", "", text)
325 text = text.replace("(", "").replace(")", "")
326 text = text.replace("'", "").replace("’", "")
327 text = text.replace("+", "").replace("@", "")
328 text = re.sub(r"[^a-zA-Z0-9_]+", "-", text)
329 text = text.strip("-")
330 return text
331
332
333def compat(line1, line2, line3):
334 message = line1
335 if line2:
336 message += "\n" + line2
337 if line3:
338 message += "\n" + line3
339 return message
340
341
342def kodi_refresh():
343 execute_builtin('UpdateLibrary(video,special://skin/foo)')
344
345
346def refresh():
347 xbmc.executebuiltin("Container.Refresh")
348
349
350def notification(message, heading=ADDON_NAME, icon=ADDON_ICON, time=2000, sound=True):
351 xbmcgui.Dialog().notification(heading, message, icon, time, sound)
352
353
354def dialog_ok(heading, line1, line2="", line3=""):
355 return xbmcgui.Dialog().ok(heading, compat(line1=line1, line2=line2, line3=line3))
356
357
358def dialog_text(heading: str, content: str = "", file=None):
359 dialog = xbmcgui.Dialog()
360 if file:
361 try:
362 with open(file, encoding="utf-8") as r:
363 content = r.read()
364 except Exception as e:
365 logger(f"Error reading file {file}: {e}", xbmc.LOGERROR)
366 notification("Error reading file.")
367 return
368 dialog.textviewer(heading, content, False)
369 return dialog
370
371
372def dialogyesno(header, text):
373 dialog = xbmcgui.Dialog()
374 confirmed = dialog.yesno(
375 header,
376 text,
377 )
378 if confirmed:
379 return True
380 else:
381 return False
382
383
384def dialog_select(heading, _list):
385 dialog = xbmcgui.Dialog()
386 return dialog.select(heading, _list)
387
388
389def close_all_dialog():
390 execute_builtin("Dialog.Close(all,true)")
391
392
393def container_update(name, **kwargs):
394 """
395 Update the container to the specified path.
396
397 :param path: The path where to update.
398 :type path: str
399 """
400 return "Container.Update({})".format(build_url(name, **kwargs))
401
402
403def container_refresh():
404 execute_builtin("Container.Refresh")
405
406
407def action_url_run(name, **kwargs):
408 return "RunPlugin({})".format(build_url(name, **kwargs))
409
410
411def build_url(action, **params):
412 for key, value in params.items():
413 if isinstance(value, (dict, list)):
414 params[key] = json.dumps(value)
415 query = urlencode(params)
416 return f"plugin://{ADDON_ID}/?action={action}&{query}"
417
418
419def url_for(action, **kwargs):
420 qs_kwargs = dict(((k, v) for k, v in list(kwargs.items())))
421 query = "?" + urlencode(qs_kwargs) if qs_kwargs else ""
422 return f"plugin://{ADDON_ID}/?action={action}&{query}"
423
424
425def play_info_hash(info_hash):
426 url = f"plugin://{JACKTORR_ADDON_ID}/play_info_hash?info_hash={quote(info_hash)}"
427 return f"PlayMedia({url})"
428
429
430def buffer_and_play(info_hash, file_id, path):
431 url = f"plugin://{JACKTORR_ADDON_ID}/buffer_and_play?info_hash={info_hash}&file_id={file_id}&path={quote(path)}"
432 return f"PlayMedia({url})"
433
434
435def kodi_play_media(name, *args, **kwargs):
436 return "PlayMedia({})".format(build_url(name, *args, **kwargs))
437
438
439def show_busy_dialog():
440 execute_builtin("ActivateWindow(busydialognocancel)")
441
442
443def show_picture(url):
444 xbmc.executebuiltin('ShowPicture("{}")'.format(url))
445
446
447def close_busy_dialog():
448 execute_builtin("Dialog.Close(busydialognocancel)")
449 execute_builtin("Dialog.Close(busydialog)")
450
451
452def execute_builtin(command, block=False):
453 return xbmc.executebuiltin(command, block)
454
455
456def get_visibility():
457 return xbmc.getCondVisibility
458
459
460def delete_file(file):
461 xbmc_delete(file)
462
463
464def update_local_addons():
465 execute_builtin("UpdateLocalAddons", True)
466 sleep(2500)
467
468
469def disable_enable_addon(addon_id=ADDON_ID):
470 try:
471 xbmc.executeJSONRPC(
472 json.dumps(
473 {
474 "jsonrpc": "2.0",
475 "id": 1,
476 "method": "Addons.SetAddonEnabled",
477 "params": {"addonid": addon_id, "enabled": False},
478 }
479 )
480 )
481 xbmc.executeJSONRPC(
482 json.dumps(
483 {
484 "jsonrpc": "2.0",
485 "id": 1,
486 "method": "Addons.SetAddonEnabled",
487 "params": {"addonid": addon_id, "enabled": True},
488 }
489 )
490 )
491 except:
492 pass
493
494
495def update_kodi_addons_db(addon_id=ADDON_ID):
496 try:
497 import sqlite3 as database
498
499 date = time.strftime("%Y-%m-%d %H:%M:%S")
500 dbcon = database.connect(
501 translate_path("special://database/Addons33.db"), timeout=40.0
502 )
503 dbcon.execute(
504 "INSERT OR REPLACE INTO installed (addonID, enabled, lastUpdated) VALUES (?, ?, ?)",
505 (addon_id, 1, date),
506 )
507 dbcon.close()
508 except:
509 pass
510
511
512def bytes_to_human_readable(size: int, unit: str = "B") -> str:
513 units = {"B": 0, "KB": 1, "MB": 2, "GB": 3, "TB": 4, "PB": 5}
514
515 while size >= 1024 and unit != "PB":
516 size /= 1024
517 unit = list(units.keys())[list(units.values()).index(units[unit] + 1)]
518
519 return f"{size:.3g} {unit}"
520
521
522def 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)
525 if match:
526 size, unit = match.groups()
527 size = float(size)
528 unit = unit.upper()
529 if unit == "GB":
530 return int(size * 1024**3)
531 if unit == "MB":
532 return int(size * 1024**2)
533 if unit == "KB":
534 return int(size * 1024)
535 return int(size)
536 return 0
537
538
539def sleep(miliseconds):
540 xbmc.sleep(miliseconds)
541
542
543def show_keyboard(id, default="", hidden=False):
544 keyboard = xbmc.Keyboard(default, translation(id), hidden)
545 keyboard.doModal()
546 if keyboard.isConfirmed():
547 return keyboard.getText()
548
549
550def get_current_view_id():
551 return xbmcgui.Window(xbmcgui.getCurrentWindowId()).getFocusId()
552
553
554SECTION_VIEW_KEYS = (
555 "view.main",
556 "view.movies",
557 "view.tvshows",
558 "view.seasons",
559 "view.episodes",
560 "view.library",
561 "view.history",
562 "view.downloads",
563)
564
565
566def get_view_setting_key(view_key):
567 return "saved_%s" % view_key.replace(".", "_")
568
569
570def get_view_property_key(view_key):
571 return "jacktook.%s" % view_key
572
573
574def _get_named_view_id(name, default="current"):
575 views_dict = {
576 "list": 50,
577 "poster": 51,
578 "iconwall": 52,
579 "shift": 53,
580 "infowall": 54,
581 "widelist": 55,
582 "wall": 500,
583 "banner": 501,
584 "fanart": 502,
585 "current": get_current_view_id(),
586 }
587 return views_dict.get(name, views_dict.get(default))
588
589
590def _normalize_container_content(content_type):
591 content_map = {
592 "": "",
593 "files": "files",
594 "tv": SHOWS_TYPE,
595 "tvshow": SHOWS_TYPE,
596 "shows": SHOWS_TYPE,
597 "movie": MOVIES_TYPE,
598 "season": SEASONS_TYPE,
599 "episode": EPISODES_TYPE,
600 "title": TITLES_TYPE,
601 "video": "videos",
602 }
603 return content_map.get(content_type, content_type)
604
605
606def _resolve_view_name(name, content_type=""):
607 normalized_content = _normalize_container_content(content_type or container_content())
608 if name in {"banner", "fanart"} and normalized_content in {
609 MOVIES_TYPE,
610 SHOWS_TYPE,
611 SEASONS_TYPE,
612 EPISODES_TYPE,
613 TITLES_TYPE,
614 "videos",
615 }:
616 return "poster"
617 return name
618
619
620def set_saved_view_property(view_key, view_id):
621 set_property_no_fallback(get_view_property_key(view_key), str(view_id))
622
623
624def save_view_id(view_key, view_id):
625 if view_id in (None, "", 0, "0"):
626 return False
627 value = str(view_id)
628 set_setting(get_view_setting_key(view_key), value)
629 set_saved_view_property(view_key, value)
630 return True
631
632
633def get_saved_view_id(view_key):
634 view_id = get_property_no_fallback(get_view_property_key(view_key))
635 if not view_id:
636 view_id = ADDON.getSetting(get_view_setting_key(view_key))
637 return str(view_id) if view_id else None
638
639
640def load_saved_view_properties():
641 for view_key in SECTION_VIEW_KEYS:
642 view_id = ADDON.getSetting(get_view_setting_key(view_key))
643 if view_id:
644 set_saved_view_property(view_key, view_id)
645
646
647def capture_current_view_id():
648 view_id = get_current_view_id()
649 if view_id in (None, 0):
650 return None
651 return str(view_id)
652
653
654def _wait_for_container_content(content_type, timeout_ms=2000):
655 expected = _normalize_container_content(content_type)
656 if not expected:
657 return True
658 elapsed = 0
659 while elapsed <= timeout_ms:
660 if _normalize_container_content(container_content()) == expected:
661 return True
662 sleep(50)
663 elapsed += 50
664 return False
665
666
667def apply_section_view(view_key, content_type=""):
668 _wait_for_container_content(content_type)
669 saved_view_id = get_saved_view_id(view_key)
670 if saved_view_id:
671 execute_builtin(f"Container.SetViewMode({saved_view_id})")
672 return saved_view_id
673 return None
674
675
676def reset_saved_views():
677 for view_key in SECTION_VIEW_KEYS:
678 setting_key = get_view_setting_key(view_key)
679 property_key = get_view_property_key(view_key)
680 ADDON.setSetting(setting_key, "")
681 clear_property(property_key)
682
683
684def set_view(name, default="current"):
685 view_id = _get_named_view_id(_resolve_view_name(name), default)
686 execute_builtin(f"Container.SetViewMode({view_id})")
687
688
689def container_content():
690 return xbmc.getInfoLabel("Container.Content")
691
692
693def copy2clip(txt):
694 import subprocess
695
696 platform = sys.platform
697 if platform == "win32":
698 try:
699 cmd = "echo %s|clip" % txt.strip()
700 return subprocess.check_call(cmd, shell=True)
701 except:
702 pass
703 elif platform == "linux2":
704 try:
705 from subprocess import PIPE, Popen
706
707 p = Popen(["xsel", "-pi"], stdin=PIPE)
708 p.communicate(input=txt)
709 except:
710 pass
711
712
713def get_datetime(string: bool = False, dt: bool = False) -> Union[date, datetime]:
714 """
715 Returns the current date/time in various formats.
716
717 :param dt: If True, returns the full datetime object.
718 :return: By default, returns a date object.
719 """
720 now = datetime.now()
721 if dt:
722 return now
723 else:
724 return now.date()
725
726
727def list_dirs(location):
728 return listdir(location)
729
730
731def translatePath(_path):
732 return translate_path(_path)
733
734
735def open_file(_file, mode="r"):
736 return xbmcvfs_File(_file, mode)
737
738
739def cancel_playback():
740 PLAYLIST.clear()
741 close_busy_dialog()
742 close_all_dialog()
743 try:
744 xbmc.Player().stop()
745 except Exception:
746 pass
747 setResolvedUrl(ADDON_HANDLE, False, ListItem(offscreen=True))
748
749
750def finish_action():
751 setResolvedUrl(ADDON_HANDLE, False, ListItem(offscreen=True))
752
753
754def make_list_item(label="", path="", offscreen=True):
755 kwargs = {"offscreen": offscreen}
756 if label:
757 kwargs["label"] = label
758 if path:
759 kwargs["path"] = path
760 return ListItem(**kwargs)
761
762
763def add_directory_items_batch(items):
764 if items:
765 addDirectoryItems(ADDON_HANDLE, items)
766
767
768def is_widget():
769 return "jacktook" not in xbmc.getInfoLabel("Container.PluginName")
770
771
772def end_of_directory(cache=True):
773 endOfDirectory(
774 ADDON_HANDLE, cacheToDisc=False if is_widget() or not cache else True
775 )
plugin.video.jacktook/lib/utils/localization/countries.py +1285 -0
……@@ -0,0 +1,1285 @@
1# -*- coding: utf-8 -*-
2# ref: https://gist.github.com/tientran0019/c68b7095976386cf8b1812327228450e
3
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'}]
1236
1237LANGUAGE_NAME_TO_CODE = {
1238 "English": "en",
1239 "Spanish": "es",
1240 "German": "de",
1241 "French": "fr",
1242 "Italian": "it",
1243 "Portuguese": "pt",
1244 "Dutch": "nl",
1245 "Polish": "pl",
1246 "Russian": "ru",
1247 "Turkish": "tr",
1248 "Greek": "el",
1249 "Hebrew": "he",
1250 "Hindi": "hi",
1251 "Indonesian": "id",
1252 "Japanese": "ja",
1253 "Korean": "ko",
1254 "Ukrainian": "uk",
1255 "Vietnamese": "vi",
1256 "Chinese": "zh",
1257 "Persian": "fa",
1258 "None": "None"
1259}
1260
1261def _check_flag(flag, country):
1262 if country['flag'] == flag: return True
1263 return False
1264
1265def _return_language(country):
1266 return country['language']['name']
1267
1268def _flag_is_minor_than(flag, country):
1269 if country['flag'] < flag: return True
1270 return False
1271
1272def _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)
1283
1284def 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)
plugin.video.jacktook/lib/utils/localization/language_detection.py +550 -0
……@@ -0,0 +1,550 @@
1from lib.utils.localization.countries import find_language_by_unicode
2import re
3
4langsSet = {
5 "bosnian",
6 "ro",
7 "swedish",
8 "mac",
9 "tr",
10 "rw",
11 "ind",
12 "czech",
13 "nl",
14 "he",
15 "lithuanian",
16 "sāmo",
17 "nor",
18 "རྫོང",
19 "ger",
20 "azə",
21 "shq",
22 "cze",
23 "hu",
24 "danish",
25 "hy",
26 "mag",
27 "ms",
28 "tajik",
29 "پښت",
30 "kaz",
31 "mk",
32 "vietnamese",
33 "sk",
34 "бъл",
35 "ps",
36 "lt",
37 "cs",
38 "mongolian",
39 "jap",
40 "geo",
41 "ko",
42 "dan",
43 "dzongkha",
44 "th",
45 "norwegian",
46 "az",
47 "mt",
48 "por",
49 "kala",
50 "sin",
51 "ja",
52 "pol",
53 "is",
54 "nepali",
55 "việt",
56 "тоҷ",
57 "amh",
58 "irish",
59 "chinese",
60 "pl",
61 "қаз",
62 "maltese",
63 "tg",
64 "arabic",
65 "ລາວ",
66 "khm",
67 "ukr",
68 "hin",
69 "am",
70 "ne",
71 "føroy",
72 "et",
73 "macedonian",
74 "polish",
75 "ita",
76 "tur",
77 "ქართული",
78 "gre",
79 "spa",
80 "বেঙ্গ",
81 "عرب",
82 "kyr",
83 "rus",
84 "bs",
85 "si",
86 "sr",
87 "belarusian",
88 "kl",
89 "my",
90 "bel",
91 "o'zb",
92 "бел",
93 "नेप",
94 "sam",
95 "albanian",
96 "faroese",
97 "turkish",
98 "spanish",
99 "da",
100 "georgian",
101 "malay",
102 "km",
103 "divehi",
104 "greek",
105 "فارسی",
106 "iri",
107 "bengali",
108 "melay",
109 "ben",
110 "mn",
111 "id",
112 "монгол",
113 "kin",
114 "portuguese",
115 "dutch",
116 "ar",
117 "de",
118 "icelandic",
119 "bg",
120 "slov",
121 "hi",
122 "norsk",
123 "est",
124 "viet",
125 "dz",
126 "rom",
127 "kyrgyz",
128 "fran",
129 "chi",
130 "ka",
131 "malaysian",
132 "fa",
133 "vi",
134 "catalan",
135 "ser",
136 "ru",
137 "indo",
138 "fo",
139 "pt",
140 "mal",
141 "burmese",
142 "kor",
143 "ga",
144 "korean",
145 "fin",
146 "lit",
147 "amharic",
148 "nep",
149 "dzo",
150 "italian",
151 "heb",
152 "be",
153 "bulgarian",
154 "eest",
155 "укр",
156 "አማ",
157 "ísl",
158 "el",
159 "lv",
160 "turkmen",
161 "日",
162 "bur",
163 "हिं",
164 "עבר",
165 "tha",
166 "taj",
167 "мак",
168 "sq",
169 "uk",
170 "bos",
171 "sven",
172 "slo",
173 "japanese",
174 "hun",
175 "french",
176 "esp",
177 "serbian",
178 "croatian",
179 "türkmen",
180 "alb",
181 "kal",
182 "uz",
183 "mla",
184 "eng",
185 "german",
186 "ខ្មែរ",
187 "hebrew",
188 "ice",
189 "cro",
190 "mon",
191 "ελλ",
192 "sinhalese",
193 "рус",
194 "lo",
195 "fr",
196 "ca",
197 "sv",
198 "uzbek",
199 "aze",
200 "հայ",
201 "ara",
202 "hindi",
203 "မြန်",
204 "kalaallisut",
205 "es",
206 "it",
207 "kazakh",
208 "kk",
209 "slovak",
210 "per",
211 "sl",
212 "suom",
213 "češ",
214 "hungarian",
215 "finnish",
216 "thai",
217 "zh",
218 "hr",
219 "ky",
220 "arm",
221 "slovene",
222 "persian",
223 "cat",
224 "bn",
225 "ukrainian",
226 "ދިވެ",
227 "sve",
228 "div",
229 "fre",
230 "кыргыз",
231 "uzb",
232 "estonian",
233 "kinyarwanda",
234 "hrv",
235 "no",
236 "pashto",
237 "azerbaijani",
238 "gae",
239 "latvian",
240 "du",
241 "en",
242 "ned",
243 "срп",
244 "samoan",
245 "中",
246 "tk",
247 "lao",
248 "indonesian",
249 "sm",
250 "한",
251 "khmer",
252 "bul",
253 "pas",
254 "far",
255 "armenian",
256 "ไทย",
257 "fi",
258 "english",
259 "liet",
260 "romanian",
261 "russian",
262 "tür",
263 "සිං",
264 "cas",
265 "castellano",
266 "castilian",
267}
268language_codes = {
269 "bosnian": "bs",
270 "ro": "ro",
271 "swedish": "sv",
272 "mac": "mk",
273 "tr": "tr",
274 "rw": "rw",
275 "ind": "id",
276 "czech": "cs",
277 "nl": "nl",
278 "he": "he",
279 "lithuanian": "lt",
280 "sāmo": "sm",
281 "nor": "no",
282 "རྫོང": "dz",
283 "ger": "de",
284 "azə": "az",
285 "shq": "sq",
286 "lat": "lv",
287 "cze": "cs",
288 "hu": "hu",
289 "danish": "da",
290 "hy": "hy",
291 "mag": "mk",
292 "ms": "ms",
293 "tajik": "tg",
294 "پښت": "ps",
295 "kaz": "kk",
296 "mk": "mk",
297 "vietnamese": "vi",
298 "sk": "sk",
299 "бъл": "bg",
300 "ps": "ps",
301 "lt": "lt",
302 "cs": "cs",
303 "mongolian": "mn",
304 "jap": "ja",
305 "geo": "ka",
306 "ko": "ko",
307 "dan": "da",
308 "dzongkha": "dz",
309 "th": "th",
310 "norwegian": "no",
311 "az": "az",
312 "mt": "mt",
313 "por": "pt",
314 "kala": "kl",
315 "sin": "si",
316 "ja": "ja",
317 "pol": "pl",
318 "is": "is",
319 "nepali": "ne",
320 "việt": "vi",
321 "тоҷ": "tj",
322 "amh": "am",
323 "irish": "ga",
324 "chinese": "zh",
325 "pl": "pl",
326 "қаз": "kk",
327 "maltese": "mt",
328 "tg": "tg",
329 "arabic": "ar",
330 "ລາວ": "lo",
331 "khm": "km",
332 "ukr": "uk",
333 "hin": "hi",
334 "am": "am",
335 "ne": "ne",
336 "føroy": "fo",
337 "et": "et",
338 "macedonian": "mk",
339 "polish": "pl",
340 "ita": "it",
341 "tur": "tr",
342 "ქართული": "ka",
343 "gre": "el",
344 "spa": "es",
345 "বেঙ্গ": "bn",
346 "عرب": "ar",
347 "kyr": "ky",
348 "rus": "ru",
349 "bs": "bs",
350 "si": "si",
351 "sr": "sr",
352 "belarusian": "be",
353 "kl": "kl",
354 "my": "my",
355 "bel": "be",
356 "o'zb": "uz",
357 "бел": "be",
358 "नेप": "ne",
359 "sam": "sm",
360 "albanian": "sq",
361 "faroese": "fo",
362 "turkish": "tr",
363 "spanish": "es",
364 "da": "da",
365 "georgian": "ka",
366 "malay": "ms",
367 "km": "km",
368 "divehi": "dv",
369 "greek": "el",
370 "فارسی": "fa",
371 "iri": "fa",
372 "bengali": "bn",
373 "melay": "ms",
374 "ben": "bn",
375 "mn": "mn",
376 "id": "id",
377 "монгол": "mn",
378 "kin": "rw",
379 "portuguese": "pt",
380 "dutch": "nl",
381 "ar": "ar",
382 "de": "de",
383 "icelandic": "is",
384 "bg": "bg",
385 "slov": "sk",
386 "hi": "hi",
387 "norsk": "no",
388 "est": "et",
389 "viet": "vi",
390 "dz": "dz",
391 "rom": "ro",
392 "kyrgyz": "ky",
393 "fran": "fr",
394 "chi": "zh",
395 "ka": "ka",
396 "malaysian": "ms",
397 "fa": "fa",
398 "vi": "vi",
399 "catalan": "ca",
400 "ser": "sr",
401 "ru": "ru",
402 "indo": "id",
403 "fo": "fo",
404 "pt": "pt",
405 "mal": "ms",
406 "burmese": "my",
407 "kor": "ko",
408 "ga": "ga",
409 "korean": "ko",
410 "fin": "fi",
411 "lit": "lt",
412 "amharic": "am",
413 "nep": "ne",
414 "dzo": "dz",
415 "italian": "it",
416 "heb": "he",
417 "be": "be",
418 "bulgarian": "bg",
419 "eest": "et",
420 "укр": "uk",
421 "አማ": "am",
422 "ísl": "is",
423 "el": "el",
424 "lv": "lv",
425 "turkmen": "tk",
426 "日": "ja",
427 "bur": "my",
428 "हिं": "hi",
429 "עבר": "he",
430 "tha": "th",
431 "taj": "tg",
432 "мак": "mk",
433 "sq": "sq",
434 "uk": "uk",
435 "bos": "bs",
436 "sven": "sv",
437 "slo": "sk",
438 "japanese": "ja",
439 "hun": "hu",
440 "french": "fr",
441 "esp": "es",
442 "serbian": "sr",
443 "croatian": "hr",
444 "türkmen": "tk",
445 "alb": "sq",
446 "kal": "kl",
447 "uz": "uz",
448 "mla": "ms",
449 "eng": "en",
450 "german": "de",
451 "ខ្មែរ": "km",
452 "hebrew": "he",
453 "ice": "is",
454 "cro": "hr",
455 "mon": "mn",
456 "ελλ": "el",
457 "sinhalese": "si",
458 "рус": "ru",
459 "lo": "lo",
460 "fr": "fr",
461 "ca": "ca",
462 "sv": "sv",
463 "uzbek": "uz",
464 "aze": "az",
465 "հայ": "hy",
466 "ara": "ar",
467 "hindi": "hi",
468 "မြန်": "my",
469 "kalaallisut": "kl",
470 "es": "es",
471 "it": "it",
472 "kazakh": "kk",
473 "kk": "kk",
474 "slovak": "sk",
475 "per": "fa",
476 "sl": "sl",
477 "suom": "fi",
478 "češ": "cs",
479 "hungarian": "hu",
480 "finnish": "fi",
481 "thai": "th",
482 "zh": "zh",
483 "hr": "hr",
484 "ky": "ky",
485 "arm": "hy",
486 "slovene": "sl",
487 "persian": "fa",
488 "cat": "ca",
489 "bn": "bn",
490 "ukrainian": "uk",
491 "ދިވެ": "dv",
492 "sve": "sv",
493 "div": "dv",
494 "fre": "fr",
495 "кыргыз": "ky",
496 "uzb": "uz",
497 "estonian": "et",
498 "kinyarwanda": "rw",
499 "hrv": "hr",
500 "no": "no",
501 "pashto": "ps",
502 "azerbaijani": "az",
503 "gae": "ga",
504 "latvian": "lv",
505 "du": "nl",
506 "en": "en",
507 "ned": "nl",
508 "срп": "sr",
509 "samoan": "sm",
510 "中": "zh",
511 "tk": "tk",
512 "dv": "dv",
513 "lao": "lo",
514 "indonesian": "id",
515 "sm": "sm",
516 "한": "ko",
517 "khmer": "km",
518 "bul": "bg",
519 "pas": "ps",
520 "far": "fa",
521 "armenian": "hy",
522 "ไทย": "th",
523 "fi": "fi",
524 "english": "en",
525 "liet": "lt",
526 "romanian": "ro",
527 "russian": "ru",
528 "tür": "tr",
529 "සිං": "si",
530 "cas": "es",
531 "castellano": "es",
532 "castilian": "es",
533}
534
535
536def extract_unicode_flags(text):
537 flag_pattern = re.compile(r"[\U0001F1E6-\U0001F1FF]{2}")
538 flags = flag_pattern.findall(text)
539 return flags
540
541
542def find_languages_in_string(s: str) -> set:
543 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 )
547 swf = f"{s} {flags}"
548 matches = re.findall(pattern, swf.lower())
549 matches = [language_codes.get(match) for match in matches]
550 return set(matches)
plugin.video.jacktook/lib/utils/parsers/title_parser.py +136 -0
……@@ -0,0 +1,136 @@
1import re
2
3def get_color_tag(label, text, color):
4 # Standardized format [B]LABEL: VALUE[/B]
5 clean_color = color.replace("FF", "CC")
6 return f"[B][COLOR CCFFFFFF]{label}[/COLOR] [COLOR {clean_color}]{text}[/COLOR][/B]"
7
8def clean_display_title(title):
9 if not title:
10 return ""
11
12 # Redundant tags to remove (expanded list)
13 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"
26 ]
27
28 clean_title = title
29 for tag in tags_to_remove:
30 clean_title = re.sub(tag, "", clean_title, flags=re.IGNORECASE)
31
32 # Clean up extra dots, underscores, and spaces
33 clean_title = clean_title.replace(".", " ").replace("_", " ").replace("[", " ").replace("]", " ").replace("(", " ").replace(")", " ")
34 clean_title = re.sub(r"\s+", " ", clean_title).strip()
35
36 if clean_title:
37 clean_title = clean_title.capitalize()
38
39 return clean_title
40
41def parse_title_info(title):
42 if not title:
43 return {"codec": "", "audio": "", "hdr": "", "source": "", "format": "", "edition": "", "note": "", "lang_detail": "", "clean_title": "", "badges": "", "release_group": ""}
44
45 info = {
46 "codec": "", "audio": "", "hdr": "", "source": "", "format": "",
47 "edition": "", "note": "", "lang_detail": "", "release_group": "",
48 "clean_title": clean_display_title(title),
49 "badges": ""
50 }
51
52 title_upper = title.upper().replace(".", " ").replace("_", " ").replace("-", " ").replace("[", " ").replace("]", " ")
53
54 # 1. Video & Bit Depth
55 bit_depth = "10bit" if "10BIT" in title_upper else "12bit" if "12BIT" in title_upper else ""
56 if any(x in title_upper for x in ["HEVC", "H265", "X265"]):
57 val = f"HEVC {bit_depth}".strip()
58 info["codec"] = get_color_tag("VIDEO:", val, "FF00FF00")
59 elif any(x in title_upper for x in ["H264", "X264", "AVC"]):
60 val = f"H.264 {bit_depth}".strip()
61 info["codec"] = get_color_tag("VIDEO:", val, "FF9ACD32")
62
63 # 2. Audio & Channels
64 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
69 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
77 if audio_format or channels:
78 val = f"{audio_format} {channels}".strip()
79 info["audio"] = get_color_tag("AUDIO:", val, "FF00BFFF")
80
81 # 3. HDR & Hybrid
82 hdr_val = ""
83 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
89 if hdr_val:
90 val = f"{hdr_val} HYBRID" if is_hybrid else hdr_val
91 info["hdr"] = get_color_tag("HDR:", val, "FFFFA500")
92
93 # 4. Editions
94 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")
100 if editions:
101 info["edition"] = get_color_tag("EDITION:", " ".join(editions), "FFDA70D6")
102
103 # 5. Integrity Notes
104 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")
108 if notes:
109 info["note"] = get_color_tag("NOTE:", " ".join(notes), "FFFF4500")
110
111 # 6. Language Details
112 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"
117 if lang:
118 info["lang_detail"] = get_color_tag("LANG:", lang, "FFFFD700")
119
120 # 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
124 # Build Badge Line
125 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
129 bullet = "[COLOR 80FFFFFF] • [/COLOR]"
130 info["badges"] = f" {bullet} ".join(badges_list)
131
132 # Release Group
133 match = re.search(r"-([a-zA-Z0-9]+)$", title)
134 if match: info["release_group"] = match.group(1)
135
136 return info
plugin.video.jacktook/lib/utils/parsers/xmltodict.py +546 -0
……@@ -0,0 +1,546 @@
1#!/usr/bin/env python
2"Makes working with XML feel like you are working with JSON"
3
4try:
5 from defusedexpat import pyexpat as expat
6except ImportError:
7 from xml.parsers import expat
8from xml.sax.saxutils import XMLGenerator
9from xml.sax.xmlreader import AttributesImpl
10try: # pragma no cover
11 from cStringIO import StringIO
12except ImportError: # pragma no cover
13 try:
14 from StringIO import StringIO
15 except ImportError:
16 from io import StringIO
17
18_dict = dict
19import platform
20if tuple(map(int, platform.python_version_tuple()[:2])) < (3, 7):
21 from collections import OrderedDict as _dict
22
23from inspect import isgenerator
24
25try: # pragma no cover
26 _basestring = basestring
27except NameError: # pragma no cover
28 _basestring = str
29try: # pragma no cover
30 _unicode = unicode
31except NameError: # pragma no cover
32 _unicode = str
33
34__author__ = 'Martin Blech'
35__version__ = '0.13.0'
36__license__ = 'MIT'
37
38
39class ParsingInterrupted(Exception):
40 pass
41
42
43class _DictSAXHandler(object):
44 def __init__(self,
45 item_depth=0,
46 item_callback=lambda *args: True,
47 xml_attribs=True,
48 attr_prefix='@',
49 cdata_key='#text',
50 force_cdata=False,
51 cdata_separator='',
52 postprocessor=None,
53 dict_constructor=_dict,
54 strip_whitespace=True,
55 namespace_separator=':',
56 namespaces=None,
57 force_list=None,
58 comment_key='#comment'):
59 self.path = []
60 self.stack = []
61 self.data = []
62 self.item = None
63 self.item_depth = item_depth
64 self.xml_attribs = xml_attribs
65 self.item_callback = item_callback
66 self.attr_prefix = attr_prefix
67 self.cdata_key = cdata_key
68 self.force_cdata = force_cdata
69 self.cdata_separator = cdata_separator
70 self.postprocessor = postprocessor
71 self.dict_constructor = dict_constructor
72 self.strip_whitespace = strip_whitespace
73 self.namespace_separator = namespace_separator
74 self.namespaces = namespaces
75 self.namespace_declarations = dict_constructor()
76 self.force_list = force_list
77 self.comment_key = comment_key
78
79 def _build_name(self, full_name):
80 if self.namespaces is None:
81 return full_name
82 i = full_name.rfind(self.namespace_separator)
83 if i == -1:
84 return full_name
85 namespace, name = full_name[:i], full_name[i+1:]
86 try:
87 short_namespace = self.namespaces[namespace]
88 except KeyError:
89 short_namespace = namespace
90 if not short_namespace:
91 return name
92 else:
93 return self.namespace_separator.join((short_namespace, name))
94
95 def _attrs_to_dict(self, attrs):
96 if isinstance(attrs, dict):
97 return attrs
98 return self.dict_constructor(zip(attrs[0::2], attrs[1::2]))
99
100 def startNamespaceDecl(self, prefix, uri):
101 self.namespace_declarations[prefix or ''] = uri
102
103 def startElement(self, full_name, attrs):
104 name = self._build_name(full_name)
105 attrs = self._attrs_to_dict(attrs)
106 if attrs and self.namespace_declarations:
107 attrs['xmlns'] = self.namespace_declarations
108 self.namespace_declarations = self.dict_constructor()
109 self.path.append((name, attrs or None))
110 if len(self.path) >= self.item_depth:
111 self.stack.append((self.item, self.data))
112 if self.xml_attribs:
113 attr_entries = []
114 for key, value in attrs.items():
115 key = self.attr_prefix+self._build_name(key)
116 if self.postprocessor:
117 entry = self.postprocessor(self.path, key, value)
118 else:
119 entry = (key, value)
120 if entry:
121 attr_entries.append(entry)
122 attrs = self.dict_constructor(attr_entries)
123 else:
124 attrs = None
125 self.item = attrs or None
126 self.data = []
127
128 def endElement(self, full_name):
129 name = self._build_name(full_name)
130 if len(self.path) == self.item_depth:
131 item = self.item
132 if item is None:
133 item = (None if not self.data
134 else self.cdata_separator.join(self.data))
135
136 should_continue = self.item_callback(self.path, item)
137 if not should_continue:
138 raise ParsingInterrupted()
139 if self.stack:
140 data = (None if not self.data
141 else self.cdata_separator.join(self.data))
142 item = self.item
143 self.item, self.data = self.stack.pop()
144 if self.strip_whitespace and data and item:
145 data = data.strip() or None
146 if data and self.force_cdata and item is None:
147 item = self.dict_constructor()
148 if item is not None:
149 if data:
150 self.push_data(item, self.cdata_key, data)
151 self.item = self.push_data(self.item, name, item)
152 else:
153 self.item = self.push_data(self.item, name, data)
154 else:
155 self.item = None
156 self.data = []
157 self.path.pop()
158
159 def characters(self, data):
160 if not self.data:
161 self.data = [data]
162 else:
163 self.data.append(data)
164
165 def comments(self, data):
166 if self.strip_whitespace:
167 data = data.strip()
168 self.item = self.push_data(self.item, self.comment_key, data)
169
170 def push_data(self, item, key, data):
171 if self.postprocessor is not None:
172 result = self.postprocessor(self.path, key, data)
173 if result is None:
174 return item
175 key, data = result
176 if item is None:
177 item = self.dict_constructor()
178 try:
179 value = item[key]
180 if isinstance(value, list):
181 value.append(data)
182 else:
183 item[key] = [value, data]
184 except KeyError:
185 if self._should_force_list(key, data):
186 item[key] = [data]
187 else:
188 item[key] = data
189 return item
190
191 def _should_force_list(self, key, value):
192 if not self.force_list:
193 return False
194 if isinstance(self.force_list, bool):
195 return self.force_list
196 try:
197 return key in self.force_list
198 except TypeError:
199 return self.force_list(self.path[:-1], key, value)
200
201
202def parse(xml_input, encoding=None, expat=expat, process_namespaces=False,
203 namespace_separator=':', disable_entities=True, process_comments=False, **kwargs):
204 """Parse the given XML input and convert it into a dictionary.
205
206 `xml_input` can either be a `string`, a file-like object, or a generator of strings.
207
208 If `xml_attribs` is `True`, element attributes are put in the dictionary
209 among regular child elements, using `@` as a prefix to avoid collisions. If
210 set to `False`, they are just ignored.
211
212 Simple example::
213
214 >>> import xmltodict
215 >>> doc = xmltodict.parse(\"\"\"
216 ... <a prop="x">
217 ... <b>1</b>
218 ... <b>2</b>
219 ... </a>
220 ... \"\"\")
221 >>> doc['a']['@prop']
222 u'x'
223 >>> doc['a']['b']
224 [u'1', u'2']
225
226 If `item_depth` is `0`, the function returns a dictionary for the root
227 element (default behavior). Otherwise, it calls `item_callback` every time
228 an item at the specified depth is found and returns `None` in the end
229 (streaming mode).
230
231 The callback function receives two parameters: the `path` from the document
232 root to the item (name-attribs pairs), and the `item` (dict). If the
233 callback's return value is false-ish, parsing will be stopped with the
234 :class:`ParsingInterrupted` exception.
235
236 Streaming example::
237
238 >>> def handle(path, item):
239 ... print('path:%s item:%s' % (path, item))
240 ... return True
241 ...
242 >>> xmltodict.parse(\"\"\"
243 ... <a prop="x">
244 ... <b>1</b>
245 ... <b>2</b>
246 ... </a>\"\"\", item_depth=2, item_callback=handle)
247 path:[(u'a', {u'prop': u'x'}), (u'b', None)] item:1
248 path:[(u'a', {u'prop': u'x'}), (u'b', None)] item:2
249
250 The optional argument `postprocessor` is a function that takes `path`,
251 `key` and `value` as positional arguments and returns a new `(key, value)`
252 pair where both `key` and `value` may have changed. Usage example::
253
254 >>> def postprocessor(path, key, value):
255 ... try:
256 ... return key + ':int', int(value)
257 ... except (ValueError, TypeError):
258 ... return key, value
259 >>> xmltodict.parse('<a><b>1</b><b>2</b><b>x</b></a>',
260 ... postprocessor=postprocessor)
261 {'a': {'b:int': [1, 2], 'b': 'x'}}
262
263 You can pass an alternate version of `expat` (such as `defusedexpat`) by
264 using the `expat` parameter. E.g:
265
266 >>> import defusedexpat
267 >>> xmltodict.parse('<a>hello</a>', expat=defusedexpat.pyexpat)
268 {'a': 'hello'}
269
270 You can use the force_list argument to force lists to be created even
271 when there is only a single child of a given level of hierarchy. The
272 force_list argument is a tuple of keys. If the key for a given level
273 of hierarchy is in the force_list argument, that level of hierarchy
274 will have a list as a child (even if there is only one sub-element).
275 The index_keys operation takes precedence over this. This is applied
276 after any user-supplied postprocessor has already run.
277
278 For example, given this input:
279 <servers>
280 <server>
281 <name>host1</name>
282 <os>Linux</os>
283 <interfaces>
284 <interface>
285 <name>em0</name>
286 <ip_address>10.0.0.1</ip_address>
287 </interface>
288 </interfaces>
289 </server>
290 </servers>
291
292 If called with force_list=('interface',), it will produce
293 this dictionary:
294 {'servers':
295 {'server':
296 {'name': 'host1',
297 'os': 'Linux'},
298 'interfaces':
299 {'interface':
300 [ {'name': 'em0', 'ip_address': '10.0.0.1' } ] } } }
301
302 `force_list` can also be a callable that receives `path`, `key` and
303 `value`. This is helpful in cases where the logic that decides whether
304 a list should be forced is more complex.
305
306
307 If `process_comment` is `True` then comment will be added with comment_key
308 (default=`'#comment'`) to then tag which contains comment
309
310 For example, given this input:
311 <a>
312 <b>
313 <!-- b comment -->
314 <c>
315 <!-- c comment -->
316 1
317 </c>
318 <d>2</d>
319 </b>
320 </a>
321
322 If called with process_comment=True, it will produce
323 this dictionary:
324 'a': {
325 'b': {
326 '#comment': 'b comment',
327 'c': {
328
329 '#comment': 'c comment',
330 '#text': '1',
331 },
332 'd': '2',
333 },
334 }
335 """
336 handler = _DictSAXHandler(namespace_separator=namespace_separator,
337 **kwargs)
338 if isinstance(xml_input, _unicode):
339 if not encoding:
340 encoding = 'utf-8'
341 xml_input = xml_input.encode(encoding)
342 if not process_namespaces:
343 namespace_separator = None
344 parser = expat.ParserCreate(
345 encoding,
346 namespace_separator
347 )
348 try:
349 parser.ordered_attributes = True
350 except AttributeError:
351 # Jython's expat does not support ordered_attributes
352 pass
353 parser.StartNamespaceDeclHandler = handler.startNamespaceDecl
354 parser.StartElementHandler = handler.startElement
355 parser.EndElementHandler = handler.endElement
356 parser.CharacterDataHandler = handler.characters
357 if process_comments:
358 parser.CommentHandler = handler.comments
359 parser.buffer_text = True
360 if disable_entities:
361 try:
362 # Attempt to disable DTD in Jython's expat parser (Xerces-J).
363 feature = "http://apache.org/xml/features/disallow-doctype-decl"
364 parser._reader.setFeature(feature, True)
365 except AttributeError:
366 # For CPython / expat parser.
367 # Anything not handled ends up here and entities aren't expanded.
368 parser.DefaultHandler = lambda x: None
369 # Expects an integer return; zero means failure -> expat.ExpatError.
370 parser.ExternalEntityRefHandler = lambda *x: 1
371 if hasattr(xml_input, 'read'):
372 parser.ParseFile(xml_input)
373 elif isgenerator(xml_input):
374 for chunk in xml_input:
375 parser.Parse(chunk, False)
376 parser.Parse(b'', True)
377 else:
378 parser.Parse(xml_input, True)
379 return handler.item
380
381
382def _process_namespace(name, namespaces, ns_sep=':', attr_prefix='@'):
383 if not namespaces:
384 return name
385 try:
386 ns, name = name.rsplit(ns_sep, 1)
387 except ValueError:
388 pass
389 else:
390 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
394 return name
395
396
397def _emit(key, value, content_handler,
398 attr_prefix='@',
399 cdata_key='#text',
400 depth=0,
401 preprocessor=None,
402 pretty=False,
403 newl='\n',
404 indent='\t',
405 namespace_separator=':',
406 namespaces=None,
407 full_document=True,
408 expand_iter=None):
409 key = _process_namespace(key, namespaces, namespace_separator, attr_prefix)
410 if preprocessor is not None:
411 result = preprocessor(key, value)
412 if result is None:
413 return
414 key, value = result
415 if (not hasattr(value, '__iter__')
416 or isinstance(value, _basestring)
417 or isinstance(value, dict)):
418 value = [value]
419 for index, v in enumerate(value):
420 if full_document and depth == 0 and index > 0:
421 raise ValueError('document with multiple roots')
422 if v is None:
423 v = _dict()
424 elif isinstance(v, bool):
425 if v:
426 v = _unicode('true')
427 else:
428 v = _unicode('false')
429 elif not isinstance(v, dict):
430 if expand_iter and hasattr(v, '__iter__') and not isinstance(v, _basestring):
431 v = _dict(((expand_iter, v),))
432 else:
433 v = _unicode(v)
434 if isinstance(v, _basestring):
435 v = _dict(((cdata_key, v),))
436 cdata = None
437 attrs = _dict()
438 children = []
439 for ik, iv in v.items():
440 if ik == cdata_key:
441 cdata = iv
442 continue
443 if ik.startswith(attr_prefix):
444 ik = _process_namespace(ik, namespaces, namespace_separator,
445 attr_prefix)
446 if ik == '@xmlns' and isinstance(iv, dict):
447 for k, v in iv.items():
448 attr = 'xmlns{}'.format(':{}'.format(k) if k else '')
449 attrs[attr] = _unicode(v)
450 continue
451 if not isinstance(iv, _unicode):
452 iv = _unicode(iv)
453 attrs[ik[len(attr_prefix):]] = iv
454 continue
455 children.append((ik, iv))
456 if type(indent) is int:
457 indent = ' ' * indent
458 if pretty:
459 content_handler.ignorableWhitespace(depth * indent)
460 content_handler.startElement(key, AttributesImpl(attrs))
461 if pretty and children:
462 content_handler.ignorableWhitespace(newl)
463 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)
469 if cdata is not None:
470 content_handler.characters(cdata)
471 if pretty and children:
472 content_handler.ignorableWhitespace(depth * indent)
473 content_handler.endElement(key)
474 if pretty and depth:
475 content_handler.ignorableWhitespace(newl)
476
477
478def unparse(input_dict, output=None, encoding='utf-8', full_document=True,
479 short_empty_elements=False,
480 **kwargs):
481 """Emit an XML document for the given `input_dict` (reverse of `parse`).
482
483 The resulting XML document is returned as a string, but if `output` (a
484 file-like object) is specified, it is written there instead.
485
486 Dictionary keys prefixed with `attr_prefix` (default=`'@'`) are interpreted
487 as XML node attributes, whereas keys equal to `cdata_key`
488 (default=`'#text'`) are treated as character data.
489
490 The `pretty` parameter (default=`False`) enables pretty-printing. In this
491 mode, lines are terminated with `'\n'` and indented with `'\t'`, but this
492 can be customized with the `newl` and `indent` parameters.
493
494 """
495 if full_document and len(input_dict) != 1:
496 raise ValueError('Document must have exactly one root.')
497 must_return = False
498 if output is None:
499 output = StringIO()
500 must_return = True
501 if short_empty_elements:
502 content_handler = XMLGenerator(output, encoding, True)
503 else:
504 content_handler = XMLGenerator(output, encoding)
505 if full_document:
506 content_handler.startDocument()
507 for key, value in input_dict.items():
508 _emit(key, value, content_handler, full_document=full_document,
509 **kwargs)
510 if full_document:
511 content_handler.endDocument()
512 if must_return:
513 value = output.getvalue()
514 try: # pragma no cover
515 value = value.decode(encoding)
516 except AttributeError: # pragma no cover
517 pass
518 return value
519
520
521if __name__ == '__main__': # pragma: no cover
522 import sys
523 import marshal
524 try:
525 stdin = sys.stdin.buffer
526 stdout = sys.stdout.buffer
527 except AttributeError:
528 stdin = sys.stdin
529 stdout = sys.stdout
530
531 (item_depth,) = sys.argv[1:]
532 item_depth = int(item_depth)
533
534 def handle_item(path, item):
535 marshal.dump((path, item), stdout)
536 return True
537
538 try:
539 root = parse(stdin,
540 item_depth=item_depth,
541 item_callback=handle_item,
542 dict_constructor=dict)
543 if item_depth == 0:
544 handle_item([], root)
545 except KeyboardInterrupt:
546 pass
plugin.video.jacktook/lib/utils/player/utils.py +318 -0
……@@ -0,0 +1,318 @@
1from urllib.parse import quote
2from lib.jacktook.utils import kodilog
3from lib.utils.debrid.debrid_utils import (
4 get_debrid_direct_url,
5 get_debrid_pack_direct_url,
6 is_supported_debrid_type,
7)
8from lib.utils.kodi.utils import (
9 execute_builtin,
10 get_setting,
11 is_elementum_addon,
12 is_jacktorr_addon,
13 is_torrest_addon,
14 notification,
15 translation,
16)
17from lib.utils.kodi.logging import summarize_locator_for_log
18from lib.utils.general.utils import (
19 DebridType,
20 IndexerType,
21 Indexer,
22 Players,
23 torrent_clients,
24)
25from xbmcgui import Dialog
26from xbmc import LOGDEBUG
27from typing import Any, Dict, Optional
28
29
30def append_proxy_headers(url: str, headers: Dict[str, str]) -> str:
31 """Attach a Stremio source's required request headers to a direct URL.
32
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
35 percent-encoded, or a Referer's own `?`/`&`/`=` would split the header list.
36
37 Without this, hotlink-protected CDNs answer the playlist but 403 the video
38 segments, which surfaces in Kodi as a stream that opens and then dies.
39 """
40 if not url or not headers or "|" in url:
41 return url
42 encoded = "&".join(
43 f"{key}={quote(str(value), safe='')}"
44 for key, value in headers.items()
45 if key and value
46 )
47 return f"{url}|{encoded}" if encoded else url
48
49
50def resolve_playback_url(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
51 indexer_type: str = data.get("type", "")
52 debrid_type: str = data.get("debrid_type", "")
53 is_pack: bool = data.get("is_pack", False)
54
55 if indexer_type in [IndexerType.DIRECT, IndexerType.STREMIO_DEBRID]:
56 if data.get("indexer") == Indexer.EASYNEWS:
57 resolved_url = get_easynews_url(data)
58 if resolved_url:
59 data["url"] = resolved_url
60 return data
61 return None
62 elif data.get("indexer") in [Indexer.JACKGRAM, Indexer.TELEGRAM]:
63 token = get_setting("jackgram_token", "")
64 url = data.get("url", "")
65 if token and "|Authorization=Bearer" not in url:
66 data["url"] = f"{url}|Authorization=Bearer {token}"
67 else:
68 data["url"] = append_proxy_headers(
69 data.get("url", ""), data.get("proxyHeaders") or {}
70 )
71 return data
72
73 if is_supported_debrid_type(debrid_type):
74 debrid_url = get_debrid_url(data, debrid_type, is_pack)
75 if debrid_url:
76 return data
77 return None
78
79 addon_url = get_torrent_url(data)
80 if addon_url:
81 data["url"] = addon_url
82 return data
83 return None
84
85
86def get_easynews_url(data: Dict[str, Any]) -> Optional[str]:
87 from lib.clients.easynews import Easynews
88
89 try:
90 user = str(get_setting("easynews_user"))
91 password = str(get_setting("easynews_password"))
92 timeout = int(get_setting("easynews_timeout", "10") or "10")
93 client = Easynews(user, password, timeout, notification)
94 return client.resolve_url(data.get("url", ""))
95 except Exception as e:
96 kodilog(f"Error resolving Easynews link: {e}")
97 return None
98
99
100def get_torrent_url(data: Dict[str, Any]) -> Optional[str]:
101 magnet: str = data.get("magnet", "")
102 url: str = data.get("url", "")
103 mode: str = data.get("mode", "")
104 ids: Any = data.get("ids", "")
105 info_hash: str = data.get("info_hash", "")
106
107 if not magnet and info_hash:
108 from lib.utils.general.utils import info_hash_to_magnet
109
110 magnet = info_hash_to_magnet(info_hash)
111
112 kodilog(
113 "Torrent playback resolution: indexer={!r}, client={!r}, magnet={!r}, url={!r}, infohash={!r}, is_torrent={}".format(
114 data.get("indexer", ""),
115 str(get_setting("torrent_client")),
116 summarize_locator_for_log(magnet),
117 summarize_locator_for_log(url),
118 str(info_hash).lower()[:12],
119 data.get("is_torrent", False),
120 )
121 )
122
123 if get_setting("torrent_enable"):
124 return get_torrent_url_for_client(magnet, url, mode, ids)
125
126 if data.get("is_torrent"):
127 selected_client = get_torrent_client_selection(magnet, url, mode, ids)
128 if selected_client:
129 return get_torrent_url_for_client(magnet, url, mode, ids, selected_client)
130 else:
131 raise TorrentException("No torrent client selected")
132 return None
133
134
135def get_torrent_url_for_client(
136 magnet: str, url: str, mode: str, ids: Any, client: str = ""
137) -> Optional[str]:
138 torrent_client = client or str(get_setting("torrent_client"))
139 if torrent_client in [Players.TORREST]:
140 return get_torrest_url(magnet, url)
141 elif torrent_client in [Players.ELEMENTUM]:
142 return get_elementum_url(magnet, url, mode, ids)
143 elif torrent_client in [Players.JACKTORR]:
144 return get_jacktorr_url(magnet, url)
145 else:
146 raise TorrentException(f"Unknown torrent client selected: {torrent_client}")
147
148
149def get_torrent_client_selection(
150 magnet: str, url: str, mode: str, ids: Any
151) -> Optional[str]:
152 chosen_client = Dialog().select(translation(90341), torrent_clients)
153 if chosen_client < 0:
154 return None
155 return torrent_clients[chosen_client]
156
157
158def get_debrid_url(
159 data: Dict[str, Any], debrid_type: str, is_pack: bool
160) -> Optional[Dict[str, Any]]:
161 if is_pack and debrid_type in [DebridType.RD, DebridType.TB, DebridType.AD]:
162 return get_debrid_pack_direct_url(debrid_type, data)
163 else:
164 return get_debrid_direct_url(debrid_type, data)
165
166
167def get_elementum_url(magnet: str, url: str, mode: str, ids: Any) -> Optional[str]:
168 if not is_elementum_addon():
169 if Dialog().yesno(
170 translation(30252),
171 translation(30254),
172 yeslabel=translation(90605),
173 nolabel=translation(90606),
174 ):
175 execute_builtin("InstallAddon(plugin.video.elementum)")
176 else:
177 notification(translation(30252))
178 return None
179
180 tmdb_id = ids.get("tmdb_id", "") if isinstance(ids, dict) else ""
181
182 if magnet or url:
183 return f"plugin://plugin.video.elementum/play?uri={quote(magnet or url)}&type={mode}&tmdb={tmdb_id}"
184 else:
185 raise TorrentException("No magnet or url found for Elementum playback")
186
187
188def get_jacktorr_url(magnet: str, url: str) -> Optional[str]:
189 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 ),
196 level=LOGDEBUG,
197 )
198 if not is_jacktorr_addon():
199 if Dialog().yesno(
200 translation(30253),
201 translation(30255),
202 yeslabel=translation(90605),
203 nolabel=translation(90606),
204 ):
205 execute_builtin("InstallAddon(plugin.video.jacktorr)")
206 else:
207 notification(translation(30253))
208 return None
209 if magnet:
210 _url = f"plugin://plugin.video.jacktorr/play_magnet?magnet={quote(magnet)}"
211 elif url:
212 _url = f"plugin://plugin.video.jacktorr/play_url?url={quote(url)}"
213 else:
214 kodilog("Jacktorr playback failed due to empty magnet and url", level=LOGDEBUG)
215 raise TorrentException("No magnet or url found for Jacktorr playback")
216 return _url
217
218
219def get_torrest_url(magnet: str, url: str) -> Optional[str]:
220 if not is_torrest_addon():
221 if Dialog().yesno(
222 translation(30250),
223 translation(30256),
224 yeslabel=translation(90605),
225 nolabel=translation(90606),
226 ):
227 execute_builtin("InstallAddon(plugin.video.torrest)")
228 else:
229 notification(translation(30250))
230 return None
231 if magnet:
232 _url = f"plugin://plugin.video.torrest/play_magnet?magnet={quote(magnet)}"
233 elif url:
234 _url = f"plugin://plugin.video.torrest/play_url?url={quote(url)}"
235 else:
236 raise TorrentException("No magnet or url found for Torrest playback")
237 return _url
238
239
240def precache_next_episodes(item_data):
241 kodilog("Precaching next episodes...")
242
243 from lib.search import search_client
244 from lib.clients.tmdb.utils.utils import tmdb_get
245
246 if not get_setting("precaching_enabled", False):
247 return
248
249 if item_data.get("mode") != "tv":
250 return
251
252 ids = item_data.get("ids")
253 if not ids:
254 return
255
256 tmdb_id = ids.get("tmdb_id")
257 if not tmdb_id:
258 return
259
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
265 if season is None or episode is None:
266 kodilog("Invalid season or episode data")
267 return
268
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
284
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", "")
290
291 search_client(
292 query=query,
293 ids=ids,
294 mode=item_data["mode"],
295 media_type=item_data.get("media_type", ""),
296 rescrape=True,
297 season=season,
298 episode=episode_number,
299 show_dialog=False,
300 )
301
302 kodilog(f"Precaching episode {season}x{episode_number}")
303
304
305class TorrentException(Exception):
306 def __init__(
307 self, message: str, status_code: Optional[int] = None, error_content: Any = None
308 ):
309 self.message = message
310 self.status_code = status_code
311 self.error_content = error_content
312 details = f"{self.message}"
313 if self.status_code is not None:
314 details += f" (Status code: {self.status_code})"
315 if self.error_content is not None:
316 details += f"\nError content: {self.error_content}"
317 super().__init__(details)
318 notification(details)
plugin.video.jacktook/lib/utils/plex/utils.py +59 -0
……@@ -0,0 +1,59 @@
1from lib.api.plex.media_server import check_server_connection, get_servers
2from lib.api.plex.plex import PlexApi
3from lib.utils.kodi.utils import get_setting, kodilog, set_setting
4from xbmcgui import Dialog
5
6
7plex = PlexApi()
8
9server_config = {"discoveryUrl": [], "streamingUrl": []}
10
11def plex_login():
12 success = plex.login()
13 if success:
14 user = plex.get_plex_user()
15 if user:
16 set_setting("plex_user", user.username)
17
18
19def validate_server():
20 servers = get_servers("https://plex.tv/api/v2", get_setting("plex_token"))
21 if servers:
22 server_names = [s.name for s in servers]
23 chosen_server = Dialog().select("Select server", server_names)
24 if chosen_server < 0:
25 return
26 for s in servers:
27 if s.name == server_names[chosen_server]:
28 set_setting("plex_server_name", s.name)
29 set_setting("plex_server_token", s.access_token)
30 for c in s.connections:
31 server_config["streamingUrl"].append(c["uri"])
32 if c["local"] is not True:
33 server_config["discoveryUrl"].append(c["uri"])
34 break
35 discovery_test(server_config["discoveryUrl"], get_setting("plex_server_token"))
36 streaming_test(server_config["streamingUrl"], get_setting("plex_server_token"))
37
38
39def discovery_test(urls, access_token):
40 kodilog("Making discovery test...")
41 kodilog(urls)
42 for url in urls:
43 success = check_server_connection(url, access_token)
44 if success:
45 set_setting("plex_discovery_url", url)
46 break
47
48
49def streaming_test(urls, access_token):
50 kodilog("Making streaming test...")
51 kodilog(urls)
52 for url in urls:
53 success = check_server_connection(url, access_token)
54 if success:
55 set_setting("plex_streaming_url", url)
56 break
57
58def plex_logout():
59 plex.logout()
plugin.video.jacktook/lib/utils/stremio/catalogs_utils.py +50 -0
……@@ -0,0 +1,50 @@
1from datetime import timedelta
2from lib.clients.stremio.addon_client import StremioAddonCatalogsClient
3from lib.db.cached import cache
4from lib.utils.kodi.utils import kodilog
5from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled
6
7
8def catalogs_get_cache(path, params, *args, **kwargs):
9 identifier = f"{path}{params}{args}"
10 data = cache.get(identifier)
11 if data:
12 return data
13
14 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(),
27 "list_stremio_tv": lambda: StremioAddonCatalogsClient(params).get_stream_info(),
28 "list_stremio_movie": lambda: StremioAddonCatalogsClient(
29 params
30 ).get_stream_info(),
31 }
32
33 try:
34 handler = handlers.get(path, lambda: None)
35 if args or kwargs:
36 data = handler(*args, **kwargs)
37 else:
38 data = handler()
39 except Exception as e:
40 kodilog(f"Error: {e}")
41 return {}
42
43 if data is not None:
44 cache.set(
45 identifier,
46 data,
47 timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0),
48 )
49
50 return data
plugin.video.jacktook/lib/utils/tmdb_init.py +29 -0
……@@ -0,0 +1,29 @@
1"""
2Lazy TMDb initialization utility.
3
4Provides a function to ensure the TMDb API key and language are
5configured before any TMDB operation. Since TMDb stores configuration
6in environment variables, this only needs to be called once per
7addon invocation.
8"""
9
10from lib.api.tmdbv3api.tmdb import TMDb
11from lib.clients.tmdb.utils.utils import LANGUAGES
12from lib.utils.kodi.utils import get_setting
13
14_tmdb_initialized = False
15
16
17def ensure_tmdb_init():
18 """Initialize TMDb API key and language if not already done."""
19 global _tmdb_initialized
20 if _tmdb_initialized:
21 return
22 tmdb = TMDb()
23 tmdb.api_key = get_setting("tmdb_api_key", "b70756b7083d9ee60f849d82d94a0d80")
24 try:
25 language_index = get_setting("language", 18)
26 tmdb.language = LANGUAGES[int(language_index)]
27 except (IndexError, ValueError):
28 tmdb.language = "en-US"
29 _tmdb_initialized = True
plugin.video.jacktook/lib/utils/torrent/flatbencode.py +161 -0
……@@ -0,0 +1,161 @@
1import collections
2import io
3import itertools
4
5ONE_CHAR = 1
6INTEGER_START = b'i'
7LIST_START = b'l'
8DICT_START = b'd'
9END = b'e'
10NEGATIVE_SIGN = b'-'
11STRING_LENGTH_SEPARATOR = b':'
12
13__all__ = ['decode', 'DecodingError', 'encode']
14
15
16class DecodingError(ValueError):
17 pass
18
19
20def byte_is_integer(b):
21 return b'0' <= b <= b'9'
22
23
24def group_by(it, n):
25 """
26 >>> list(group_by([1, 2, 3, 4], 2))
27 [(1, 2), (3, 4)]
28 """
29 return zip(*[itertools.islice(it2, i, None, n) for i, it2 in enumerate(itertools.tee(it))])
30
31
32def list_to_dict(l):
33 if not all(isinstance(k, bytes) for k, v in group_by(reversed(l), 2)):
34 raise DecodingError
35 return collections.OrderedDict(group_by(reversed(l), 2))
36
37
38def _read_integer(buf):
39 c = buf.read(ONE_CHAR)
40 if c == NEGATIVE_SIGN:
41 negative = True
42 c = buf.read(ONE_CHAR)
43 else:
44 negative = False
45
46 acc = io.BytesIO()
47 while c != END:
48 if len(c) == 0:
49 raise DecodingError
50 if not byte_is_integer(c):
51 raise DecodingError
52 acc.write(c)
53 c = buf.read(ONE_CHAR)
54
55 n = acc.getvalue()
56 if n.startswith(b'0') and len(n) > 1: # '03' is illegal
57 raise DecodingError
58 n = int(n)
59 if n == 0 and negative: # '-0' is illegal
60 raise DecodingError
61 if negative:
62 n = -n
63 return n
64
65
66def _read_length(c, buf):
67 acc = io.BytesIO()
68 while c != STRING_LENGTH_SEPARATOR:
69 if not byte_is_integer(c):
70 raise DecodingError
71 acc.write(c)
72 c = buf.read(ONE_CHAR)
73 return int(acc.getvalue())
74
75
76def _read_string(firstchar, buf):
77 length = _read_length(firstchar, buf)
78 string = buf.read(length)
79 if len(string) != length:
80 raise DecodingError
81 return string
82
83
84list_starter = object()
85dict_starter = object()
86
87
88def decode(s):
89 buf = io.BufferedReader(io.BytesIO(s))
90 buf.seek(0)
91
92 stack = []
93
94 while True:
95 c = buf.read(ONE_CHAR)
96 if not c:
97 raise DecodingError
98 if c == END:
99 acc = []
100 while True:
101 if not stack:
102 raise DecodingError
103 x = stack.pop()
104 if x == list_starter:
105 elem = list(reversed(acc))
106 break
107 elif x == dict_starter:
108 elem = list_to_dict(acc)
109 break
110 else:
111 acc.append(x)
112 elif c == INTEGER_START:
113 elem = _read_integer(buf)
114 elif c == DICT_START:
115 stack.append(dict_starter)
116 continue
117 elif c == LIST_START:
118 stack.append(list_starter)
119 continue
120 else:
121 elem = _read_string(c, buf)
122
123 if not stack:
124 end_of_string = not buf.read(ONE_CHAR)
125 if not end_of_string:
126 raise DecodingError
127 return elem
128 else:
129 stack.append(elem)
130
131
132def encode(obj):
133 def generator(obj):
134 if isinstance(obj, dict):
135 if not all(isinstance(k, bytes) for k in obj.keys()):
136 raise ValueError("Dictionary keys must be strings")
137 yield DICT_START
138 # Dictionary keys should be sorted according to the BEP-0003:
139 # "Keys must be strings and appear in sorted order (sorted as
140 # raw strings, not alphanumerics)."
141 for k in sorted(obj.keys()):
142 yield from generator(k)
143 yield from generator(obj[k])
144 yield END
145 elif isinstance(obj, list):
146 yield LIST_START
147 for elem in obj:
148 yield from generator(elem)
149 yield END
150 elif isinstance(obj, bytes):
151 yield str(len(obj)).encode('ascii')
152 yield STRING_LENGTH_SEPARATOR
153 yield obj
154 elif isinstance(obj, int):
155 yield INTEGER_START
156 yield str(obj).encode('ascii')
157 yield END
158 else:
159 raise ValueError("type {} not supported".format(type(obj)))
160
161 return b''.join(generator(obj))
plugin.video.jacktook/lib/utils/torrent/torrserver_init.py +25 -0
……@@ -0,0 +1,25 @@
1from lib.api.jacktorr.jacktorr import TorrServer
2from lib.utils.general.utils import (
3 get_service_host,
4 get_port,
5 get_username,
6 get_password,
7 ssl_enabled,
8)
9from lib.utils.kodi.utils import JACKTORR_ADDON
10
11_torrserver_api = None
12
13
14def get_torrserver_api():
15 global _torrserver_api
16
17 if _torrserver_api is None and JACKTORR_ADDON:
18 _torrserver_api = TorrServer(
19 get_service_host(),
20 get_port(),
21 get_username(),
22 get_password(),
23 ssl_enabled(),
24 )
25 return _torrserver_api
plugin.video.jacktook/lib/utils/torrent/torrserver_utils.py +207 -0
……@@ -0,0 +1,207 @@
1import hashlib
2from io import BytesIO
3import requests
4from lib.utils.kodi.kodi_formats import is_picture, is_text, is_video, is_music
5from lib.utils.kodi.utils import (
6 ADDON_HANDLE,
7 JACKTORR_ADDON,
8 buffer_and_play,
9 build_url,
10 kodilog,
11 notification,
12 refresh,
13 show_picture,
14 translation,
15)
16from lib.utils.general.utils import (
17 USER_AGENT_HEADER,
18 build_list_item,
19)
20from lib.vendor.bencodepy import bencodepy
21
22from xbmcplugin import addDirectoryItem, endOfDirectory
23from xbmcgui import Dialog
24
25from lib.utils.torrent.torrserver_init import get_torrserver_api
26
27
28def torrent_status(info_hash):
29 status = get_torrserver_api().get_torrent_info(link=info_hash)
30 notification(
31 "{}".format(status.get("stat_string")),
32 status.get("name"),
33 sound=False,
34 )
35
36
37def torrent_files(params):
38 info_hash = params.get("info_hash")
39
40 info = get_torrserver_api().get_torrent_info(link=info_hash)
41 file_stats = info.get("file_stats")
42
43 for f in file_stats:
44 name = f.get("path")
45 id = f.get("id")
46 serve_url = get_torrserver_api().get_stream_url(
47 link=info_hash, path=f.get("path"), file_id=id
48 )
49 file_li = build_list_item(name, "download.png")
50 file_li.setPath(serve_url)
51
52 context_menu_items = []
53 info_type = None
54 info_labels = {"title": info.get("title")}
55 kwargs = dict(info_hash=info_hash, file_id=id, path=name)
56
57 if is_picture(name):
58 url = build_url("display_picture", **kwargs)
59 file_li.setInfo("pictures", info_labels)
60 elif is_text(name):
61 url = build_url("display_text", **kwargs)
62 else:
63 url = serve_url
64 if is_video(name):
65 info_type = "video"
66 elif is_music(name):
67 info_type = "music"
68
69 if info_type is not None:
70 file_li.setInfo(info_type, info_labels)
71 file_li.setProperty("IsPlayable", "true")
72
73 context_menu_items.append(
74 (
75 translation(30700),
76 buffer_and_play(**kwargs),
77 )
78 )
79
80 file_li.addContextMenuItems(context_menu_items)
81
82 if info_type is not None:
83 addDirectoryItem(
84 ADDON_HANDLE,
85 build_url("play_url", url=serve_url, name=name),
86 file_li,
87 )
88 else:
89 addDirectoryItem(ADDON_HANDLE, url, file_li)
90 endOfDirectory(ADDON_HANDLE)
91
92
93def torrent_action(params):
94 info_hash = params.get("info_hash")
95 action_str = params.get("action_str")
96
97 needs_refresh = True
98
99 if action_str == "drop":
100 get_torrserver_api().drop_torrent(info_hash)
101 elif action_str == "remove_torrent":
102 get_torrserver_api().remove_torrent(info_hash)
103 elif action_str == "torrent_status":
104 torrent_status(info_hash)
105 needs_refresh = False
106 else:
107 kodilog(f"Unknown action: {action_str}")
108 needs_refresh = False
109
110 if needs_refresh:
111 refresh()
112
113
114def display_picture(params):
115 show_picture(
116 get_torrserver_api().get_stream_url(
117 link=params.get("info_hash"),
118 path=params.get("path"),
119 file_id=params.get("file_id"),
120 )
121 )
122
123
124def display_text(params):
125 r = requests.get(
126 get_torrserver_api().get_stream_url(
127 link=params.get("info_hash"),
128 path=params.get("path"),
129 file_id=params.get("file_id"),
130 )
131 )
132 Dialog().textviewer(params.get("path"), r.text)
133
134
135def add_source_to_torrserver(
136 magnet="", url="", info_hash="", title="", poster=""
137):
138 if not JACKTORR_ADDON:
139 notification(translation(30253))
140 return None
141
142 api = get_torrserver_api()
143 if api is None:
144 notification(translation(30253))
145 return None
146
147 try:
148 added_hash = None
149
150 if url and url.startswith("http"):
151 try:
152 response = requests.get(url, timeout=15, headers=USER_AGENT_HEADER)
153 response.raise_for_status()
154 torrent_obj = BytesIO(response.content)
155 torrent_obj.name = "torrent.torrent"
156 added_hash = api.add_torrent_obj(
157 torrent_obj, title=title, poster=poster
158 )
159 except Exception as exc:
160 kodilog(f"Failed to add torrent URL to TorrServer: {exc}")
161
162 if not added_hash:
163 if not magnet and info_hash:
164 magnet = convert_info_hash_to_magnet(info_hash)
165
166 if magnet:
167 added_hash = api.add_magnet(magnet, title=title, poster=poster)
168 else:
169 notification(translation(90361))
170 return None
171
172 notification(translation(90360))
173 return added_hash
174 except Exception as exc:
175 kodilog(f"Failed to add source to TorrServer: {exc}")
176 notification(str(exc))
177 return None
178
179
180def extract_magnet_from_url(url: str):
181 try:
182 response = requests.get(url, timeout=10, headers=USER_AGENT_HEADER)
183 if response.status_code == 200:
184 return extract_torrent_metadata(response.content)
185 else:
186 kodilog(f"Failed to fetch content from URL: {url}")
187 return None
188 except Exception as e:
189 kodilog(f"Failed to fetch content from URL: {url}, Error: {e}")
190 return None
191
192
193def extract_torrent_metadata(content: bytes):
194 kodilog("Extracting torrent metadata...")
195 try:
196 torrent_data = bencodepy.decode(content)
197 info = torrent_data[b"info"]
198 info_encoded = bencodepy.encode(info)
199 info_hash = hashlib.sha1(info_encoded).hexdigest()
200 return convert_info_hash_to_magnet(info_hash)
201 except Exception as e:
202 kodilog(f"Error occurred extracting torrent metadata: {e}")
203 return None
204
205
206def convert_info_hash_to_magnet(info_hash: str) -> str:
207 return f"magnet:?xt=urn:btih:{info_hash}"
plugin.video.jacktook/lib/utils/torrentio/utils.py +77 -0
……@@ -0,0 +1,77 @@
1from datetime import timedelta
2from lib.db.cached import cache
3from lib.utils.kodi.settings import get_cache_expiration, is_cache_enabled
4from lib.utils.kodi.utils import translation
5import xbmcgui
6
7
8items = [
9 "YTS",
10 "EZTV",
11 "RARBG",
12 "1337x",
13 "ThePirateBay",
14 "KickassTorrents",
15 "TorrentGalaxy",
16 "MagnetDL",
17 "HorribleSubs",
18 "NyaaSi",
19 "TokyoTosho",
20 "AniDex",
21 "Rutor",
22 "Rutracker",
23 "Comando",
24 "BluDV",
25 "Torrent9",
26 "MejorTorrent",
27 "Wolfmax4k",
28 "Cinecalidad",
29]
30
31
32def open_providers_selection(identifier="torrentio_providers"):
33 cached_providers = cache.get(identifier)
34 if cached_providers:
35 choice = xbmcgui.Dialog().yesno(
36 translation(90570),
37 translation(90571) % ",".join(cached_providers),
38 yeslabel=translation(90605),
39 nolabel=translation(90606),
40 )
41 if not choice:
42 return
43 providers_selection()
44 else:
45 providers_selection()
46
47
48def providers_selection(identifier="torrentio_providers"):
49 selected = xbmcgui.Dialog().multiselect(translation(90572), items)
50 if selected:
51 providers = [items[i] for i in selected]
52 cache.set(
53 identifier,
54 providers,
55 timedelta(hours=get_cache_expiration() if is_cache_enabled() else 0),
56 )
57 xbmcgui.Dialog().ok(
58 translation(90573), translation(90574) % ",".join(providers)
59 )
60 else:
61 xbmcgui.Dialog().notification(
62 translation(90575), translation(90576), xbmcgui.NOTIFICATION_INFO
63 )
64
65
66def filter_torrentio_provider(results, identifier="torrentio_providers"):
67 selected_providers = cache.get(identifier)
68 if not selected_providers:
69 return results
70
71 filtered_results = [
72 res
73 for res in results
74 if res["indexer"] != "Torrentio"
75 or (res["indexer"] == "Torrentio" and res["provider"] in selected_providers)
76 ]
77 return filtered_results
plugin.video.jacktook/lib/utils/views/continue_watching.py +117 -0
……@@ -0,0 +1,117 @@
1# -*- coding: utf-8 -*-
2from lib.jacktook.utils import kodilog
3import json
4import os
5from xbmc import executebuiltin
6from xbmcplugin import endOfDirectory, setContent
7from lib.utils.kodi.utils import (
8 ADDON_HANDLE,
9 ADDON_PATH,
10 add_directory_items_batch,
11 apply_section_view,
12 build_url,
13 make_list_item,
14 translation,
15)
16from lib.db.pickle_db import PickleDatabase
17from lib.utils.general.utils import set_pluging_category
18from lib.utils.views.last_files import add_last_files_context_menu, parse_time
19from lib.utils.kodi.utils import end_of_directory
20from lib.utils.general.utils import format_season_episode, truncate_text
21
22
23def has_continue_watching_items():
24 all_items = PickleDatabase().get_key("jt:lfh").items()
25 for title, data in all_items:
26 progress = float(data.get("progress", 0))
27 if 5 < progress < 90:
28 return True
29 return False
30
31
32def remove_continue_watching_item(params):
33 title = params.get("title")
34 if title:
35 PickleDatabase().delete_item(key="jt:lfh", subkey=title)
36 executebuiltin("Container.Refresh")
37
38
39def show_continue_watching():
40 set_pluging_category(translation(90200))
41 setContent(ADDON_HANDLE, "videos")
42
43 all_items = list(reversed(PickleDatabase().get_key("jt:lfh").items()))
44 items = sorted(all_items, key=parse_time, reverse=True)
45
46 directory_items = []
47 for title, data in items:
48 # Check progress
49 progress = float(data.get("progress", 0))
50 if progress <= 5 or progress >= 90:
51 continue
52
53 tv_data = data.get("tv_data", {})
54
55 label_title = data.get("title", "")
56
57 if tv_data:
58 show_name = tv_data.get("name", "")
59 episode_label = format_season_episode(
60 tv_data.get("season"), tv_data.get("episode")
61 )
62 if episode_label:
63 label = f"{show_name or label_title} {episode_label}"
64 else:
65 label = show_name or label_title
66 else:
67 label = label_title
68
69 list_item = make_list_item(label=label)
70
71 # Set Art
72 icon = os.path.join(ADDON_PATH, "resources", "img", "magnet.png")
73
74 # Try to find art in data
75 art = {}
76 if data.get("poster"):
77 art["poster"] = data.get("poster")
78 if data.get("fanart"):
79 art["fanart"] = data.get("fanart")
80 if not art:
81 art["icon"] = icon
82 list_item.setArt(art)
83
84 # Set Info
85 info_tag = list_item.getVideoInfoTag()
86 info_tag.setTitle(label)
87 info_tag.setPlot(truncate_text(data.get("overview", "")))
88
89 if "current_time" in data and "total_time" in data:
90 try:
91 current_time = float(data["current_time"])
92 total_time = float(data["total_time"])
93 info_tag.setResumePoint(current_time, total_time)
94 except ValueError:
95 pass
96
97 list_item.setProperty("PercentPlayed", str(progress))
98
99 list_item.setProperty("IsPlayable", "true")
100 context_menu = add_last_files_context_menu(data)
101 context_menu.append(
102 (
103 translation(90206),
104 f"RunPlugin({build_url('remove_continue_watching', title=title)})",
105 )
106 )
107 list_item.addContextMenuItems(context_menu)
108
109 # url to play
110 url = build_url("play_media", data=json.dumps(data))
111
112 directory_items.append((url, list_item, False))
113
114 add_directory_items_batch(directory_items)
115
116 end_of_directory(cache=False)
117 apply_section_view("view.history", content_type="videos")
plugin.video.jacktook/lib/utils/views/last_files.py +66 -0
……@@ -0,0 +1,66 @@
1import json
2import os
3from lib.db.pickle_db import PickleDatabase
4from lib.utils.general.utils import (
5 format_season_episode,
6 parse_time,
7 set_pluging_category,
8)
9from lib.utils.kodi.last_files_actions import add_last_files_context_menu
10from lib.utils.kodi.utils import ADDON_HANDLE, ADDON_PATH, add_directory_items_batch, apply_section_view, build_url, end_of_directory, make_list_item, translation
11
12
13
14
15pickle_db = PickleDatabase()
16
17
18def show_last_files():
19 set_pluging_category(translation(90071))
20
21 directory_items = []
22
23 list_item = make_list_item(label="Clear Files")
24 list_item.setArt(
25 {"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")}
26 )
27 directory_items.append((build_url("clear_history", type="lfh"), list_item, True))
28
29 all_items = list(reversed(pickle_db.get_key("jt:lfh").items()))
30
31 items = sorted(all_items, key=parse_time, reverse=True)
32
33 for title, data in items:
34 formatted_time = data["timestamp"]
35 tv_data = data.get("tv_data", {})
36 if tv_data:
37 name = tv_data.get("name", "")
38 episode_label = format_season_episode(
39 tv_data.get("season"), tv_data.get("episode")
40 )
41 if episode_label:
42 label = f"{title} {episode_label} - {name} — {formatted_time}"
43 else:
44 label = f"{title} - {name} — {formatted_time}"
45 else:
46 label = f"{title}—{formatted_time}"
47
48 list_item = make_list_item(label=label)
49 list_item.setArt(
50 {"icon": os.path.join(ADDON_PATH, "resources", "img", "magnet.png")}
51 )
52 list_item.setProperty("IsPlayable", "true")
53 list_item.addContextMenuItems(add_last_files_context_menu(data))
54 directory_items.append(
55 (
56 build_url(
57 "play_media",
58 data=json.dumps(data),
59 ),
60 list_item,
61 False,
62 )
63 )
64 add_directory_items_batch(directory_items)
65 end_of_directory()
66 apply_section_view("view.history")
plugin.video.jacktook/lib/utils/views/last_titles.py +117 -0
……@@ -0,0 +1,117 @@
1import json
2import os
3
4from lib.clients.tmdb.utils.utils import tmdb_get
5from lib.db.pickle_db import PickleDatabase
6from lib.jacktook.utils import kodilog
7from lib.utils.general.utils import parse_time, set_media_infoTag, set_pluging_category
8from lib.utils.kodi.utils import (
9 ADDON_HANDLE,
10 ADDON_PATH,
11 add_directory_items_batch,
12 apply_section_view,
13 build_url,
14 container_refresh,
15 end_of_directory,
16 make_list_item,
17 translation,
18)
19
20
21
22
23pickle_db = PickleDatabase()
24
25
26def delete_last_title_entry(params):
27 pickle_db.delete_item(key="jt:lth", subkey=params.get("title"))
28 container_refresh()
29
30
31def show_last_titles(params):
32 if params is None:
33 params = {}
34
35 set_pluging_category(translation(90070))
36
37 per_page = 10
38 page = int(params.get("page", 1))
39
40 all_items = list(reversed(pickle_db.get_key("jt:lth").items()))
41 total = len(all_items)
42
43 start = (page - 1) * per_page
44 end = start + per_page
45 items = all_items[start:end]
46
47 items = sorted(items, key=parse_time, reverse=True)
48
49 # Add "Clear Titles" button
50 directory_items = []
51
52 list_item = make_list_item(label="Clear Titles")
53 list_item.setArt(
54 {"icon": os.path.join(ADDON_PATH, "resources", "img", "clear.png")}
55 )
56 directory_items.append((build_url("clear_history", type="lth"), list_item, True))
57
58 for title, data in items:
59 formatted_time = data["timestamp"]
60 mode = data["mode"]
61 ids = data.get("ids")
62
63 if mode == "tv":
64 details = tmdb_get("tv_details", ids.get("tmdb_id"))
65 else:
66 details = tmdb_get("movie_details", ids.get("tmdb_id"))
67
68 if not details:
69 kodilog(f"Failed to get details for {mode} with ID {ids.get('tmdb_id')}")
70 continue
71
72 list_item = make_list_item(label=f"{title} — {formatted_time}")
73 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 )
77 list_item.addContextMenuItems(
78 [
79 (
80 "Delete from history",
81 f'RunPlugin({build_url("delete_last_title_entry", title=title)})',
82 )
83 ]
84 )
85
86 if mode == "tv":
87 directory_items.append(
88 (build_url("show_seasons_details", ids=ids, mode=mode), list_item, True)
89 )
90 elif mode == "movies":
91 list_item.setProperty("IsPlayable", "true")
92 directory_items.append(
93 (build_url("search", mode=mode, query=title, ids=ids), list_item, False)
94 )
95 elif mode == "tg_latest":
96 directory_items.append(
97 (
98 build_url(
99 "list_jackgram_title_sources", data=json.dumps(data.get("tg_data"))
100 ),
101 list_item,
102 True,
103 )
104 )
105
106 # "Next Page"
107 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 )
112 directory_items.append((build_url("titles_history", page=page + 1), list_item, True))
113
114 add_directory_items_batch(directory_items)
115
116 end_of_directory()
117 apply_section_view("view.history")
plugin.video.jacktook/lib/utils/views/library.py +219 -0
……@@ -0,0 +1,219 @@
1# -*- coding: utf-8 -*-
2import os
3
4from xbmcgui import ListItem
5from xbmcplugin import addDirectoryItem, setContent
6
7from lib.clients.tmdb.utils.utils import tmdb_get
8from lib.db.cached import cache
9from lib.db.pickle_db import PickleDatabase
10from lib.jacktook.utils import kodilog
11from lib.utils.general.utils import remove_from_library, set_media_infoTag, set_pluging_category
12from lib.utils.kodi.utils import ADDON_HANDLE, ADDON_PATH, add_directory_items_batch, apply_section_view, build_url, end_of_directory, make_list_item, notification, translation
13from lib.utils.views.last_titles import parse_time
14
15
16def _library_cache_key(mode):
17 return "library_view|{}".format(mode)
18
19
20def _is_stremio_library_item(data):
21 return data.get("source") == "stremio_catalog" or (
22 data.get("addon_url")
23 and data.get("catalog_type")
24 and (data.get("meta_id") or data.get("ids", {}).get("original_id"))
25 )
26
27
28def _normalize_library_mode(mode):
29 return "tv" if mode == "tv" else "movies"
30
31
32def _build_stremio_details(data, mode):
33 ids = data.get("ids", {})
34 details = {
35 "title": data.get("title", ""),
36 "name": data.get("title", ""),
37 "overview": data.get("overview", ""),
38 "poster": data.get("poster", ""),
39 "fanart": data.get("fanart", ""),
40 "banner": data.get("fanart", ""),
41 "landscape": data.get("fanart", ""),
42 "clearart": "",
43 "clearlogo": "",
44 "imdb_id": ids.get("imdb_id", ""),
45 "tvdb_id": ids.get("tvdb_id", ""),
46 "id": ids.get("tmdb_id", "") or ids.get("original_id", ""),
47 "genres": data.get("genres", []),
48 }
49 if mode == "tv":
50 details["original_name"] = data.get("title", "")
51 else:
52 details["original_title"] = data.get("title", "")
53 return details
54
55
56def _build_tmdb_entry(title, data, mode):
57 ids = data.get("ids", {})
58 details_path = "tv_details" if mode == "tv" else "movie_details"
59 details = tmdb_get(details_path, ids.get("tmdb_id"))
60 if not details:
61 return None
62 return {"title": title, "data": data, "details": details, "is_stremio": False}
63
64
65def _build_stremio_entry(title, data, mode):
66 return {
67 "title": title,
68 "data": data,
69 "details": _build_stremio_details(data, mode),
70 "is_stremio": True,
71 }
72
73
74def _get_library_entries(items, mode):
75 cached_entries = cache.get(_library_cache_key(mode))
76 if cached_entries:
77 return cached_entries
78
79 entries = []
80 for title, data in items:
81 if _is_stremio_library_item(data):
82 entries.append(_build_stremio_entry(title, data, mode))
83 continue
84
85 entry = _build_tmdb_entry(title, data, mode)
86 if entry:
87 entries.append(entry)
88
89 cache.set(_library_cache_key(mode), entries)
90 return entries
91
92
93def _build_library_context_menu(title):
94 remove_url = build_url("remove_from_library", title=title)
95 return [(translation(90204), f"RunPlugin({remove_url})")]
96
97
98def _library_mode_matches(item_mode, mode):
99 if mode == "tv":
100 return item_mode == "tv"
101 return item_mode in ["movie", "movies"]
102
103
104def _get_stremio_library_url(data, mode):
105 ids = data.get("ids", {})
106 meta_id = data.get("meta_id") or ids.get("original_id", "")
107 if mode == "tv":
108 if data.get("addon_url") and data.get("catalog_type") and meta_id:
109 return build_url(
110 "list_stremio_seasons",
111 addon_url=data.get("addon_url"),
112 catalog_type=data.get("catalog_type"),
113 meta_id=meta_id,
114 )
115 return build_url("show_seasons_details", ids=ids, mode=mode)
116
117 if data.get("addon_url") and data.get("catalog_type") and meta_id:
118 return build_url(
119 "list_stremio_movie",
120 addon_url=data.get("addon_url"),
121 catalog_type=data.get("catalog_type"),
122 meta_id=meta_id,
123 ids=data.get("ids", {}),
124 title=data.get("title", ""),
125 overview=data.get("overview", ""),
126 poster=data.get("poster", ""),
127 fanart=data.get("fanart", ""),
128 genres=data.get("genres", []),
129 )
130
131 return build_url("search", mode=mode, query=data.get("title", ""), ids=ids)
132
133
134def _get_library_item_url(entry, mode):
135 data = entry["data"]
136 ids = data.get("ids", {})
137 if entry["is_stremio"]:
138 return _get_stremio_library_url(data, mode)
139 if mode == "tv":
140 return build_url("show_seasons_details", ids=ids, mode=mode)
141 return build_url("search", mode=mode, query=entry["title"], ids=ids)
142
143
144def show_library_items(mode="tv"):
145 category = translation(90202) if mode == "tv" else translation(90203)
146 set_pluging_category(category)
147 setContent(ADDON_HANDLE, mode)
148
149 all_items = list(reversed(PickleDatabase().get_key("jt:lib").items()))
150 items = []
151 for title, data in all_items:
152 item_mode = data.get("mode")
153 if _library_mode_matches(item_mode, mode):
154 items.append((title, data))
155
156 items = sorted(items, key=parse_time, reverse=True)
157
158 directory_items = []
159 for entry in _get_library_entries(items, mode):
160 title = entry["title"]
161 data = entry["data"]
162 details = entry["details"]
163
164 list_item = make_list_item(label=title)
165 set_media_infoTag(list_item, data=details, mode=_normalize_library_mode(mode))
166 list_item.addContextMenuItems(_build_library_context_menu(title))
167
168 # 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 )
172
173 item_url = _get_library_item_url(entry, mode)
174 is_folder = mode == "tv"
175 if mode == "movies":
176 list_item.setProperty("IsPlayable", "true" if not entry["is_stremio"] else "false")
177 directory_items.append((item_url, list_item, is_folder))
178
179 add_directory_items_batch(directory_items)
180
181 end_of_directory(cache=False)
182 if mode == "tv":
183 apply_section_view("view.library", content_type="tvshows")
184 else:
185 apply_section_view("view.library", content_type="movies")
186
187
188def remove_library_item(params):
189 title = params.get("title")
190 if title:
191 remove_from_library(title)
192 from xbmc import executebuiltin
193
194 executebuiltin("Container.Refresh")
195
196
197def clear_library_items(params):
198 mode = params.get("mode", "tv")
199 pickle_db = PickleDatabase()
200 library_items = dict(pickle_db.get_key("jt:lib") or {})
201 titles_to_remove = []
202 removed_count = 0
203 for title, data in library_items.items():
204 if _library_mode_matches(data.get("mode"), mode):
205 titles_to_remove.append(title)
206 removed_count += 1
207 for title in titles_to_remove:
208 pickle_db.delete_item("jt:lib", title, commit=False)
209 if titles_to_remove:
210 pickle_db.commit()
211
212 cache.delete(_library_cache_key("tv"))
213 cache.delete(_library_cache_key("movies"))
214
215 if removed_count:
216 notification("", translation(90692))
217 else:
218 notification("", translation(90693))
219 return removed_count
plugin.video.jacktook/lib/utils/views/shows.py +178 -0
……@@ -0,0 +1,178 @@
1import json
2from lib.api.trakt.trakt_utils import add_trakt_watched_context_menu, is_trakt_auth
3from lib.clients.tmdb.utils.utils import (
4 add_tmdb_episode_context_menu,
5 add_tmdb_show_context_menu,
6 tmdb_get,
7)
8from lib.utils.kodi.utils import (
9 ADDON_HANDLE,
10 add_directory_items_batch,
11 apply_section_view,
12 build_url,
13 end_of_directory,
14 get_setting,
15 make_list_item,
16)
17from lib.utils.general.utils import (
18 execute_thread_pool_collection,
19 get_fanart_details,
20 set_content_type,
21 set_media_infoTag,
22)
23
24
25
26
27def show_seasons_details(params):
28 set_content_type("season")
29
30 ids = json.loads(params.get("ids", "{}"))
31 mode = params.get("mode", "")
32 media_type = params.get("media_type", "")
33
34 show_season_info(ids, mode, media_type)
35 end_of_directory()
36 apply_section_view("view.seasons", content_type="seasons")
37
38
39def show_season_info(ids, mode, media_type):
40 tmdb_id = ids.get("tmdb_id")
41 tvdb_id = ids.get("tvdb_id")
42 imdb_id = ids.get("imdb_id")
43
44 if imdb_id:
45 res = tmdb_get("find_by_imdb_id", imdb_id)
46 if res and res.get("tv_results"):
47 tmdb_id = res["tv_results"][0]["id"]
48
49 ids = {"tmdb_id": tmdb_id, "tvdb_id": tvdb_id, "imdb_id": imdb_id}
50
51 details = tmdb_get("tv_details", tmdb_id)
52 name = getattr(details, "name")
53 seasons = getattr(details, "seasons")
54 fanart_details = get_fanart_details(tvdb_id=tvdb_id, mode=mode)
55
56 results = execute_thread_pool_collection(
57 seasons,
58 _process_season,
59 details,
60 name,
61 ids,
62 mode,
63 media_type,
64 fanart_details,
65 )
66
67 # Sort by season number
68 results.sort(key=lambda x: x[0])
69
70 add_directory_items_batch(
71 [(url, list_item, True) for _, url, list_item in results if list_item is not None]
72 )
73
74
75def _process_season(season, details, name, ids, mode, media_type, fanart_details):
76 season_name = season.name
77 overview = season.overview
78 if not overview:
79 season.update({"overview": getattr(details, "overview", "")})
80
81 if "Miniseries" in season_name:
82 season_name = "Season 1"
83
84 season_number = season.season_number
85 if season_number == 0 and not get_setting("include_tvshow_specials"):
86 return # skip specials if disabled
87
88 list_item = make_list_item(label=season_name)
89 set_media_infoTag(list_item, data=season, fanart_data=fanart_details, mode="season")
90 list_item.setProperty("IsPlayable", "false")
91
92 context_menu = add_tmdb_show_context_menu(mode, ids)
93 if is_trakt_auth():
94 context_menu += add_trakt_watched_context_menu(
95 "shows", season=season_number, ids=ids
96 )
97 list_item.addContextMenuItems(context_menu)
98
99 url = build_url(
100 "show_episodes_details",
101 tv_name=name,
102 ids=ids,
103 mode=mode,
104 media_type=media_type,
105 season=season_number,
106 )
107
108 return (season_number, url, list_item)
109
110
111def show_episodes_details(params):
112 set_content_type("episode")
113
114 tv_name = params.get("tv_name", "")
115 season = int(params.get("season", 1))
116 ids = json.loads(params.get("ids", "{}"))
117 mode = params.get("mode", "")
118 media_type = params.get("media_type", "")
119
120 show_episode_info(tv_name, season, ids, mode, media_type)
121 end_of_directory()
122 apply_section_view("view.episodes", content_type="episodes")
123
124
125def 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}
128 )
129 fanart_details = get_fanart_details(tvdb_id=ids.get("tvdb_id"), mode=mode)
130
131 results = execute_thread_pool_collection(
132 getattr(season_details, "episodes"),
133 _process_episode,
134 tv_name,
135 season,
136 ids,
137 mode,
138 media_type,
139 fanart_details,
140 )
141
142 # Sort by episode number
143 results.sort(key=lambda x: x[0])
144
145 add_directory_items_batch(
146 [(url, list_item, False) for _, url, list_item in results if list_item is not None]
147 )
148
149
150def _process_episode(episode, tv_name, season, ids, mode, media_type, fanart_details):
151 ep_name = episode.name
152 episode_number = episode.episode_number
153
154 tv_data = {"name": ep_name, "episode": episode_number, "season": season}
155
156 list_item = make_list_item(label=f"{season}x{episode_number}. {ep_name}")
157 list_item.setProperty("IsPlayable", "true")
158 set_media_infoTag(
159 list_item, data=episode, fanart_data=fanart_details, mode="episode"
160 )
161
162 context_menu = add_tmdb_episode_context_menu(mode, tv_name, tv_data, ids)
163 if is_trakt_auth():
164 context_menu += add_trakt_watched_context_menu(
165 "shows", season=season, episode=episode_number, ids=ids
166 )
167 list_item.addContextMenuItems(context_menu)
168
169 url = build_url(
170 "search",
171 mode=mode,
172 media_type=media_type,
173 query=tv_name,
174 ids=ids,
175 tv_data=tv_data,
176 )
177
178 return (episode_number, url, list_item)
plugin.video.jacktook/lib/utils/views/weekly_calendar.py +252 -0
……@@ -0,0 +1,252 @@
1from datetime import datetime, date, timedelta
2from copy import copy
3import os
4import re
5
6from lib.clients.tmdb.utils.utils import tmdb_get
7from lib.db.pickle_db import PickleDatabase
8from lib.jacktook.utils import kodilog
9from lib.utils.kodi.utils import (
10 ADDON_HANDLE,
11 ADDON_PATH,
12 add_directory_items_batch,
13 apply_section_view,
14 build_url,
15 end_of_directory,
16 translation,
17 kodi_play_media,
18 make_list_item,
19)
20from lib.utils.general.utils import (
21 execute_thread_pool,
22 set_media_infoTag,
23 set_pluging_category,
24 translate_weekday,
25)
26from lib.utils.kodi.utils import get_setting
27
28
29
30
31def show_weekly_calendar(library=False):
32 set_pluging_category(translation(90021))
33
34 if library:
35 source_dict = PickleDatabase().get_key("jt:lib")
36 else:
37 source_dict = PickleDatabase().get_key("jt:lth")
38
39 tv_shows = [
40 (title, data) for title, data in source_dict.items() if data.get("mode") == "tv"
41 ]
42
43 results = []
44
45 # Broadcast Day Adjustment logic
46 # This is a manual simulation of broadcast times, not real timezone handling.
47 use_broadcast_adjustment = get_setting("use_broadcast_adjustment", False)
48 broadcast_offset = int(get_setting("broadcast_offset", 0))
49
50 def calculate_adjusted_broadcast(date_str, offset_hours):
51 """
52 Calculates an adjusted date and time based on a manual hour offset.
53 Assumes the original air time is midnight (00:00).
54 """
55 if not date_str:
56 return None, None
57
58 try:
59 # Parse date, assume midnight (00:00) for calculation
60 dt = datetime.strptime(date_str, "%Y-%m-%d")
61
62 if use_broadcast_adjustment:
63 # Clamp offset to -24 to +24 (defensive, though settings limit this)
64 offset = max(-24, min(24, offset_hours))
65 dt += timedelta(hours=offset)
66
67 return dt.strftime("%Y-%m-%d"), dt.strftime("%H:%M")
68 except Exception as e:
69 kodilog(f"Error calculating broadcast adjustment: {e}")
70 return date_str, "00:00"
71
72 def fetch_episodes_for_show(item):
73 title, data = item
74 episodes, details = get_episodes_for_show(data.get("ids"))
75 for ep in episodes:
76 # create a shallow copy to avoid mutating the cached data
77 ep_copy = copy(ep)
78
79 original_air_date = ep_copy.get("air_date")
80 adjusted_date, adjusted_time = calculate_adjusted_broadcast(
81 original_air_date, broadcast_offset
82 )
83
84 # Attach adjusted values for display
85 ep_copy["air_date"] = adjusted_date
86 ep_copy["air_time"] = adjusted_time
87
88 if adjusted_date and is_this_week(adjusted_date):
89 results.append((title, data, ep_copy, details))
90
91 # Use thread pool to fetch episodes in parallel
92 execute_thread_pool(tv_shows, fetch_episodes_for_show)
93
94 # Add fixed item showing current date at the top
95 current_date = datetime.now().strftime("%A, %d %B %Y")
96 directory_items = []
97
98 date_item = make_list_item(
99 label=f"[UPPERCASE][COLOR=orange]Today: {current_date}[/COLOR][/UPPERCASE]"
100 )
101 date_item.setArt(
102 {"icon": os.path.join(ADDON_PATH, "resources", "img", "history.png")}
103 )
104 directory_items.append(("", date_item, False))
105
106 today_str = datetime.now().strftime("%Y-%m-%d")
107
108 results_today = [r for r in results if r[2].get("air_date") == today_str]
109 results_other = [r for r in results if r[2].get("air_date") != today_str]
110
111 results = sorted(results_today, key=lambda x: x[2].get("air_date", "")) + sorted(
112 results_other, key=lambda x: x[2].get("air_date", "")
113 )
114
115 # Add items to Kodi UI
116 for (
117 title,
118 data,
119 ep,
120 details,
121 ) in results:
122 # Get episode name if available
123 ep_name = ep.get("name", "")
124
125 # Build complete IDs if possible from show details
126 ids = copy(data.get("ids", {}))
127 external_ids = getattr(details, "external_ids", {})
128 if external_ids:
129 if external_ids.get("imdb_id"):
130 ids["imdb_id"] = external_ids.get("imdb_id")
131 if external_ids.get("tvdb_id"):
132 ids["tvdb_id"] = external_ids.get("tvdb_id")
133
134 tv_data = {
135 "name": ep_name or title,
136 "episode": ep["number"],
137 "season": ep["season"],
138 }
139
140 # Get the day of the week from air_date
141 air_date_obj = parse_date_str(ep["air_date"])
142 weekday_name = air_date_obj.strftime("%A")
143 weekday_name_translated = translate_weekday(weekday_name)
144
145 # Mark if episode is released today
146 is_today = ep["air_date"] == today_str
147 mark = (
148 f"[UPPERCASE][COLOR=orange]TODAY- [/COLOR][/UPPERCASE]" if is_today else ""
149 )
150
151 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
153 list_item = make_list_item(label=ep_title)
154 list_item.setProperty("IsPlayable", "true")
155
156 set_media_infoTag(list_item, data=details, mode=data.get("mode"))
157
158 # Add context menu items
159 list_item.addContextMenuItems(
160 [
161 (
162 translation(90049),
163 kodi_play_media(
164 name="search",
165 mode=data.get("mode"),
166 query=title,
167 ids=ids,
168 tv_data=tv_data,
169 rescrape=True,
170 ),
171 ),
172 ]
173 )
174
175 directory_items.append(
176 (
177 build_url(
178 "search",
179 mode=data.get("mode"),
180 media_type=data.get("mode"),
181 query=title,
182 ids=ids,
183 tv_data=tv_data,
184 ),
185 list_item,
186 False,
187 )
188 )
189
190 add_directory_items_batch(directory_items)
191
192 end_of_directory(cache=False)
193 if library:
194 apply_section_view("view.library")
195 else:
196 apply_section_view("view.history")
197
198
199def get_episodes_for_show(ids):
200 tmdb_id = ids.get("tmdb_id")
201
202 try:
203 show_details = tmdb_get("tv_details", tmdb_id)
204 seasons = getattr(show_details, "seasons")
205 seasons = [s for s in seasons if s.get("season_number", 0) > 0]
206 if not seasons:
207 return [], show_details
208 latest_season = max(seasons, key=lambda s: s.get("season_number", 0))
209 season_number = latest_season.get("season_number")
210 season_details = tmdb_get(
211 "season_details", {"id": tmdb_id, "season": season_number}
212 )
213
214 episodes = []
215 for ep in getattr(season_details, "episodes"):
216 air_date = ep.get("air_date")
217 if air_date:
218 episodes.append(
219 {
220 "season": season_number,
221 "number": ep.get("episode_number"),
222 "air_date": air_date,
223 "name": ep.get("name"),
224 }
225 )
226 return episodes, show_details
227 except Exception as e:
228 kodilog(f"Error fetching episodes for TMDB ID {tmdb_id}: {e}")
229 return {}, []
230
231
232def is_this_week(date_str):
233 """Check if the date_str (YYYY-MM-DD) is in the current week."""
234 try:
235 d = parse_date_str(date_str)
236 today = date.today()
237 year, week, _ = today.isocalendar()
238 d_year, d_week, _ = d.isocalendar()
239 return (d_year, d_week) == (year, week)
240 except Exception as e:
241 kodilog(f"Error: {str(e)}")
242
243
244def parse_date_str(date_str: str) -> date:
245 """Parse a date string (YYYY-MM-DD or similar) into a date object without using datetime.strptime."""
246 date_str_cleaned = date_str.strip().split("T")[0]
247 match = re.search(r"(\d{4})[\-\/]?(\d{1,2})[\-\/]?(\d{1,2})", date_str_cleaned)
248 if match:
249 year, month, day = map(int, match.groups())
250 return date(year, month, day)
251 else:
252 raise ValueError(f"Invalid date format: {date_str}")
plugin.video.jacktook/lib/vendor/bencodepy/bencode/BTL.py +9 -0
……@@ -0,0 +1,9 @@
1"""bencode.py - `BTL` backwards compatibility support."""
2
3# Compatibility with previous versions:
4from bencode.exceptions import BencodeDecodeError as BTFailure # noqa: F401
5
6
7__all__ = (
8 'BTFailure'
9)
plugin.video.jacktook/lib/vendor/bencodepy/bencode/__init__.py +46 -0
……@@ -0,0 +1,46 @@
1# The contents of this file are subject to the BitTorrent Open Source License
2# Version 1.1 (the License). You may not copy or use this file, in either
3# source code or executable form, except in compliance with the License. You
4# may obtain a copy of the License at http://www.bittorrent.com/license/.
5#
6# Software distributed under the License is distributed on an AS IS basis,
7# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
8# for the specific language governing rights and limitations under the
9# License.
10
11# Written by Petru Paler
12
13"""bencode.py - bencode encoder + decoder."""
14
15from bencode.BTL import BTFailure
16from bencode.exceptions import BencodeDecodeError
17
18from bencodepy import Bencached, Bencode
19
20__all__ = (
21 'BTFailure',
22 'Bencached',
23 'BencodeDecodeError',
24 'bencode',
25 'bdecode',
26 'bread',
27 'bwrite',
28 'encode',
29 'decode'
30)
31
32
33DEFAULT = Bencode(
34 encoding='utf-8',
35 encoding_fallback='value',
36 dict_ordered=True,
37 dict_ordered_sort=True
38)
39
40bdecode = DEFAULT.decode
41bencode = DEFAULT.encode
42bread = DEFAULT.read
43bwrite = DEFAULT.write
44
45decode = bdecode
46encode = bencode
plugin.video.jacktook/lib/vendor/bencodepy/bencode/exceptions.py +6 -0
……@@ -0,0 +1,6 @@
1"""bencode.py - encoder + decode exceptions."""
2from bencodepy.exceptions import BencodeDecodeError
3
4__all__ = (
5 'BencodeDecodeError',
6)
plugin.video.jacktook/lib/vendor/bencodepy/bencodepy/__init__.py +185 -0
……@@ -0,0 +1,185 @@
1# The contents of this file are subject to the BitTorrent Open Source License
2# Version 1.1 (the License). You may not copy or use this file, in either
3# source code or executable form, except in compliance with the License. You
4# may obtain a copy of the License at http://www.bittorrent.com/license/.
5#
6# Software distributed under the License is distributed on an AS IS basis,
7# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
8# for the specific language governing rights and limitations under the
9# License.
10
11# Written by Petru Paler
12
13"""bencode.py - bencode encoder + decoder."""
14
15from .common import Bencached
16from .decoder import BencodeDecoder
17from .encoder import BencodeEncoder
18from .exceptions import BencodeDecodeError
19
20try:
21 from typing import Dict, List, Tuple, Deque, Union, TextIO, BinaryIO, Any
22except ImportError:
23 Dict = List = Tuple = Deque = Union = TextIO = BinaryIO = Any = None
24
25try:
26 from collections import OrderedDict
27except ImportError:
28 OrderedDict = None
29
30try:
31 import pathlib
32except ImportError:
33 pathlib = None
34
35__all__ = (
36 'Bencached',
37 'Bencode',
38 'BencodeDecoder',
39 'BencodeDecodeError',
40 'BencodeEncoder',
41 'bencode',
42 'bdecode',
43 'bread',
44 'bwrite',
45 'encode',
46 'decode'
47)
48
49
50class Bencode(object):
51 def __init__(self, encoding=None, encoding_fallback=None, dict_ordered=False, dict_ordered_sort=False):
52 self.decoder = BencodeDecoder(
53 encoding=encoding,
54 encoding_fallback=encoding_fallback,
55 dict_ordered=dict_ordered,
56 dict_ordered_sort=dict_ordered_sort
57 )
58
59 self.encoder = BencodeEncoder()
60
61 def decode(self, value):
62 # type: (bytes) -> Union[Tuple, List, OrderedDict, bool, int, str, bytes]
63 """
64 Decode bencode formatted byte string ``value``.
65
66 :param value: Bencode formatted string
67 :type value: bytes
68
69 :return: Decoded value
70 :rtype: object
71 """
72 return self.decoder.decode(value)
73
74 def encode(self, value):
75 # type: (Union[Tuple, List, OrderedDict, Dict, bool, int, str, bytes]) -> bytes
76 """
77 Encode ``value`` into the bencode format.
78
79 :param value: Value
80 :type value: object
81
82 :return: Bencode formatted string
83 :rtype: str
84 """
85 return self.encoder.encode(value)
86
87 def read(self,
88 fd # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO]
89 ):
90 # type: (...) -> Union[Tuple, List, OrderedDict, bool, int, str, bytes]
91 """Return bdecoded data from filename, file, or file-like object.
92
93 if fd is a bytes/string or pathlib.Path-like object, it is opened and
94 read, otherwise .read() is used. if read() not available, exception
95 raised.
96 """
97 if isinstance(fd, (bytes, str)):
98 with open(fd, 'rb') as fd:
99 return self.decode(fd.read())
100 elif pathlib is not None and isinstance(fd, (pathlib.Path, pathlib.PurePath)):
101 with open(str(fd), 'rb') as fd:
102 return self.decode(fd.read())
103 else:
104 return self.decode(fd.read())
105
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 ):
110 # type: (...) -> None
111 """Write data in bencoded form to filename, file, or file-like object.
112
113 if fd is bytes/string or pathlib.Path-like object, it is opened and
114 written to, otherwise .write() is used. if write() is not available,
115 exception raised.
116 """
117 if isinstance(fd, (bytes, str)):
118 with open(fd, 'wb') as fd:
119 fd.write(self.encode(data))
120 elif pathlib is not None and isinstance(fd, (pathlib.Path, pathlib.PurePath)):
121 with open(str(fd), 'wb') as fd:
122 fd.write(self.encode(data))
123 else:
124 fd.write(self.encode(data))
125
126
127DEFAULT = Bencode()
128
129
130def bencode(value):
131 # type: (Union[Tuple, List, OrderedDict, Dict, bool, int, str, bytes]) -> bytes
132 """
133 Encode ``value`` into the bencode format.
134
135 :param value: Value
136 :type value: object
137
138 :return: Bencode formatted string
139 :rtype: str
140 """
141 return DEFAULT.encode(value)
142
143
144def bdecode(value):
145 # type: (bytes) -> Union[Tuple, List, OrderedDict, bool, int, str, bytes]
146 """
147 Decode bencode formatted byte string ``value``.
148
149 :param value: Bencode formatted string
150 :type value: bytes
151
152 :return: Decoded value
153 :rtype: object
154 """
155 return DEFAULT.decode(value)
156
157
158def bread(fd # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO]
159 ):
160 # type: (...) -> Union[Tuple, List, OrderedDict, bool, int, str, bytes]
161 """Return bdecoded data from filename, file, or file-like object.
162
163 if fd is a bytes/string or pathlib.Path-like object, it is opened and
164 read, otherwise .read() is used. if read() not available, exception
165 raised.
166 """
167 return DEFAULT.read(fd)
168
169
170def bwrite(data, # type: Union[Tuple, List, OrderedDict, Dict, bool, int, str, bytes]
171 fd # type: Union[bytes, str, pathlib.Path, pathlib.PurePath, TextIO, BinaryIO]
172 ):
173 # type: (...) -> None
174 """Write data in bencoded form to filename, file, or file-like object.
175
176 if fd is bytes/string or pathlib.Path-like object, it is opened and
177 written to, otherwise .write() is used. if write() is not available,
178 exception raised.
179 """
180 return DEFAULT.write(data, fd)
181
182
183# Compatibility Proxies
184encode = bencode
185decode = bdecode
plugin.video.jacktook/lib/vendor/bencodepy/bencodepy/common.py +8 -0
……@@ -0,0 +1,8 @@
1"""bencode.py - common."""
2
3
4class Bencached(object):
5 __slots__ = ['bencoded']
6
7 def __init__(self, s):
8 self.bencoded = s
plugin.video.jacktook/lib/vendor/bencodepy/bencodepy/compat.py +30 -0
……@@ -0,0 +1,30 @@
1"""bencode.py - compatibility helpers."""
2
3import sys
4
5PY2 = sys.version_info[0] == 2
6PY3 = sys.version_info[0] == 3
7
8
9def is_binary(s):
10 if PY3:
11 return isinstance(s, bytes)
12
13 return isinstance(s, str)
14
15
16def is_text(s):
17 if PY3:
18 return isinstance(s, str)
19
20 return isinstance(s, unicode) # noqa: F821
21
22
23def to_binary(s):
24 if is_binary(s):
25 return s
26
27 if is_text(s):
28 return s.encode('utf-8', 'strict')
29
30 raise TypeError("expected binary or text (found %s)" % type(s))
plugin.video.jacktook/lib/vendor/bencodepy/bencodepy/decoder.py +157 -0
……@@ -0,0 +1,157 @@
1# The contents of this file are subject to the BitTorrent Open Source License
2# Version 1.1 (the License). You may not copy or use this file, in either
3# source code or executable form, except in compliance with the License. You
4# may obtain a copy of the License at http://www.bittorrent.com/license/.
5#
6# Software distributed under the License is distributed on an AS IS basis,
7# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
8# for the specific language governing rights and limitations under the
9# License.
10
11# Written by Petru Paler
12
13"""bencode.py - bencode decoder."""
14
15from .compat import to_binary
16from .exceptions import BencodeDecodeError
17from collections import OrderedDict
18
19try:
20 from typing import Dict, List, Tuple, Deque, Union, TextIO, BinaryIO, Any
21except ImportError:
22 Dict = List = Tuple = Deque = Union = TextIO = BinaryIO = Any = None
23
24try:
25 import pathlib
26except ImportError:
27 pathlib = None
28
29ENCODING_FALLBACK_TYPES = ('key', 'value')
30
31
32class BencodeDecoder(object):
33 def __init__(self, encoding=None, encoding_fallback=None, dict_ordered=False, dict_ordered_sort=False):
34 self.encoding = encoding
35 self.dict_ordered = dict_ordered
36 self.dict_ordered_sort = dict_ordered_sort
37
38 if dict_ordered_sort and not dict_ordered:
39 raise ValueError(
40 'Invalid value for "dict_ordered_sort" (requires "dict_ordered" to be enabled)'
41 )
42
43 # Parse encoding fallback
44 if encoding_fallback is not None and encoding_fallback not in ENCODING_FALLBACK_TYPES + ('all',):
45 raise ValueError(
46 'Invalid value for "encoding_fallback" (expected "all", "keys", "values" or None)'
47 )
48
49 if encoding_fallback == 'all':
50 self.encoding_fallback = ENCODING_FALLBACK_TYPES
51 elif encoding_fallback is not None:
52 self.encoding_fallback = (encoding_fallback,)
53 else:
54 self.encoding_fallback = tuple()
55
56 # noinspection PyDictCreation
57 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
71
72 def decode(self, value):
73 # type: (bytes) -> Union[Tuple, List, OrderedDict, bool, int, str, bytes]
74 """
75 Decode bencode formatted byte string ``value``.
76
77 :param value: Bencode formatted string
78 :type value: bytes
79
80 :return: Decoded value
81 :rtype: object
82 """
83 try:
84 value = to_binary(value)
85 data, length = self.decode_func[value[0:1]](value, 0)
86 except (IndexError, KeyError, TypeError, ValueError):
87 raise BencodeDecodeError("not a valid bencoded string")
88
89 if length != len(value):
90 raise BencodeDecodeError("invalid bencoded value (data after valid prefix)")
91
92 return data
93
94 def decode_int(self, x, f):
95 # type: (bytes, int) -> Tuple[int, int]
96 f += 1
97 newf = x.index(b'e', f)
98 n = int(x[f:newf])
99
100 if x[f:f + 1] == b'-':
101 if x[f + 1:f + 2] == b'0':
102 raise ValueError
103 elif x[f:f + 1] == b'0' and newf != f + 1:
104 raise ValueError
105
106 return n, newf + 1
107
108 def decode_string(self, x, f, kind='value'):
109 # type: (bytes, int) -> Tuple[bytes, int]
110 """Decode torrent bencoded 'string' in x starting at f."""
111 colon = x.index(b':', f)
112 n = int(x[f:colon])
113
114 if x[f:f + 1] == b'0' and colon != f + 1:
115 raise ValueError
116
117 colon += 1
118 s = x[colon:colon + n]
119
120 if self.encoding:
121 try:
122 return s.decode(self.encoding), colon + n
123 except UnicodeDecodeError:
124 if kind not in self.encoding_fallback:
125 raise
126
127 return bytes(s), colon + n
128
129 def decode_list(self, x, f):
130 # type: (bytes, int) -> Tuple[List, int]
131 r, f = [], f + 1
132
133 while x[f:f + 1] != b'e':
134 v, f = self.decode_func[x[f:f + 1]](x, f)
135 r.append(v)
136
137 return r, f + 1
138
139 def decode_dict(self, x, f):
140 # type: (bytes, int) -> Tuple[OrderedDict[str, Any], int]
141 """Decode bencoded dictionary."""
142
143 f += 1
144
145 if self.dict_ordered:
146 r = OrderedDict()
147 else:
148 r = {}
149
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)
153
154 if self.dict_ordered_sort:
155 r = OrderedDict(sorted(r.items()))
156
157 return r, f + 1
plugin.video.jacktook/lib/vendor/bencodepy/bencodepy/encoder.py +134 -0
……@@ -0,0 +1,134 @@
1# The contents of this file are subject to the BitTorrent Open Source License
2# Version 1.1 (the License). You may not copy or use this file, in either
3# source code or executable form, except in compliance with the License. You
4# may obtain a copy of the License at http://www.bittorrent.com/license/.
5#
6# Software distributed under the License is distributed on an AS IS basis,
7# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
8# for the specific language governing rights and limitations under the
9# License.
10
11# Written by Petru Paler
12
13"""bencode.py - bencode encoder."""
14
15from .common import Bencached
16from .compat import PY2, to_binary
17from collections import deque
18
19try:
20 from typing import Dict, List, Tuple, Deque, Union, TextIO, BinaryIO, Any
21except ImportError:
22 Dict = List = Tuple = Deque = Union = TextIO = BinaryIO = Any = None
23
24try:
25 from collections import OrderedDict
26except ImportError:
27 OrderedDict = None
28
29try:
30 import pathlib
31except ImportError:
32 pathlib = None
33
34
35class BencodeEncoder(object):
36 def __init__(self):
37 # noinspection PyDictCreation
38 self.encode_func = {}
39 self.encode_func[Bencached] = self.encode_bencached
40
41 if PY2:
42 from types import DictType, IntType, ListType, LongType, StringType, TupleType, UnicodeType
43
44 self.encode_func[DictType] = self.encode_dict
45 self.encode_func[IntType] = self.encode_int
46 self.encode_func[ListType] = self.encode_list
47 self.encode_func[LongType] = self.encode_int
48 self.encode_func[StringType] = self.encode_bytes
49 self.encode_func[TupleType] = self.encode_list
50 self.encode_func[UnicodeType] = self.encode_string
51
52 if OrderedDict is not None:
53 self.encode_func[OrderedDict] = self.encode_dict
54
55 try:
56 from types import BooleanType
57
58 self.encode_func[BooleanType] = self.encode_bool
59 except ImportError:
60 pass
61 else:
62 self.encode_func[OrderedDict] = self.encode_dict
63 self.encode_func[bool] = self.encode_bool
64 self.encode_func[dict] = self.encode_dict
65 self.encode_func[int] = self.encode_int
66 self.encode_func[list] = self.encode_list
67 self.encode_func[str] = self.encode_string
68 self.encode_func[tuple] = self.encode_list
69 self.encode_func[bytes] = self.encode_bytes
70
71 def encode(self, value):
72 # type: (Union[Tuple, List, OrderedDict, Dict, bool, int, str, bytes]) -> bytes
73 """
74 Encode ``value`` into the bencode format.
75
76 :param value: Value
77 :type value: object
78
79 :return: Bencode formatted string
80 :rtype: str
81 """
82 r = deque() # makes more sense for something with lots of appends
83
84 # Encode provided value
85 self.encode_func[type(value)](value, r)
86
87 # Join parts
88 return b''.join(r)
89
90 def encode_bencached(self, x, r):
91 # type: (Bencached, Deque[bytes]) -> None
92 r.append(x.bencoded)
93
94 def encode_int(self, x, r):
95 # type: (int, Deque[bytes]) -> None
96 r.extend((b'i', str(x).encode('utf-8'), b'e'))
97
98 def encode_bool(self, x, r):
99 # type: (bool, Deque[bytes]) -> None
100 if x:
101 self.encode_int(1, r)
102 else:
103 self.encode_int(0, r)
104
105 def encode_bytes(self, x, r):
106 # type: (bytes, Deque[bytes]) -> None
107 r.extend((str(len(x)).encode('utf-8'), b':', x))
108
109 def encode_string(self, x, r):
110 # type: (str, Deque[bytes]) -> None
111 return self.encode_bytes(x.encode("UTF-8"), r)
112
113 def encode_list(self, x, r):
114 # type: (List, Deque[bytes]) -> None
115 r.append(b'l')
116
117 for i in x:
118 self.encode_func[type(i)](i, r)
119
120 r.append(b'e')
121
122 def encode_dict(self, x, r):
123 # type: (Dict, Deque[bytes]) -> None
124 r.append(b'd')
125
126 # force all keys to bytes, because str and bytes are incomparable
127 ilist = [(to_binary(k), v) for k, v in x.items()]
128 ilist.sort(key=lambda kv: kv[0])
129
130 for k, v in ilist:
131 self.encode_func[type(k)](k, r)
132 self.encode_func[type(v)](v, r)
133
134 r.append(b'e')
plugin.video.jacktook/lib/vendor/bencodepy/bencodepy/exceptions.py +7 -0
……@@ -0,0 +1,7 @@
1"""bencode.py - exceptions."""
2
3
4class BencodeDecodeError(Exception):
5 """Bencode decode error."""
6
7 pass
plugin.video.jacktook/lib/vendor/segno/__init__.py +181 -0
……@@ -0,0 +1,181 @@
1#
2# Copyright (c) 2016 - 2024 -- Lars Heuer
3# All rights reserved.
4#
5# License: BSD License
6#
7"""\
8QR Code and Micro QR Code implementation.
9
10"QR Code" and "Micro QR Code" are registered trademarks of DENSO WAVE INCORPORATED.
11"""
12import sys
13import io
14from . import encoder
15from .encoder import DataOverflowError
16from . import writers, utils
17
18__version__ = '1.6.6'
19
20__all__ = ('make', 'make_qr', 'make_micro', 'make_sequence', 'QRCode',
21 'QRCodeSequence', 'DataOverflowError')
22
23
24def make(content, error=None, version=None, mode=None, mask=None, encoding=None,
25 eci=False, micro=None, boost_error=True):
26 return QRCode(encoder.encode(content, error, version, mode, mask, encoding,
27 eci, micro, boost_error=boost_error))
28
29
30
31class QRCode:
32 __slots__ = ('_error', '_matrix_size', '_mode', '_version', 'mask', 'matrix')
33
34 def __init__(self, code):
35 matrix = code.matrix
36 self.matrix = matrix
37 self.mask = code.mask
38 self._matrix_size = len(matrix[0]), len(matrix)
39 self._version = code.version
40 self._error = code.error
41 self._mode = code.segments[0].mode if len(code.segments) == 1 else None
42
43 @property
44 def version(self):
45 return encoder.get_version_name(self._version)
46
47 @property
48 def error(self):
49 if self._error is None:
50 return None
51 return encoder.get_error_name(self._error)
52
53 @property
54 def mode(self):
55 if self._mode is not None:
56 return encoder.get_mode_name(self._mode)
57 return None
58
59 @property
60 def designator(self):
61 version = str(self.version)
62 return '-'.join((version, self.error) if self.error else (version,))
63
64 @property
65 def default_border_size(self):
66 return utils.get_default_border_size(self._matrix_size)
67
68 @property
69 def is_micro(self):
70 return self._version < 1
71
72 def __eq__(self, other):
73 return self.__class__ == other.__class__ and self.matrix == other.matrix
74
75 __hash__ = None
76
77 def symbol_size(self, scale=1, border=None):
78 return utils.get_symbol_size(self._matrix_size, scale=scale, border=border)
79
80 def matrix_iter(self, scale=1, border=None, verbose=False):
81 iterfn = utils.matrix_iter_verbose if verbose else utils.matrix_iter
82 return iterfn(self.matrix, self._matrix_size, scale, border)
83
84 def show(self, delete_after=20, scale=10, border=None, dark='#000',
85 light='#fff'): # pragma: no cover
86 import os
87 import time
88 import tempfile
89 import webbrowser
90 import threading
91 from urllib.parse import urljoin
92 from urllib.request import pathname2url
93
94 def delete_file(name):
95 time.sleep(delete_after)
96 try:
97 os.unlink(name)
98 except OSError:
99 pass
100
101 f = tempfile.NamedTemporaryFile('wb', suffix='.png', delete=False)
102 try:
103 self.save(f, scale=scale, dark=dark, light=light, border=border)
104 except:
105 f.close()
106 os.unlink(f.name)
107 raise
108 f.close()
109 webbrowser.open_new_tab(urljoin('file:', pathname2url(f.name)))
110 if delete_after is not None:
111 t = threading.Thread(target=delete_file, args=(f.name,))
112 t.start()
113
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)
120
121 def svg_inline(self, **kw):
122 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'))
125
126 def png_data_uri(self, **kw):
127 return writers.as_png_data_uri(self.matrix, self._matrix_size, **kw)
128
129 def terminal(self, out=None, border=None, compact=False):
130 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
133 try:
134 writers.write_terminal_win(self.matrix, self._matrix_size, border)
135 except OSError:
136 writers.write_terminal(self.matrix, self._matrix_size, sys.stdout, border)
137 else:
138 writers.write_terminal(self.matrix, self._matrix_size, out or sys.stdout, border)
139
140 def save(self, out, kind=None, **kw):
141 writers.save(self.matrix, self._matrix_size, out, kind, **kw)
142
143 def __getattr__(self, name):
144 if name.startswith('to_'):
145 try:
146 import importlib_metadata as metadata
147 except ImportError:
148 from importlib import metadata
149 from functools import partial
150 for ep in metadata.entry_points(group='segno.plugin.converter',
151 name=name[3:]):
152 plugin = ep.load()
153 return partial(plugin, self)
154 raise AttributeError(f'{self.__class__} object has no attribute {name}')
155
156
157class QRCodeSequence(tuple):
158 __slots__ = ()
159
160 def __new__(cls, qrcodes):
161 return super().__new__(cls, qrcodes)
162
163 def terminal(self, out=None, border=None, compact=False):
164 for qrcode in self:
165 qrcode.terminal(out=out, border=border, compact=compact)
166
167 def save(self, out, kind=None, **kw):
168 filename = lambda o, n: o # noqa: E731
169 m = len(self)
170 if m > 1 and isinstance(out, str):
171 dot_idx = out.rfind('.')
172 if dot_idx > -1:
173 out = out[:dot_idx] + '-{0:02d}-{1:02d}' + out[dot_idx:]
174 filename = lambda o, n: o.format(m, n) # noqa: E731
175 for n, qrcode in enumerate(self, start=1):
176 qrcode.save(filename(out, n), kind=kind, **kw)
177
178 def __getattr__(self, item):
179 if len(self) == 1:
180 return getattr(self[0], item)
181 raise AttributeError(f"{self.__class__} object has no attribute '{item}'")
plugin.video.jacktook/lib/vendor/segno/consts.py +784 -0
……@@ -0,0 +1,784 @@
1#
2# Copyright (c) 2016 - 2024 -- Lars Heuer
3# All rights reserved.
4#
5# License: BSD License
6#
7"""\
8Constants.
9
10Internal module. May change without further warning.
11"""
12from collections import namedtuple
13
14ALPHANUMERIC_CHARS = br'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:'
15
16# ISO/IEC 18004:2015(E) -- Table 2 — Mode indicators for QR Code (page 23)
17MODE_NUMERIC = 0x1
18MODE_ALPHANUMERIC = 0x2
19MODE_STRUCTURED_APPEND = 0x3
20MODE_BYTE = 0x4
21MODE_ECI = 0x7
22MODE_KANJI = 0x8
23# Hanzi is not part of ISO/IEC 18004 and must be enabled by the user explicitly
24MODE_HANZI = 0xD
25
26# Micro QR Code uses different mode indicators
27# ISO/IEC 18004:2015(E) -- Table 2 — Mode indicators for QR Code (page 23)
28MODE_TO_MICRO_MODE_MAPPING = {
29 MODE_NUMERIC: 0x0,
30 MODE_ALPHANUMERIC: 0x1,
31 MODE_BYTE: 0x2,
32 MODE_KANJI: 0x3,
33}
34
35# Rectangular Micro QR Code mode indicators
36# ISO/IEC 23941:2022(E) -- Table 2 - Mode and Mode indicators for rMQR (page 17)
37MODE_TO_RECT_MICRO_MODE_MAPPING = {
38 MODE_NUMERIC: 0x1,
39 MODE_ALPHANUMERIC: 0x2,
40 MODE_BYTE: 0x3,
41 MODE_KANJI: 0x64
42}
43
44# Note: These versions must be comparable: Version 1 > M4 > M3 > M2 > M1
45VERSION_M4 = 0
46VERSION_M3 = -1
47VERSION_M2 = -2
48VERSION_M1 = -3
49
50MICRO_VERSION_MAPPING = {
51 'M1': VERSION_M1,
52 'M2': VERSION_M2,
53 'M3': VERSION_M3,
54 'M4': VERSION_M4,
55}
56
57MICRO_VERSIONS = tuple(sorted(MICRO_VERSION_MAPPING.values()))
58
59# ISO/IEC 18004:2015(E)
60# Table 12 — Error correction level indicators for QR Code symbols (page 55)
61ERROR_LEVEL_L = 1
62ERROR_LEVEL_M = 0
63ERROR_LEVEL_Q = 3
64ERROR_LEVEL_H = 2
65
66ERROR_LEVEL_TO_MICRO_MAPPING = {
67 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},
75}
76
77DEFAULT_BYTE_ENCODING = 'iso-8859-1'
78KANJI_ENCODING = 'shift_jis'
79HANZI_ENCODING = 'gb2312'
80
81MODE_MAPPING = {
82 'numeric': MODE_NUMERIC,
83 'alphanumeric': MODE_ALPHANUMERIC,
84 'byte': MODE_BYTE,
85 'kanji': MODE_KANJI,
86 'hanzi': MODE_HANZI,
87}
88
89ERROR_MAPPING = {
90 'L': ERROR_LEVEL_L,
91 'M': ERROR_LEVEL_M,
92 'Q': ERROR_LEVEL_Q,
93 'H': ERROR_LEVEL_H,
94}
95
96#
97# ISO/IEC 18004:2015(E) -- 7.3.2 Extended Channel Interpretation (ECI) mode (page 20)
98#
99# <https://strokescribe.com/en/ECI.html>
100# ECI Reference
101# ------ ---------
102# 000000 Represents the default encodation scheme
103# 000001 Represents the GLI encodation scheme of a number of symbologies
104# with characters 0 to 127 being identical to those of
105# ISO/IEC 646 : 1991 IRV (equivalent to ANSI X3.4) and characters
106# 128 to 255 being identical to those values of ISO 8859-1
107# 000002 An equivalent code table to ECI 000000, without the return-to-GLI 0
108# logic. It is the default encodation scheme for encoders fully
109# compliant with this standard.
110# 000003 ISO/IEC 8859-1 Latin alphabet No. 1
111# 000004 ISO/IEC 8859-2 Latin alphabet No. 2
112# 000005 ISO/IEC 8859-3 Latin alphabet No. 3
113# 000006 ISO/IEC 8859-4 Latin alphabet No. 4
114# 000007 ISO/IEC 8859-5 Latin/Cyrillic alphabet
115# 000008 ISO/IEC 8859-6 Latin/Arabic alphabet
116# 000009 ISO/IEC 8859-7 Latin/Greek alphabet
117# 000010 ISO/IEC 8859-8 Latin/Hebrew alphabet
118# 000011 ISO/IEC 8859-9 Latin alphabet No. 5
119# 000012 ISO/IEC 8859-10 Latin alphabet No. 6
120# 000013 ISO/IEC 8859-11 Latin/Thai alphabet
121# 000014 Reserved
122# 000015 ISO/IEC 8859-13 Latin alphabet No. 7 (Baltic Rim)
123# 000016 ISO/IEC 8859-14 Latin alphabet No. 8 (Celtic)
124# 000017 ISO/IEC 8859-15 Latin alphabet No. 9
125# 000018 ISO/IEC 8859-16 Latin alphabet No. 10
126# 000019 Reserved
127# 000020 Shift JIS (JIS X 0208 Annex 1 + JIS X 0201)
128# 000021 Windows 1250 Latin 2 (Central Europe)
129# 000022 Windows 1251 Cyrillic
130# 000023 Windows 1252 Latin 1
131# 000024 Windows 1256 Arabic
132# 000025 ISO/IEC 10646 UCS-2 (High order byte first)
133# 000026 ISO/IEC 10646 UTF-8 (See information above)
134# 000027 ISO/IEC 646:1991 International Reference Version of ISO 7-bit
135# coded character set
136# 000028 Big 5 (Taiwan) Chinese Character Set
137# 000029 GB (PRC) Chinese Character Set
138# 000030 Korean Character Set
139ECI_ASSIGNMENT_NUM = {
140 # 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,
168}
169
170
171# ISO/IEC 18004:2015(E) -- Table 2 — Mode indicators for QR Code (page 23)
172SUPPORTED_MODES = {
173 MODE_NUMERIC: (None, VERSION_M1, VERSION_M2, VERSION_M3, VERSION_M4),
174 MODE_ALPHANUMERIC: (None, VERSION_M2, VERSION_M3, VERSION_M4),
175 MODE_BYTE: (None, VERSION_M3, VERSION_M4),
176 MODE_ECI: (None,),
177 MODE_KANJI: (None, VERSION_M3, VERSION_M4),
178 MODE_HANZI: (None,),
179}
180
181# ISO/IEC 18004:2015(E) -- Table 2 — Mode indicators for QR Code (page 23)
182TERMINATOR_LENGTH = {
183 None: 4, # QR Codes, all versions
184 VERSION_M1: 3,
185 VERSION_M2: 5,
186 VERSION_M3: 7,
187 VERSION_M4: 9
188}
189
190VERSION_RANGE_01_09 = 1 # Version 1 .. 9
191VERSION_RANGE_10_26 = 2 # Version 10 .. 26
192VERSION_RANGE_27_40 = 3 # Version 27 .. 40
193
194# ISO/IEC 18004:2015(E)
195# Table 3 — Number of bits in character count indicator for QR Code (page 23)
196CHAR_COUNT_INDICATOR_LENGTH = {
197 MODE_NUMERIC: {
198 VERSION_RANGE_01_09: 10,
199 VERSION_RANGE_10_26: 12,
200 VERSION_RANGE_27_40: 14, VERSION_M1: 3, VERSION_M2: 4, VERSION_M3: 5, VERSION_M4: 6},
201 MODE_ALPHANUMERIC: {
202 VERSION_RANGE_01_09: 9,
203 VERSION_RANGE_10_26: 11,
204 VERSION_RANGE_27_40: 13, VERSION_M2: 3, VERSION_M3: 4, VERSION_M4: 5},
205 MODE_BYTE: {
206 VERSION_RANGE_01_09: 8,
207 VERSION_RANGE_10_26: 16,
208 VERSION_RANGE_27_40: 16, VERSION_M3: 4, VERSION_M4: 5},
209 MODE_KANJI: {
210 VERSION_RANGE_01_09: 8,
211 VERSION_RANGE_10_26: 10,
212 VERSION_RANGE_27_40: 12, VERSION_M3: 3, VERSION_M4: 4},
213 MODE_HANZI: {
214 VERSION_RANGE_01_09: 8,
215 VERSION_RANGE_10_26: 10,
216 VERSION_RANGE_27_40: 12},
217}
218
219
220# ISO/IEC 18004:2015(E) - 6.4.10 Bit stream to codeword conversion (page 33)
221# Table 7 — Number of symbol characters and input data capacity for QR Code
222SYMBOL_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}
271}
272
273
274# ISO/IEC 23941:2022(E) - 7.4.10 Bit stream to codeword conversion (page 25)
275# Table 6 — Number of symbol characters and input data capacity for rMQR
276RSYMBOL_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},
309}
310
311
312# ISO/IEC 18004:2015(E) -- Table 9 — Error correction characteristics for QR Code (page 38)
313# ISO/IEC 23941:2022(E) -- Table 8 — Error correction characteristics for rMQR (page 29)
314EC = namedtuple('EC', 'num_blocks num_total num_data')
315
316ECC = {
317 VERSION_M1: {None: (EC(1, 5, 3),)},
318 VERSION_M2: {ERROR_LEVEL_L: (EC(1, 10, 5),), ERROR_LEVEL_M: (EC(1, 10, 4),)},
319 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),)},
322 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),)},
325 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),)},
328 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),)},
331 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),)},
334 5: {
335 ERROR_LEVEL_L: (EC(1, 134, 108),), ERROR_LEVEL_M: (EC(2, 67, 43),),
336 ERROR_LEVEL_Q: (EC(2, 33, 15), EC(2, 34, 16)),
337 ERROR_LEVEL_H: (EC(2, 33, 11), EC(2, 34, 12))},
338 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),)},
341 7: {
342 ERROR_LEVEL_L: (EC(2, 98, 78),), ERROR_LEVEL_M: (EC(4, 49, 31),),
343 ERROR_LEVEL_Q: (EC(2, 32, 14), EC(4, 33, 15)),
344 ERROR_LEVEL_H: (EC(4, 39, 13), EC(1, 40, 14))},
345 8: {
346 ERROR_LEVEL_L: (EC(2, 121, 97),),
347 ERROR_LEVEL_M: (EC(2, 60, 38), EC(2, 61, 39)),
348 ERROR_LEVEL_Q: (EC(4, 40, 18), EC(2, 41, 19)),
349 ERROR_LEVEL_H: (EC(4, 40, 14), EC(2, 41, 15))},
350 9: {
351 ERROR_LEVEL_L: (EC(2, 146, 116),),
352 ERROR_LEVEL_M: (EC(3, 58, 36), EC(2, 59, 37)),
353 ERROR_LEVEL_Q: (EC(4, 36, 16), EC(4, 37, 17)),
354 ERROR_LEVEL_H: (EC(4, 36, 12), EC(4, 37, 13))},
355 10: {
356 ERROR_LEVEL_L: (EC(2, 86, 68), EC(2, 87, 69)),
357 ERROR_LEVEL_M: (EC(4, 69, 43), EC(1, 70, 44)),
358 ERROR_LEVEL_Q: (EC(6, 43, 19), EC(2, 44, 20)),
359 ERROR_LEVEL_H: (EC(6, 43, 15), EC(2, 44, 16))},
360 11: {
361 ERROR_LEVEL_L: (EC(4, 101, 81),),
362 ERROR_LEVEL_M: (EC(1, 80, 50), EC(4, 81, 51)),
363 ERROR_LEVEL_Q: (EC(4, 50, 22), EC(4, 51, 23)),
364 ERROR_LEVEL_H: (EC(3, 36, 12), EC(8, 37, 13))},
365 12: {
366 ERROR_LEVEL_L: (EC(2, 116, 92), EC(2, 117, 93)),
367 ERROR_LEVEL_M: (EC(6, 58, 36), EC(2, 59, 37)),
368 ERROR_LEVEL_Q: (EC(4, 46, 20), EC(6, 47, 21)),
369 ERROR_LEVEL_H: (EC(7, 42, 14), EC(4, 43, 15))},
370 13: {
371 ERROR_LEVEL_L: (EC(4, 133, 107),),
372 ERROR_LEVEL_M: (EC(8, 59, 37), EC(1, 60, 38)),
373 ERROR_LEVEL_Q: (EC(8, 44, 20), EC(4, 45, 21)),
374 ERROR_LEVEL_H: (EC(12, 33, 11), EC(4, 34, 12))},
375 14: {
376 ERROR_LEVEL_L: (EC(3, 145, 115), EC(1, 146, 116)),
377 ERROR_LEVEL_M: (EC(4, 64, 40), EC(5, 65, 41)),
378 ERROR_LEVEL_Q: (EC(11, 36, 16), EC(5, 37, 17)),
379 ERROR_LEVEL_H: (EC(11, 36, 12), EC(5, 37, 13))},
380 15: {
381 ERROR_LEVEL_L: (EC(5, 109, 87), EC(1, 110, 88)),
382 ERROR_LEVEL_M: (EC(5, 65, 41), EC(5, 66, 42)),
383 ERROR_LEVEL_Q: (EC(5, 54, 24), EC(7, 55, 25)),
384 ERROR_LEVEL_H: (EC(11, 36, 12), EC(7, 37, 13))},
385 16: {
386 ERROR_LEVEL_L: (EC(5, 122, 98), EC(1, 123, 99)),
387 ERROR_LEVEL_M: (EC(7, 73, 45), EC(3, 74, 46)),
388 ERROR_LEVEL_Q: (EC(15, 43, 19), EC(2, 44, 20)),
389 ERROR_LEVEL_H: (EC(3, 45, 15), EC(13, 46, 16))},
390 17: {
391 ERROR_LEVEL_L: (EC(1, 135, 107), EC(5, 136, 108)),
392 ERROR_LEVEL_M: (EC(10, 74, 46), EC(1, 75, 47)),
393 ERROR_LEVEL_Q: (EC(1, 50, 22), EC(15, 51, 23)),
394 ERROR_LEVEL_H: (EC(2, 42, 14), EC(17, 43, 15))},
395 18: {
396 ERROR_LEVEL_L: (EC(5, 150, 120), EC(1, 151, 121)),
397 ERROR_LEVEL_M: (EC(9, 69, 43), EC(4, 70, 44)),
398 ERROR_LEVEL_Q: (EC(17, 50, 22), EC(1, 51, 23)),
399 ERROR_LEVEL_H: (EC(2, 42, 14), EC(19, 43, 15))},
400 19: {
401 ERROR_LEVEL_L: (EC(3, 141, 113), EC(4, 142, 114)),
402 ERROR_LEVEL_M: (EC(3, 70, 44), EC(11, 71, 45)),
403 ERROR_LEVEL_Q: (EC(17, 47, 21), EC(4, 48, 22)),
404 ERROR_LEVEL_H: (EC(9, 39, 13), EC(16, 40, 14))},
405 20: {
406 ERROR_LEVEL_L: (EC(3, 135, 107), EC(5, 136, 108)),
407 ERROR_LEVEL_M: (EC(3, 67, 41), EC(13, 68, 42)),
408 ERROR_LEVEL_Q: (EC(15, 54, 24), EC(5, 55, 25)),
409 ERROR_LEVEL_H: (EC(15, 43, 15), EC(10, 44, 16))},
410 21: {
411 ERROR_LEVEL_L: (EC(4, 144, 116), EC(4, 145, 117)),
412 ERROR_LEVEL_M: (EC(17, 68, 42),),
413 ERROR_LEVEL_Q: (EC(17, 50, 22), EC(6, 51, 23)),
414 ERROR_LEVEL_H: (EC(19, 46, 16), EC(6, 47, 17))},
415 22: {
416 ERROR_LEVEL_L: (EC(2, 139, 111), EC(7, 140, 112)),
417 ERROR_LEVEL_M: (EC(17, 74, 46),),
418 ERROR_LEVEL_Q: (EC(7, 54, 24), EC(16, 55, 25)),
419 ERROR_LEVEL_H: (EC(34, 37, 13),)},
420 23: {
421 ERROR_LEVEL_L: (EC(4, 151, 121), EC(5, 152, 122)),
422 ERROR_LEVEL_M: (EC(4, 75, 47), EC(14, 76, 48)),
423 ERROR_LEVEL_Q: (EC(11, 54, 24), EC(14, 55, 25)),
424 ERROR_LEVEL_H: (EC(16, 45, 15), EC(14, 46, 16))},
425 24: {
426 ERROR_LEVEL_L: (EC(6, 147, 117), EC(4, 148, 118)),
427 ERROR_LEVEL_M: (EC(6, 73, 45), EC(14, 74, 46)),
428 ERROR_LEVEL_Q: (EC(11, 54, 24), EC(16, 55, 25)),
429 ERROR_LEVEL_H: (EC(30, 46, 16), EC(2, 47, 17))},
430 25: {
431 ERROR_LEVEL_L: (EC(8, 132, 106), EC(4, 133, 107)),
432 ERROR_LEVEL_M: (EC(8, 75, 47), EC(13, 76, 48)),
433 ERROR_LEVEL_Q: (EC(7, 54, 24), EC(22, 55, 25)),
434 ERROR_LEVEL_H: (EC(22, 45, 15), EC(13, 46, 16))},
435 26: {
436 ERROR_LEVEL_L: (EC(10, 142, 114), EC(2, 143, 115)),
437 ERROR_LEVEL_M: (EC(19, 74, 46), EC(4, 75, 47)),
438 ERROR_LEVEL_Q: (EC(28, 50, 22), EC(6, 51, 23)),
439 ERROR_LEVEL_H: (EC(33, 46, 16), EC(4, 47, 17))},
440 27: {
441 ERROR_LEVEL_L: (EC(8, 152, 122), EC(4, 153, 123)),
442 ERROR_LEVEL_M: (EC(22, 73, 45), EC(3, 74, 46)),
443 ERROR_LEVEL_Q: (EC(8, 53, 23), EC(26, 54, 24)),
444 ERROR_LEVEL_H: (EC(12, 45, 15), EC(28, 46, 16))},
445 28: {
446 ERROR_LEVEL_L: (EC(3, 147, 117), EC(10, 148, 118)),
447 ERROR_LEVEL_M: (EC(3, 73, 45), EC(23, 74, 46)),
448 ERROR_LEVEL_Q: (EC(4, 54, 24), EC(31, 55, 25)),
449 ERROR_LEVEL_H: (EC(11, 45, 15), EC(31, 46, 16))},
450 29: {
451 ERROR_LEVEL_L: (EC(7, 146, 116), EC(7, 147, 117)),
452 ERROR_LEVEL_M: (EC(21, 73, 45), EC(7, 74, 46)),
453 ERROR_LEVEL_Q: (EC(1, 53, 23), EC(37, 54, 24)),
454 ERROR_LEVEL_H: (EC(19, 45, 15), EC(26, 46, 16))},
455 30: {
456 ERROR_LEVEL_L: (EC(5, 145, 115), EC(10, 146, 116)),
457 ERROR_LEVEL_M: (EC(19, 75, 47), EC(10, 76, 48)),
458 ERROR_LEVEL_Q: (EC(15, 54, 24), EC(25, 55, 25)),
459 ERROR_LEVEL_H: (EC(23, 45, 15), EC(25, 46, 16))},
460 31: {
461 ERROR_LEVEL_L: (EC(13, 145, 115), EC(3, 146, 116)),
462 ERROR_LEVEL_M: (EC(2, 74, 46), EC(29, 75, 47)),
463 ERROR_LEVEL_Q: (EC(42, 54, 24), EC(1, 55, 25)),
464 ERROR_LEVEL_H: (EC(23, 45, 15), EC(28, 46, 16))},
465 32: {
466 ERROR_LEVEL_L: (EC(17, 145, 115),),
467 ERROR_LEVEL_M: (EC(10, 74, 46), EC(23, 75, 47)),
468 ERROR_LEVEL_Q: (EC(10, 54, 24), EC(35, 55, 25)),
469 ERROR_LEVEL_H: (EC(19, 45, 15), EC(35, 46, 16))},
470 33: {
471 ERROR_LEVEL_L: (EC(17, 145, 115), EC(1, 146, 116)),
472 ERROR_LEVEL_M: (EC(14, 74, 46), EC(21, 75, 47)),
473 ERROR_LEVEL_Q: (EC(29, 54, 24), EC(19, 55, 25)),
474 ERROR_LEVEL_H: (EC(11, 45, 15), EC(46, 46, 16))},
475 34: {
476 ERROR_LEVEL_L: (EC(13, 145, 115), EC(6, 146, 116)),
477 ERROR_LEVEL_M: (EC(14, 74, 46), EC(23, 75, 47)),
478 ERROR_LEVEL_Q: (EC(44, 54, 24), EC(7, 55, 25)),
479 ERROR_LEVEL_H: (EC(59, 46, 16), EC(1, 47, 17))},
480 35: {
481 ERROR_LEVEL_L: (EC(12, 151, 121), EC(7, 152, 122)),
482 ERROR_LEVEL_M: (EC(12, 75, 47), EC(26, 76, 48)),
483 ERROR_LEVEL_Q: (EC(39, 54, 24), EC(14, 55, 25)),
484 ERROR_LEVEL_H: (EC(22, 45, 15), EC(41, 46, 16))},
485 36: {
486 ERROR_LEVEL_L: (EC(6, 151, 121), EC(14, 152, 122)),
487 ERROR_LEVEL_M: (EC(6, 75, 47), EC(34, 76, 48)),
488 ERROR_LEVEL_Q: (EC(46, 54, 24), EC(10, 55, 25)),
489 ERROR_LEVEL_H: (EC(2, 45, 15), EC(64, 46, 16))},
490 37: {
491 ERROR_LEVEL_L: (EC(17, 152, 122), EC(4, 153, 123)),
492 ERROR_LEVEL_M: (EC(29, 74, 46), EC(14, 75, 47)),
493 ERROR_LEVEL_Q: (EC(49, 54, 24), EC(10, 55, 25)),
494 ERROR_LEVEL_H: (EC(24, 45, 15), EC(46, 46, 16))},
495 38: {
496 ERROR_LEVEL_L: (EC(4, 152, 122), EC(18, 153, 123)),
497 ERROR_LEVEL_M: (EC(13, 74, 46), EC(32, 75, 47)),
498 ERROR_LEVEL_Q: (EC(48, 54, 24), EC(14, 55, 25)),
499 ERROR_LEVEL_H: (EC(42, 45, 15), EC(32, 46, 16))},
500 39: {
501 ERROR_LEVEL_L: (EC(20, 147, 117), EC(4, 148, 118)),
502 ERROR_LEVEL_M: (EC(40, 75, 47), EC(7, 76, 48)),
503 ERROR_LEVEL_Q: (EC(43, 54, 24), EC(22, 55, 25)),
504 ERROR_LEVEL_H: (EC(10, 45, 15), EC(67, 46, 16))},
505 40: {
506 ERROR_LEVEL_L: (EC(19, 148, 118), EC(6, 149, 119)),
507 ERROR_LEVEL_M: (EC(18, 75, 47), EC(31, 76, 48)),
508 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},
542}
543
544
545# ISO/IEC 18004:2015 -- Annex C - D.1 Error correction bit calculation
546# Table C.1 — Valid format information bit sequences (page 80)
547FORMAT_INFO = (
548 # M: mask 0, mask 1 .. 7
549 0x5412, 0x5125, 0x5e7c, 0x5b4b, 0x45f9, 0x40ce, 0x4f97, 0x4aa0,
550 # L
551 0x77c4, 0x72f3, 0x7daa, 0x789d, 0x662f, 0x6318, 0x6c41, 0x6976,
552 # H
553 0x1689, 0x13be, 0x1ce7, 0x19d0, 0x0762, 0x0255, 0x0d0c, 0x083b,
554 # Q
555 0x355f, 0x3068, 0x3f31, 0x3a06, 0x24b4, 0x2183, 0x2eda, 0x2bed,
556)
557
558FORMAT_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,
563)
564
565FORMAT_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
573)
574
575FORMAT_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
583)
584
585
586# ISO/IEC 18004:2015 -- Annex D - D.1 Error correction bit calculation
587# Table D.1 — Version information bit stream for each version (page 82)
588VERSION_INFO = (
589 # 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,
595)
596
597
598# ISO/IEC 18004:2015 -- Annex E - Position of alignment patterns
599# Table E.1 — Row/column coordinates of center module of alignment patterns (page 83)
600ALIGNMENT_POS = (
601 (6, 18), # Version 2 (version 1 has no additional alignment patterns)
602 (6, 22), # Version 3
603 (6, 26), # ..
604 (6, 30),
605 (6, 34),
606 (6, 22, 38), # Version 7
607 (6, 24, 42),
608 (6, 26, 46),
609 (6, 28, 50),
610 (6, 30, 54),
611 (6, 32, 58),
612 (6, 34, 62),
613 (6, 26, 46, 66), # Version 14
614 (6, 26, 48, 70),
615 (6, 26, 50, 74),
616 (6, 30, 54, 78),
617 (6, 30, 56, 82),
618 (6, 30, 58, 86),
619 (6, 34, 62, 90),
620 (6, 28, 50, 72, 94), # Version 21
621 (6, 26, 50, 74, 98),
622 (6, 30, 54, 78, 102),
623 (6, 28, 54, 80, 106),
624 (6, 32, 58, 84, 110),
625 (6, 30, 58, 86, 114),
626 (6, 34, 62, 90, 118),
627 (6, 26, 50, 74, 98, 122), # Version 28
628 (6, 30, 54, 78, 102, 126),
629 (6, 26, 52, 78, 104, 130),
630 (6, 30, 56, 82, 108, 134),
631 (6, 34, 60, 86, 112, 138),
632 (6, 30, 58, 86, 114, 142),
633 (6, 34, 62, 90, 118, 146),
634 (6, 30, 54, 78, 102, 126, 150), # Version 35
635 (6, 24, 50, 76, 102, 128, 154),
636 (6, 28, 54, 80, 106, 132, 158),
637 (6, 32, 58, 84, 110, 136, 162),
638 (6, 26, 54, 82, 110, 138, 166),
639 (6, 30, 58, 86, 114, 142, 170), # Version 40
640)
641
642# ISO/IEC 23941:2022(E) -- Annex D - Position of alignment patterns
643# Table D.1 — Column coordinates of centre module of alignment patterns (page 61)
644RECT_MICRO_ALIGNMENT_POS = {
645 43: (21,),
646 59: (19, 39),
647 77: (25, 51),
648 99: (23, 49, 75),
649 139: (27, 55, 83, 111),
650}
651
652
653# ISO/IEC 18004:2015 -- Annex A - Error detection and correction generator polynomials
654# Table A.1 — Generator polynomials for Reed-Solomon error correction codewords (page 73)
655GEN_POLY = {
656 2: (25, 1),
657 5: (113, 164, 166, 119, 10),
658 6: (166, 0, 134, 5, 176, 15),
659 7: (87, 229, 146, 149, 238, 102, 21),
660 8: (175, 238, 208, 249, 215, 252, 196, 28),
661 10: (251, 67, 46, 61, 118, 70, 64, 94, 32, 45),
662 13: (74, 152, 176, 100, 86, 100, 106, 104, 130, 218, 206, 140, 78),
663 14: (199, 249, 155, 48, 190, 124, 218, 137, 216, 87, 207, 59, 22, 91),
664 15: (8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105),
665 16: (120, 104, 107, 109, 102, 161, 76, 3, 91, 191, 147, 169, 182, 194, 225, 120),
666 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
674}
675
676# Precomputed Galios Log tables
677#
678# prime polynomial: 0x11d (285) / generator: 2
679
680# GF(256) log
681GALIOS_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
698)
699
700# GF(256) antilog
701# Inverse of the logarithm table. Maps integer logarithms to members
702# of the field.
703GALIOS_EXP = ([
704 1, 2, 4, 8, 16, 32, 64, 128, 29, 58, 116, 232, 205, 135, 19, 38, 76, 152,
705 45, 90, 180, 117, 234, 201, 143, 3, 6, 12, 24, 48, 96, 192, 157, 39, 78,
706 156, 37, 74, 148, 53, 106, 212, 181, 119, 238, 193, 159, 35, 70, 140, 5,
707 10, 20, 40, 80, 160, 93, 186, 105, 210, 185, 111, 222, 161, 95, 190, 97,
708 194, 153, 47, 94, 188, 101, 202, 137, 15, 30, 60, 120, 240, 253, 231, 211,
709 187, 107, 214, 177, 127, 254, 225, 223, 163, 91, 182, 113, 226, 217, 175,
710 67, 134, 17, 34, 68, 136, 13, 26, 52, 104, 208, 189, 103, 206, 129, 31,
711 62, 124, 248, 237, 199, 147, 59, 118, 236, 197, 151, 51, 102, 204, 133, 23,
712 46, 92, 184, 109, 218, 169, 79, 158, 33, 66, 132, 21, 42, 84, 168, 77, 154,
713 41, 82, 164, 85, 170, 73, 146, 57, 114, 228, 213, 183, 115, 230, 209, 191,
714 99, 198, 145, 63, 126, 252, 229, 215, 179, 123, 246, 241, 255, 227, 219,
715 171, 75, 150, 49, 98, 196, 149, 55, 110, 220, 165, 87, 174, 65, 130, 25,
716 50, 100, 200, 141, 7, 14, 28, 56, 112, 224, 221, 167, 83, 166, 81, 162, 89,
717 178, 121, 242, 249, 239, 195, 155, 43, 86, 172, 69, 138, 9, 18, 36, 72,
718 144, 61, 122, 244, 245, 247, 243, 251, 235, 203, 139, 11, 22, 44, 88, 176,
719 125, 250, 233, 207, 131, 27, 54, 108, 216, 173, 71, 142] * 2
720)
721
722
723# Constants for module types
724
725TYPE_FINDER_PATTERN_LIGHT = 6
726"""\
727Light finder module
728"""
729TYPE_FINDER_PATTERN_DARK = TYPE_FINDER_PATTERN_LIGHT << 8
730"""\
731Dark finder module.
732"""
733TYPE_SEPARATOR = 8
734"""\
735Separator around the finder patterns (light module)
736"""
737TYPE_ALIGNMENT_PATTERN_LIGHT = 10
738"""\
739Light alignment pattern module.
740"""
741TYPE_ALIGNMENT_PATTERN_DARK = TYPE_ALIGNMENT_PATTERN_LIGHT << 8
742"""\
743Dark alignment pattern module.
744"""
745TYPE_TIMING_LIGHT = 12
746"""\
747Light timing pattern module.
748"""
749TYPE_TIMING_DARK = TYPE_TIMING_LIGHT << 8
750"""\
751Dark timing patten module.
752"""
753TYPE_FORMAT_LIGHT = 14
754"""\
755Light format information module.
756"""
757TYPE_FORMAT_DARK = TYPE_FORMAT_LIGHT << 8
758"""\
759Dark format information module.
760"""
761TYPE_VERSION_LIGHT = 16
762"""\
763Light version information module.
764"""
765TYPE_VERSION_DARK = TYPE_VERSION_LIGHT << 8
766"""\
767Dark version information module.
768"""
769TYPE_DARKMODULE = 512
770"""\
771A single dark module which occurs in QR Codes (but not in Micro QR Codes).
772"""
773TYPE_DATA_LIGHT = 4
774"""\
775Light module in the encoding area (either a data module or an error correction module).
776"""
777TYPE_DATA_DARK = TYPE_DATA_LIGHT << 8
778"""\
779Dark module in the encoding area (either a data module or an error correction module).
780"""
781TYPE_QUIET_ZONE = 18
782"""\
783Border of light modules.
784"""
plugin.video.jacktook/lib/vendor/segno/encoder.py +1091 -0
……@@ -0,0 +1,1091 @@
1#
2# Copyright (c) 2016 - 2024 -- Lars Heuer
3# All rights reserved.
4#
5# License: BSD License
6#
7"""\
8QR Code and Micro QR Code encoder.
9
10DOES NOT belong to the public API.
11
12"QR Code" and "Micro QR Code" are registered trademarks of DENSO WAVE INCORPORATED.
13"""
14from operator import itemgetter, gt, lt, xor
15from functools import partial, reduce
16from itertools import islice, chain, product
17import re
18import math
19import codecs
20from collections import namedtuple
21from . import consts
22from itertools import zip_longest
23import sys
24_MAX_PENALTY_SCORE = sys.maxsize
25del sys
26
27__all__ = ('encode', 'encode_sequence', 'DataOverflowError')
28
29
30class DataOverflowError(ValueError):
31 """\
32 Indicates a problem that the provided data does not fit into the
33 provided QR Code version or the data is too large in general.
34
35 This exception is inherited from :py:exc:`ValueError` and is only raised
36 if the data does not fit into the provided (Micro) QR Code version.
37
38 Basically it is sufficient to catch a :py:exc:`ValueError`.
39 """
40
41
42Code = namedtuple('Code', 'matrix version error mask segments')
43
44
45def encode(content, error=None, version=None, mode=None, mask=None,
46 encoding=None, eci=False, micro=None, boost_error=True):
47 version = normalize_version(version)
48 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')
51 if micro and version is not None and version not in consts.MICRO_VERSIONS:
52 raise ValueError(f'Illegal Micro QR Code version "{get_version_name(version)}"')
53 error = normalize_errorlevel(error, accept_none=True)
54 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)}"')
58 if error == consts.ERROR_LEVEL_H and (micro or version in consts.MICRO_VERSIONS):
59 raise ValueError('Error correction level "H" is not available for Micro QR Codes')
60 if eci and (micro or version in consts.MICRO_VERSIONS):
61 raise ValueError('The ECI mode is not available for Micro QR Codes')
62 segments = prepare_data(content, mode, encoding)
63 guessed_version = find_version(segments, error, eci=eci, micro=micro)
64 if version is None:
65 version = guessed_version
66 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)}')
69 if error is None and version != consts.VERSION_M1:
70 error = consts.ERROR_LEVEL_L
71 is_micro = version < 1
72 mask = normalize_mask(mask, is_micro)
73 return _encode(segments, error, version, mask, eci, boost_error)
74
75
76def encode_sequence(content, error=None, version=None, mode=None, mask=None,
77 encoding=None, eci=False, boost_error=True, symbol_count=None):
78 def one_item_segments(chunk, mode):
79 segs = Segments()
80 segs.add_segment(make_segment(chunk, mode=mode, encoding=encoding))
81 return segs
82
83 def divide_into_chunks(data, num):
84 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)]
86
87 def calc_qrcode_bit_length(char_count, ver_range, mode, encoding=None,
88 is_eci=False, is_sa=False):
89 overhead = 4 # Mode indicator for QR Codes, only
90 # Number of bits in character count indicator
91 overhead += consts.CHAR_COUNT_INDICATOR_LENGTH[mode][ver_range]
92 if is_eci and mode == consts.MODE_BYTE and encoding != consts.DEFAULT_BYTE_ENCODING:
93 overhead += 4 # ECI indicator
94 overhead += 8 # ECI assignment no
95 if is_sa:
96 # 4 bit for mode, 4 bit for the position, 4 bit for total number of symbols
97 # 8 bit for parity data
98 overhead += 5 * 4
99 bits = 0
100 if mode == consts.MODE_NUMERIC:
101 num, remainder = divmod(char_count, 3)
102 bits += num * 10 + (4 if remainder == 1 else 7)
103 elif mode == consts.MODE_ALPHANUMERIC:
104 num, remainder = divmod(char_count, 2)
105 bits += num * 11 + (6 if remainder else 0)
106 elif mode == consts.MODE_BYTE:
107 bits += char_count * 8
108 elif mode in (consts.MODE_KANJI, consts.MODE_HANZI):
109 bits += char_count * 13
110 return overhead + bits
111
112 def number_of_symbols_by_version(content, version, error, mode):
113 length = len(content)
114 ver_range = version_range(version)
115 bit_length = calc_qrcode_bit_length(length, ver_range, mode, encoding,
116 is_eci=eci, is_sa=True)
117 capacity = consts.SYMBOL_CAPACITY[version][error]
118 # Initial result does not contain the overhead of SA mode for all QR Codes
119 cnt = int(math.ceil(bit_length / capacity))
120 # Overhead of SA mode for all QR Codes
121 bit_length += 5 * 4 * (cnt - 1) + (12 * (cnt - 1) if eci else 0)
122 return int(math.ceil(bit_length / capacity))
123
124 version = normalize_version(version)
125 if version is not None:
126 if version < 1:
127 raise ValueError('This function does not accept Micro QR Code versions. '
128 f'Provided: "{get_version_name(version)}"')
129 elif symbol_count is None:
130 raise ValueError('Please provide either a QR Code version or the symbol count')
131 if symbol_count is not None and not 1 <= symbol_count <= 16:
132 raise ValueError('The symbol count must be in range 1 .. 16')
133 error = normalize_errorlevel(error, accept_none=True)
134 if error is None:
135 error = consts.ERROR_LEVEL_L
136 mode = normalize_mode(mode)
137 mask = normalize_mask(mask, is_micro=False)
138 segments = prepare_data(content, mode, encoding)
139 guessed_version = None
140 if symbol_count is None:
141 try:
142 # Try to find a version which fits without using Structured Append
143 guessed_version = find_version(segments, error, eci=eci, micro=False)
144 except DataOverflowError:
145 # Data does fit into a usual QR Code but ignore the error silently,
146 # guessed_version is None
147 pass
148 if guessed_version and guessed_version <= (version or guessed_version):
149 # 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)]
152 if len(segments.modes) > 1:
153 raise ValueError('This function cannot handle more than one mode (yet). Sorry.')
154 mode = segments.modes[0] # CHANGE iff more than one mode is supported!
155 # Creating one QR code failed or max_no is not None
156 if mode == consts.MODE_NUMERIC:
157 content = str(content)
158 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')
160 sa_parity_data = calc_structured_append_parity(content)
161 num_symbols = symbol_count or 16
162 if version is not None:
163 num_symbols = number_of_symbols_by_version(content, version, error, mode)
164 if num_symbols > 16:
165 raise DataOverflowError(f'The data does not fit into Structured Append version {version}')
166 chunks = divide_into_chunks(content, num_symbols)
167 if symbol_count is not None:
168 segments = one_item_segments(max(chunks, key=len), mode)
169 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)]
175
176
177def _encode(segments, error, version, mask, eci, boost_error, sa_info=None):
178 is_micro = version < 1
179 sa_mode = sa_info is not None
180 buff = Buffer()
181 ver = version
182 ver_range = version
183 if not is_micro:
184 ver = None
185 ver_range = version_range(version)
186 if boost_error:
187 error = boost_error_level(version, error, segments, eci, is_sa=sa_mode)
188 if sa_mode:
189 # ISO/IEC 18004:2015(E) -- 8 Structured Append (page 59)
190 for i in sa_info[:3]:
191 buff.append_bits(i, 4)
192 buff.append_bits(sa_info.parity, 8)
193 # ISO/IEC 18004:2015(E) -- 7.4 Data encoding (page 22)
194 for segment in segments:
195 write_segment(buff, segment, ver, ver_range, eci)
196 capacity = consts.SYMBOL_CAPACITY[version][error]
197 # ISO/IEC 18004:2015(E) -- 7.4.9 Terminator (page 32)
198 write_terminator(buff, capacity, ver, len(buff))
199 # ISO/IEC 18004:2015(E) -- 7.4.10 Bit stream to codeword conversion (page 34)
200 write_padding_bits(buff, version, len(buff))
201 # ISO/IEC 18004:2015(E) -- 7.4.10 Bit stream to codeword conversion (page 34)
202 write_pad_codewords(buff, version, capacity, len(buff))
203 # ISO/IEC 18004:2015(E) -- 7.6 Constructing the final message codeword sequence (page 45)
204 buff = make_final_message(version, error, buff)
205 # Matrix with timing pattern and reserved format / version regions
206 width = calc_matrix_size(version)
207 height = width
208 matrix = make_matrix(width, height)
209 # ISO/IEC 18004:2015 -- 6.3.3 Finder pattern (page 16)
210 add_finder_patterns(matrix, width, height)
211 # ISO/IEC 18004:2015 -- 6.3.6 Alignment patterns (page 17)
212 add_alignment_patterns(matrix, width, height)
213 # ISO/IEC 18004:2015 -- 7.7 Codeword placement in matrix (page 46)
214 add_codewords(matrix, buff, version)
215 # ISO/IEC 18004:2015(E) -- 7.8.2 Data mask patterns (page 50)
216 # ISO/IEC 18004:2015(E) -- 7.8.3 Evaluation of data masking results (page 53)
217 mask, matrix = find_and_apply_best_mask(matrix, width, height, mask)
218 # ISO/IEC 18004:2015(E) -- 7.9 Format information (page 55)
219 add_format_info(matrix, version, error, mask)
220 # ISO/IEC 18004:2015(E) -- 7.10 Version information (page 58)
221 add_version_info(matrix, version)
222 return Code(matrix, version, error, mask, segments)
223
224
225def boost_error_level(version, error, segments, eci, is_sa=False):
226 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]
229 if version < 1:
230 levels.pop() # H isn't support by Micro QR Codes
231 if version < consts.VERSION_M4:
232 levels.pop() # Error level Q isn't supported by M2 and M3
233 data_length = segments.bit_length_with_overhead(version, eci, is_sa=is_sa)
234 for error_level in levels[levels.index(error) + 1:]:
235 if consts.SYMBOL_CAPACITY[version][error_level] >= data_length:
236 error = error_level
237 else:
238 break
239 return error
240
241
242def write_segment(buff, segment, ver, ver_range, eci=False):
243 mode = segment.mode
244 append_bits = buff.append_bits
245 # Write ECI header if requested
246 if eci and mode == consts.MODE_BYTE \
247 and segment.encoding != consts.DEFAULT_BYTE_ENCODING:
248 append_bits(consts.MODE_ECI, 4)
249 append_bits(get_eci_assignment_number(segment.encoding), 8)
250 if ver is None: # QR Code
251 append_bits(mode, 4)
252 if mode == consts.MODE_HANZI:
253 subset = 1 # Indicator for GB2312 subset
254 append_bits(subset, 4)
255 elif ver > consts.VERSION_M1: # Micro QR Code (M1 has no mode indicator)
256 append_bits(consts.MODE_TO_MICRO_MODE_MAPPING[mode], ver + 3)
257 # Character count indicator
258 append_bits(segment.char_count,
259 consts.CHAR_COUNT_INDICATOR_LENGTH[mode][ver_range])
260 buff.extend(segment.bits)
261
262
263def write_terminator(buff, capacity, ver, length):
264 buff.extend([0] * min(capacity - length, consts.TERMINATOR_LENGTH[ver]))
265
266
267def write_padding_bits(buff, version, length):
268 if version not in (consts.VERSION_M1, consts.VERSION_M3):
269 buff.extend([0] * (8 - (length % 8)))
270
271
272def write_pad_codewords(buff, version, capacity, length):
273 write = buff.extend
274 if version in (consts.VERSION_M1, consts.VERSION_M3):
275 write([0] * (capacity - length))
276 else:
277 pad_codewords = ((1, 1, 1, 0, 1, 1, 0, 0), (0, 0, 0, 1, 0, 0, 0, 1))
278 for i in range(capacity // 8 - length // 8):
279 write(pad_codewords[i % 2])
280
281
282# 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))
292
293
294def add_finder_patterns(matrix, width, height):
295 is_square = width == height
296 corners = ((0, 0), (0, len(matrix) - 8), (-8, 0)) # Upper left, upper right, bottom left
297 if is_square and width < 21:
298 corners = ((0, 0),)
299 finder_range = range(8)
300 for i, j in corners:
301 offset = 1 if i == 0 else 0
302 sepoffset = 0 if j != 0 else 1
303 for r in finder_range:
304 matrix[i + r][j:j + 8] = _FINDER_PATTERN[offset + r][sepoffset:sepoffset + 8]
305
306
307def add_timing_pattern(matrix, is_micro):
308 j, stop = (0, len(matrix)) if is_micro else (6, len(matrix) - 8)
309 col = matrix[j]
310 bit = 0x1
311 for i in range(8, stop):
312 matrix[i][j] = bit
313 col[i] = bit
314 bit ^= 0x1
315
316
317def add_alignment_patterns(matrix, width, height):
318 is_square = width == height
319 version = (width - 17) // 4 # QR Codes: version * 4 + 17 == width / height of the matrix w/o border
320 if is_square and version < 2: # QR Codes version < 2 don't have alignment patterns
321 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)
327 positions = consts.ALIGNMENT_POS[version - 2]
328 alignment_range = range(5)
329 min_pos = positions[0]
330 max_pos = positions[-1]
331 finder_positions = ((min_pos, min_pos), (min_pos, max_pos), (max_pos, min_pos))
332 for x, y in product(positions, repeat=2):
333 if (x, y) in finder_positions:
334 continue
335 # The x and y values represent the center of the alignment pattern
336 i, j = x - 2, y - 2
337 for r in alignment_range:
338 matrix[i + r][j:j + 5] = pattern[r * 5:r * 5 + 5]
339
340
341def add_codewords(matrix, codewords, version):
342 matrix_size = len(matrix)
343 is_micro = version < 1
344 # Necessary for M1 and M3: The algorithm would start at the upper right
345 # corner, see <https://github.com/heuer/segno/issues/36>
346 inc = 0 if version not in (consts.VERSION_M1, consts.VERSION_M3) else 2
347 idx = 0 # Pointer to the current codeword
348 codeword_length = len(codewords)
349 range_two = range(2)
350 for right in range(matrix_size - 1, 0, -2):
351 if not is_micro and right <= 6:
352 right -= 1
353 for vertical in range(matrix_size):
354 for z in range_two:
355 j = right - z
356 upwards = ((right + inc) & 2) == 0
357 if not is_micro:
358 upwards ^= j < 6
359 i = (matrix_size - 1 - vertical) if upwards else vertical
360 row = matrix[i]
361 if row[j] == 0x2 and idx < codeword_length:
362 row[j] = codewords[idx]
363 idx += 1
364 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')
367
368
369def make_final_message(version, error, buff):
370 def to_binary(val, length=8):
371 return ((val >> i) & 1 for i in reversed(range(length)))
372
373 ec_infos = consts.ECC[version][error]
374 data_blocks, error_blocks = make_blocks(ec_infos, buff)
375 cw_four = None
376 if version in (consts.VERSION_M1, consts.VERSION_M3):
377 cw_four = to_binary(data_blocks[0].pop(-1) >> 4, 4)
378 res = Buffer()
379 # 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))))
381 if cw_four is not None:
382 res.extend(cw_four)
383 # 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))))
385 remainder = 0
386 if version in (2, 3, 4, 5, 6):
387 remainder = 7
388 elif version in (14, 15, 16, 17, 18, 19, 20, 28, 29, 30, 31, 32, 33, 34):
389 remainder = 3
390 elif version in (21, 22, 23, 24, 25, 26, 27):
391 remainder = 4
392 res.extend(b'\0' * remainder)
393 return res
394
395
396def make_blocks(ec_infos, buff):
397 codewords = buff.toints()
398 data_blocks, error_blocks = [], []
399 append_data_block = data_blocks.append
400 append_error_block = error_blocks.append
401 gen_log = consts.GALIOS_LOG
402 gen_exp = consts.GALIOS_EXP
403 for ec_info in ec_infos:
404 num_error_words = ec_info.num_total - ec_info.num_data
405 gen = consts.GEN_POLY[num_error_words]
406 range_error_words = range(num_error_words)
407 for i in range(ec_info.num_blocks):
408 block = bytearray(islice(codewords, ec_info.num_data))
409 append_data_block(block)
410 len_data = len(block)
411 error_block = bytearray(block)
412 error_block.extend([0] * num_error_words)
413 # Extended synthetic division, see http://research.swtch.com/field
414 for k in range(len_data):
415 coef = error_block[k]
416 if coef != 0: # log(0) is undefined
417 lcoef = gen_log[coef]
418 for n in range_error_words:
419 error_block[k + n + 1] ^= gen_exp[lcoef + gen[n]]
420 append_error_block(error_block[len_data:])
421 return data_blocks, error_blocks
422
423
424def find_and_apply_best_mask(matrix, width, height, proposed_mask=None):
425 is_better = lt
426 best_score = _MAX_PENALTY_SCORE
427 eval_mask = evaluate_mask
428 is_micro = width == height and width < 21
429 if is_micro:
430 is_better = gt
431 best_score = -1
432 eval_mask = evaluate_micro_mask
433 # Matrix to check if a module belongs to the encoding region
434 # or to the function patterns
435 function_matrix = make_matrix(width, height)
436 add_finder_patterns(function_matrix, width, height)
437 add_alignment_patterns(function_matrix, width, height)
438 if not is_micro:
439 function_matrix[-8][8] = 0x1
440
441 def is_encoding_region(i, j):
442 return function_matrix[i][j] > 0x1
443
444 mask_patterns = get_data_mask_functions(is_micro)
445 # If the user supplied a mask pattern, the evaluation step is skipped
446 if proposed_mask is not None:
447 apply_mask(matrix, mask_patterns[proposed_mask], width, height,
448 is_encoding_region)
449 return proposed_mask, matrix
450
451 best_matrix = None
452 for mask_number, mask_pattern in enumerate(mask_patterns):
453 m = [ba[:] for ba in matrix]
454 apply_mask(m, mask_pattern, width, height, is_encoding_region)
455 # NOTE: DO NOT add format / version info in advance of evaluation
456 # See ISO/IEC 18004:2015(E) -- 7.8. Data masking (page 50)
457 score = eval_mask(m, width, height)
458 if is_better(score, best_score):
459 best_score = score
460 best_pattern = mask_number
461 best_matrix = tuple(m)
462 return best_pattern, best_matrix
463
464
465def apply_mask(matrix, mask_pattern, width, height, is_encoding_region):
466 width_range = range(width)
467 for i in range(height):
468 row = matrix[i]
469 for j in width_range:
470 if is_encoding_region(i, j):
471 row[j] ^= mask_pattern(i, j)
472
473
474def evaluate_mask(matrix, width, height):
475 return sum(mask_scores(matrix, width, height))
476
477
478def mask_scores(matrix, width, height):
479 n3_pattern = bytearray((0x1, 0x0, 0x1, 0x1, 0x1, 0x0, 0x1))
480
481 def n3_pattern_occurrences(seq):
482 count = 0
483 idx = seq.find(n3_pattern)
484 while idx != -1:
485 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)]):
489 count += 40 # N3 = 40
490 else:
491 offset = idx + 4
492 idx = seq.find(n3_pattern, offset)
493 return count
494
495 score_n1 = 0
496 score_n2 = 0
497 score_n3 = 0
498 assert width == height
499 qr_size = width
500 qr_module_range = range(qr_size)
501 dark_module_counter = 0
502 last_row = None
503 # Collects the bytes column-wise (required to calculate score N3)
504 n3_column = bytearray(qr_size)
505 for i in qr_module_range:
506 row = matrix[i]
507 row_prev_bit = -1
508 col_prev_bit = -1
509 # N1
510 n1_row_counter = 0
511 n1_col_counter = 0
512 for j in qr_module_range:
513 row_current_bit = row[j]
514 col_current_bit = matrix[j][i]
515 n3_column[j] = col_current_bit
516 dark_module_counter += row_current_bit
517 # N1 -- row-wise
518 if row_current_bit == row_prev_bit:
519 n1_row_counter += 1
520 else:
521 if n1_row_counter >= 5:
522 score_n1 += n1_row_counter - 2
523 n1_row_counter = 1
524 # N1 -- col-wise
525 if col_current_bit == col_prev_bit:
526 n1_col_counter += 1
527 else:
528 if n1_col_counter >= 5:
529 score_n1 += n1_col_counter - 2
530 n1_col_counter = 1
531 # N2
532 if last_row and j and row_current_bit == row_prev_bit == last_row[j] == last_row[j - 1]:
533 score_n2 += 3
534 row_prev_bit = row_current_bit
535 col_prev_bit = col_current_bit
536 last_row = row
537 # N3
538 score_n3 += n3_pattern_occurrences(row)
539 score_n3 += n3_pattern_occurrences(n3_column)
540 # N1
541 if n1_row_counter >= 5:
542 score_n1 += n1_row_counter - 2
543 if n1_col_counter >= 5:
544 score_n1 += n1_col_counter - 2
545 # N4
546 percent = float(dark_module_counter) / (qr_size ** 2)
547 score_n4 = 10 * int(abs(percent * 100 - 50) / 5) # N4 = 10
548 return score_n1, score_n2, score_n3, score_n4
549
550
551def evaluate_micro_mask(matrix, width, height):
552 module_range = range(1, width)
553 last_row = matrix[-1]
554 sum1 = sum(matrix[i][-1] for i in module_range)
555 sum2 = sum(last_row[i] for i in module_range)
556 return sum1 * 16 + sum2 if sum1 <= sum2 else sum2 * 16 + sum1
557
558
559def calc_format_info(version, error, mask_pattern):
560 fmt = mask_pattern
561 if version > 0:
562 if error == consts.ERROR_LEVEL_L:
563 fmt += 0x08
564 elif error == consts.ERROR_LEVEL_H:
565 fmt += 0x10
566 elif error == consts.ERROR_LEVEL_Q:
567 fmt += 0x18
568 format_info = consts.FORMAT_INFO[fmt]
569 else:
570 fmt += consts.ERROR_LEVEL_TO_MICRO_MAPPING[version][error] << 2
571 format_info = consts.FORMAT_INFO_MICRO[fmt]
572 return format_info
573
574
575def add_format_info(matrix, version, error, mask_pattern):
576 is_micro = version < 1
577 format_info = calc_format_info(version, error, mask_pattern)
578 voffset = int(is_micro)
579 hoffset = voffset
580 row_eight = matrix[8]
581 for i in range(8):
582 vbit = (format_info >> i) & 0x01
583 hbit = (format_info >> (14 - i)) & 0x01
584 if i == 6 and not is_micro: # Timing pattern
585 voffset += 1
586 hoffset = 1
587 # vertical row, upper left corner
588 matrix[i + voffset][8] = vbit
589 # horizontal row, upper left corner
590 row_eight[i + hoffset] = hbit
591 if not is_micro:
592 # horizontal row, upper right corner
593 row_eight[-1 - i] = vbit
594 # vertical row, bottom left corner
595 matrix[-1 - i][8] = hbit
596 if not is_micro:
597 # Dark module
598 matrix[-8][8] = 0x1
599
600
601def add_version_info(matrix, version):
602 if version < 7:
603 return
604 version_info = consts.VERSION_INFO[version - 7]
605 for i in range(6):
606 bit1 = (version_info >> (i * 3)) & 0x01
607 bit2 = (version_info >> ((i * 3) + 1)) & 0x01
608 bit3 = (version_info >> ((i * 3) + 2)) & 0x01
609 # Lower left
610 matrix[-11][i] = bit1
611 matrix[-10][i] = bit2
612 matrix[-9][i] = bit3
613 # Upper right
614 row = matrix[i]
615 row[-11] = bit1
616 row[-10] = bit2
617 row[-9] = bit3
618
619
620def prepare_data(content, mode, encoding):
621 segments = Segments()
622 add_segment = segments.add_segment
623 if isinstance(content, (str, bytes, int)):
624 add_segment(make_segment(content, mode, encoding))
625 return segments
626 for item in content:
627 seg_content, seg_mode, seg_encoding = item, mode, encoding
628 if isinstance(item, tuple):
629 seg_content = item[0]
630 if len(item) > 1:
631 seg_mode = item[1] or mode # item[1] could be None
632 if len(item) > 2:
633 seg_encoding = item[2] or encoding # item[2] may be None
634 add_segment(make_segment(seg_content, seg_mode, seg_encoding))
635 return segments
636
637
638def data_to_bytes(data, encoding):
639 if isinstance(data, bytes):
640 return data, len(data), encoding or consts.DEFAULT_BYTE_ENCODING
641 data = str(data)
642 if encoding is not None:
643 # Use the provided encoding; could raise an exception by intention
644 data = data.encode(encoding)
645 else:
646 try:
647 # Try to use the default byte encoding
648 encoding = consts.DEFAULT_BYTE_ENCODING
649 data = data.encode(encoding)
650 except UnicodeError:
651 try:
652 # Try Kanji / Shift_JIS
653 encoding = consts.KANJI_ENCODING
654 data = data.encode(encoding)
655 except UnicodeError:
656 # Use UTF-8
657 encoding = 'utf-8'
658 data = data.encode(encoding)
659 return data, len(data), encoding
660
661
662def make_segment(data, mode, encoding=None):
663 if mode == consts.MODE_HANZI:
664 encoding = consts.HANZI_ENCODING
665 segment_data, segment_length, segment_encoding = data_to_bytes(data, encoding)
666 segment_mode = mode
667 guessed_mode = find_mode(segment_data) if segment_mode != consts.MODE_BYTE else consts.MODE_BYTE
668 if segment_mode is not None:
669 # Check if user provided mode is applicable for the given segment_data
670 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)}')
674 else:
675 segment_mode = guessed_mode
676 if segment_mode != consts.MODE_BYTE:
677 segment_encoding = None
678 char_count = segment_length if segment_mode not in (consts.MODE_KANJI, consts.MODE_HANZI) else segment_length // 2
679 buff = Buffer()
680 append_bits = buff.append_bits
681 if segment_mode == consts.MODE_NUMERIC:
682 for i in range(0, segment_length, 3):
683 chunk = segment_data[i:i + 3]
684 append_bits(int(chunk), len(chunk) * 3 + 1)
685 elif segment_mode == consts.MODE_ALPHANUMERIC:
686 # ISO/IEC 18004:2015(E) -- 7.4.4 Alphanumeric mode (page 26)
687 to_byte = consts.ALPHANUMERIC_CHARS.find
688 for i in range(0, segment_length, 2):
689 chunk = segment_data[i:i + 2]
690 if len(chunk) > 1:
691 append_bits(to_byte(chunk[0]) * 45 + to_byte(chunk[1]), 11)
692 else:
693 append_bits(to_byte(chunk), 6)
694 elif segment_mode == consts.MODE_BYTE:
695 # ISO/IEC 18004:2015(E) -- 7.4.5 Byte mode (page 27)
696 for b in segment_data:
697 append_bits(b, 8)
698 elif segment_mode == consts.MODE_HANZI:
699 # GBT 18284-2000 -- 6.4.5 Hanzi mode (page 18)
700 # Note: len(segment.data)! segment.data_length = len(segment.data) / 2!!
701 for i in range(0, segment_length, 2):
702 code = (segment_data[i] << 8) | segment_data[i + 1]
703 if 0xa1a1 <= code <= 0xaafe:
704 # For characters with GB2312 values from A1A1HEX to AAFEHEX:
705 # a) Subtract A1A1HEX from GB2312 value;
706 diff = code - 0xa1a1
707 elif 0xb0a1 <= code <= 0xfafe:
708 # For characters with GB2312 values from B0A1HEX to FAFEHEX:
709 # a) Subtract A6A1HEX from GB2312 value;
710 diff = code - 0xa6a1
711 else: # pragma: no cover
712 raise ValueError(f'Invalid Hanzi bytes: {code}')
713 append_bits(((diff >> 8) * 0x60) + (diff & 0xff), 13)
714 else:
715 # ISO/IEC 18004:2015(E) -- 7.4.6 Kanji mode (page 29)
716 for i in range(0, segment_length, 2):
717 code = (segment_data[i] << 8) | segment_data[i + 1]
718 if 0x8140 <= code <= 0x9ffc:
719 # 1. a) For characters with Shift JIS values from 8140HEX to 9FFCHEX:
720 # Subtract 8140HEX from Shift JIS value;
721 diff = code - 0x8140
722 elif 0xe040 <= code <= 0xebbf:
723 # 2. a) For characters with Shift JIS values from E040HEX to EBBFHEX:
724 # Subtract C140HEX from Shift JIS value;
725 diff = code - 0xc140
726 else: # pragma: no cover
727 raise ValueError(f'Invalid Kanji bytes: {code}')
728 append_bits(((diff >> 8) * 0xc0) + (diff & 0xff), 13)
729 return _Segment(buff.getbits(), char_count, segment_mode, segment_encoding)
730
731
732def make_matrix(width, height, reserve_regions=True, add_timing=True):
733 is_square = width == height
734 is_micro = is_square and width < 21
735 row = [0x2] * width
736 matrix = tuple(bytearray(row) for i in range(height))
737 if reserve_regions:
738 if is_square and width > 41: # QR Codes < version 7 don't have a version pattern
739 # Reserve version pattern areas
740 for i in range(6):
741 row = matrix[i]
742 # Upper right
743 row[-11] = 0x0
744 row[-10] = 0x0
745 row[-9] = 0x0
746 # Lower left
747 matrix[-11][i] = 0x0
748 matrix[-10][i] = 0x0
749 matrix[-9][i] = 0x0
750 # Reserve format pattern areas
751 row_eight = matrix[8]
752 for i in range(9):
753 matrix[i][8] = 0x0 # Upper left
754 row_eight[i] = 0x0 # Upper bottom
755 if not is_micro:
756 matrix[-i][8] = 0x0 # Bottom left
757 row_eight[-i] = 0x0 # Upper right
758 if add_timing:
759 # ISO/IEC 18004:2015 -- 6.3.5 Timing pattern (page 17)
760 add_timing_pattern(matrix, is_micro)
761 return matrix
762
763
764def normalize_version(version):
765 if version is None:
766 return None
767 error = False
768 try:
769 version = int(version)
770 # Don't want Micro QR Code constants as input
771 error = version < 1
772 except (ValueError, TypeError):
773 try:
774 version = consts.MICRO_VERSION_MAPPING[version.upper()]
775 except (KeyError, AttributeError):
776 error = True
777 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')
780 return version
781
782
783def normalize_mode(mode):
784 if mode is None or mode in consts.MODE_MAPPING.values():
785 return mode
786 try:
787 return consts.MODE_MAPPING[mode.lower()]
788 except (KeyError, AttributeError):
789 raise ValueError(f'Illegal mode "{mode}". '
790 f'Supported values: {", ".join(sorted(consts.MODE_MAPPING.keys()))}')
791
792
793def normalize_mask(mask, is_micro):
794 if mask is None:
795 return None
796 try:
797 mask = int(mask)
798 except ValueError:
799 raise ValueError(f'Invalid data mask "{mask}". '
800 'Must be an integer or a string which represents an integer value.')
801 if is_micro:
802 if not 0 <= mask < 4:
803 raise ValueError(f'Invalid data mask "{mask}" for Micro QR Code. Must be in range 0 .. 3')
804 else:
805 if not 0 <= mask < 8:
806 raise ValueError(f'Invalid data mask "{mask}". Must be in range 0 .. 7')
807 return mask
808
809
810def normalize_errorlevel(error, accept_none=False):
811 if error is None:
812 if not accept_none:
813 raise ValueError('The error level must be provided')
814 return error
815 try:
816 return consts.ERROR_MAPPING[error.upper()]
817 except (KeyError, AttributeError):
818 if error in consts.ERROR_MAPPING.values():
819 return error
820 raise ValueError(f'Illegal error correction level: "{error}". Supported levels: L, M, Q, H')
821
822
823def get_mode_name(mode_const):
824 for name, val in consts.MODE_MAPPING.items():
825 if val == mode_const:
826 return name
827 raise ValueError(f'Unknown mode "{mode_const}"')
828
829
830def get_error_name(error_const):
831 for name, val in consts.ERROR_MAPPING.items():
832 if val == error_const:
833 return name
834 raise ValueError(f'Unknown error level "{error_const}"')
835
836
837def get_version_name(version_const):
838 if 0 < version_const < 41:
839 return version_const
840 for name, v in consts.MICRO_VERSION_MAPPING.items():
841 if v == version_const:
842 return name
843 raise ValueError(f'Unknown version constant "{version_const}"')
844
845
846_ALPHANUMERIC_PATTERN = re.compile(br'^[' + re.escape(consts.ALPHANUMERIC_CHARS) + br']+\Z')
847
848
849def is_alphanumeric(data):
850 return _ALPHANUMERIC_PATTERN.match(data)
851
852
853def is_kanji(data):
854 data_len = len(data)
855 if not data_len or data_len % 2:
856 return False
857 data_iter = iter(data)
858 for i in range(0, data_len, 2):
859 code = (next(data_iter) << 8) | next(data_iter)
860 if not (0x8140 <= code <= 0x9ffc or 0xe040 <= code <= 0xebbf):
861 return False
862 return True
863
864
865def find_mode(data):
866 if data.isdigit():
867 return consts.MODE_NUMERIC
868 if is_alphanumeric(data):
869 return consts.MODE_ALPHANUMERIC
870 if is_kanji(data):
871 return consts.MODE_KANJI
872 return consts.MODE_BYTE
873
874
875def find_version(segments, error, eci, micro, is_sa=False):
876 assert not (eci and micro)
877 micro_allowed = micro or micro is None
878 min_version = consts.VERSION_M1 if micro_allowed else 1
879 max_version = consts.VERSION_M4 if micro else 40
880 if min_version < 1:
881 min_version = max([find_minimum_version_for_mode(mode) for mode in segments.modes])
882 if error is not None and micro_allowed:
883 min_version = consts.VERSION_M2
884 for version in range(min_version, max_version + 1):
885 if error is None and version != consts.VERSION_M1:
886 error = consts.ERROR_LEVEL_L
887 try:
888 if consts.SYMBOL_CAPACITY[version][error] >= segments.bit_length_with_overhead(version, eci, is_sa):
889 return version
890 except KeyError:
891 pass
892 help_txt = ''
893 if micro is None:
894 help_txt = '(Micro) '
895 elif micro:
896 help_txt = 'Micro '
897 raise DataOverflowError(f'Data too large. No {help_txt}QR Code can handle the provided data')
898
899
900def calc_matrix_size(ver):
901 return ver * 4 + 17 if ver > 0 else (ver + 4) * 2 + 9
902
903
904def calc_structured_append_parity(content):
905 if not isinstance(content, str):
906 content = str(content)
907 try:
908 data = content.encode('iso-8859-1')
909 except UnicodeError:
910 try:
911 data = content.encode('shift-jis')
912 except (LookupError, UnicodeError):
913 data = content.encode('utf-8')
914 return reduce(xor, data)
915
916
917def is_mode_supported(mode, ver):
918 ver = None if ver > 0 else ver
919 try:
920 return ver in consts.SUPPORTED_MODES[mode]
921 except KeyError:
922 raise ValueError(f'Unknown mode "{mode}"')
923
924
925def find_minimum_version_for_mode(mode):
926 for v in consts.MICRO_VERSIONS:
927 if is_mode_supported(mode, v):
928 return v
929 return 1
930
931
932def version_range(version):
933 if 0 < version < 10:
934 return consts.VERSION_RANGE_01_09
935 elif 9 < version < 27:
936 return consts.VERSION_RANGE_10_26
937 elif 26 < version < 41:
938 return consts.VERSION_RANGE_27_40
939 raise ValueError(f'Unknown version "{version}"')
940
941
942def get_eci_assignment_number(encoding):
943 try:
944 return consts.ECI_ASSIGNMENT_NUM[codecs.lookup(encoding).name]
945 except KeyError:
946 raise ValueError(f'Unknown ECI assignment number for encoding "{encoding}".')
947
948
949def get_data_mask_functions(is_micro):
950 def fn0(i, j):
951 return (i + j) & 0x1 == 0
952
953 def fn1(i, j):
954 return i & 0x1 == 0
955
956 def fn2(i, j):
957 return j % 3 == 0
958
959 def fn3(i, j):
960 return (i + j) % 3 == 0
961
962 def fn4(i, j):
963 return (i // 2 + j // 3) & 0x1 == 0
964
965 def fn5(i, j):
966 tmp = i * j
967 return (tmp & 0x1) + (tmp % 3) == 0
968
969 def fn6(i, j):
970 tmp = i * j
971 return ((tmp & 0x1) + (tmp % 3)) & 0x1 == 0
972
973 def fn7(i, j):
974 return (((i + j) & 0x1) + (i * j) % 3) & 0x1 == 0
975
976 if is_micro:
977 return fn1, fn4, fn6, fn7
978 return fn0, fn1, fn2, fn3, fn4, fn5, fn6, fn7
979
980
981class Segments:
982 __slots__ = ('bit_length', 'modes', 'segments')
983
984 def __init__(self):
985 self.segments = []
986 self.bit_length = 0
987 self.modes = []
988
989 def add_segment(self, segment):
990 """\
991
992 :param _Segment segment: Segment to add.
993 """
994 if self.segments:
995 prev_seg = self.segments[-1]
996 if prev_seg.mode == segment.mode and prev_seg.encoding == segment.encoding:
997 # 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)
1001 self.bit_length -= len(prev_seg.bits)
1002 del self.segments[-1]
1003 del self.modes[-1]
1004 self.segments.append(segment)
1005 self.bit_length += len(segment.bits)
1006 self.modes.append(segment.mode)
1007
1008 def __len__(self):
1009 return len(self.segments)
1010
1011 def __getitem__(self, item):
1012 return self.segments[item]
1013
1014 def __iter__(self):
1015 return iter(self.segments)
1016
1017 def bit_length_with_overhead(self, version, eci, is_sa=False):
1018 overhead = 0
1019 # ECI overhead
1020 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)
1024 overhead += no_eci_indicators * 4 # ECI indicator
1025 overhead += no_eci_indicators * 8 # ECI assignment no
1026 if is_sa:
1027 # 4 bit for mode, 4 bit for the position, 4 bit for total number of symbols
1028 # 8 bit for parity data
1029 overhead += 5 * 4
1030 # Mode indicator overhead
1031 if version > 0: # QR Code
1032 overhead += len(self.modes) * 4
1033 elif version > consts.VERSION_M1: # Micro QR Code (M1 has no mode indicator)
1034 overhead += len(self.modes) * (version + 3)
1035 # Char count indicator overhead
1036 ver_range = version_range(version) if version > 0 else version
1037 overhead += sum(consts.CHAR_COUNT_INDICATOR_LENGTH[mode][ver_range] for mode in self.modes)
1038 return overhead + self.bit_length
1039
1040
1041class _Segment(tuple):
1042 __slots__ = ()
1043
1044 def __new__(cls, bits, char_count, mode, encoding=None):
1045 return tuple.__new__(cls, (bits, char_count, mode, encoding))
1046
1047 bits = property(itemgetter(0))
1048 char_count = property(itemgetter(1))
1049 mode = property(itemgetter(2))
1050 encoding = property(itemgetter(3))
1051
1052
1053class Buffer:
1054 __slots__ = ['_data']
1055
1056 def __init__(self, iterable=()):
1057 self._data = bytearray(iterable)
1058
1059 def extend(self, iterable):
1060 self._data.extend(iterable)
1061
1062 def append_bits(self, val, length):
1063 self._data.extend((val >> i) & 1 for i in reversed(range(length)))
1064
1065 def getbits(self):
1066 return self._data
1067
1068 def toints(self):
1069 """\
1070 Returns an iterable of integers interpreting the content of `seq`
1071 as sequence of binary numbers of length 8.
1072 """
1073 return (int(''.join(map(str, g)), 2) for g in zip_longest(*[iter(self._data)] * 8, fillvalue=0))
1074
1075 def __len__(self):
1076 return len(self._data)
1077
1078 def __getitem__(self, item):
1079 return self._data[item]
1080
1081
1082class _StructuredAppendInfo(tuple):
1083 __slots__ = ()
1084
1085 def __new__(cls, number, total, parity):
1086 return super().__new__(cls, (consts.MODE_STRUCTURED_APPEND, number, total, parity))
1087
1088 mode = property(itemgetter(0))
1089 number = property(itemgetter(1))
1090 total = property(itemgetter(2))
1091 parity = property(itemgetter(3))
plugin.video.jacktook/lib/vendor/segno/utils.py +225 -0
……@@ -0,0 +1,225 @@
1#
2# Copyright (c) 2016 - 2024 -- Lars Heuer
3# All rights reserved.
4#
5# License: BSD License
6#
7# type: ignore
8"""\
9Utility functions useful for writers or QR Code objects.
10
11DOES NOT belong to the public API.
12"""
13from itertools import chain, repeat
14from . import consts
15
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')
19
20
21def get_default_border_size(matrix_size):
22 """\
23 Returns the default border size (quiet zone) for the provided version.
24
25 :param tuple(int, int) matrix_size: Tuple of width and height of the matrix.
26 :rtype: int
27 """
28 width, height = matrix_size
29 return 4 if width > 17 and width == height else 2
30
31
32def get_border(matrix_size, border):
33 """\
34 Returns `border` if not ``None``, otherwise the default border size for
35 the provided QR Code.
36
37 :param tuple(int, int) matrix_size: Tuple of width and height of the matrix.
38 :param border: The size of the quiet zone or ``None``.
39 :type border: int or None
40
41 :rtype: int
42 """
43 return border if border is not None else get_default_border_size(matrix_size)
44
45
46def get_symbol_size(matrix_size, scale=1, border=None):
47 """\
48 Returns the symbol size (width x height) with the provided border and
49 scaling factor.
50
51 :param tuple(int, int) matrix_size: Tuple of width and height of the matrix.
52 :param scale: Indicates the size of a single module (default: 1).
53 The size of a module depends on the used output format; i.e.
54 in a PNG context, a scaling factor of 2 indicates that a module
55 has a size of 2 x 2 pixel. Some outputs (i.e. SVG) accept
56 floating point values.
57 :type scale: int or float
58 :param int border: The border size or ``None`` to specify the
59 default quiet zone (4 for QR Codes, 2 for Micro QR Codes).
60 :rtype: tuple (width, height)
61 """
62 if border is None:
63 border = get_default_border_size(matrix_size)
64 width, height = matrix_size
65 width += 2 * border
66 height += 2 * border
67 return width * scale, height * scale
68
69
70def check_valid_scale(scale):
71 """\
72 Raises a :py:exc:`ValueError` iff `scale` is negative or zero.
73
74 :param scale: Scaling factor.
75 :type scale: float or int
76 """
77 if scale <= 0:
78 raise ValueError(f'The scale must not be negative or zero. Got: "{scale}"')
79
80
81def check_valid_border(border):
82 """\
83 Raises a :py:exc:`ValueError` iff `border` is negative.
84
85 :param int border: Indicating the size of the quiet zone.
86 """
87 if border is not None and (int(border) != border or border < 0):
88 raise ValueError(f'The border must not a non-negative integer value. Got: "{border}"')
89
90
91def matrix_to_lines(matrix, x, y, incby=1):
92 """\
93 Converts the `matrix` into an iterable of ((x1, y1), (x2, y2)) tuples which
94 represent a sequence (horizontal line) of dark modules.
95
96 The path starts at the 1st row of the matrix and moves down to the last row.
97
98 :param matrix: An iterable of bytearrays.
99 :param x: Initial position on the x-axis.
100 :param y: Initial position on the y-axis.
101 :param incby: Value to move along the y-axis (default: 1).
102 :rtype: iterable of (x1, y1), (x2, y2) tuples
103 """
104 y -= incby # Move along y-axis so we can simply increment y in the loop
105 last_bit = 0x1
106 for row in matrix:
107 x1, x2 = x, x
108 y += incby
109 for bit in row:
110 if last_bit != bit and not bit:
111 yield (x1, y), (x2, y)
112 x1 = x2
113 x2 += 1
114 if not bit:
115 x1 += 1
116 last_bit = bit
117 if last_bit:
118 yield (x1, y), (x2, y)
119 last_bit = 0x0
120
121
122def matrix_iter(matrix, matrix_size, scale=1, border=None):
123 """\
124 Returns an iterator / generator over the provided matrix which includes
125 the border and the scaling factor.
126
127 If either the `scale` or `border` value is invalid, a :py:exc:`ValueError`
128 is raised.
129
130 :param matrix: An iterable of bytearrays.
131 :param tuple(int, int) matrix_size: Tuple of width and height of the matrix.
132 :param int scale: The scaling factor (default: ``1``).
133 :param int border: The border size or ``None`` to specify the
134 default quiet zone (4 for QR Codes, 2 for Micro QR Codes).
135 :raises: :py:exc:`ValueError` if an illegal scale or border value is provided
136 """
137 check_valid_border(border)
138 scale = int(scale)
139 check_valid_scale(scale)
140 border = get_border(matrix_size, border)
141 width, height = matrix_size
142 border_row = [0x0] * width
143 width_range, height_range = range(-border, width + border), range(-border, height + border)
144 for i in height_range:
145 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))
147 for s in repeat(None, scale):
148 yield row
149
150
151def matrix_iter_verbose(matrix, matrix_size, scale=1, border=None):
152 """\
153 Returns an iterator / generator over the provided matrix which includes
154 the border and the scaling factor.
155
156 This iterator / generator returns different values for dark / light modules
157 and therefor the different parts (like the finder patterns, alignment patterns etc.)
158 are distinguishable. If this information isn't necessary, use the
159 :py:func:`matrix_iter()` function because it is much cheaper and faster.
160
161 If either the `scale` or `border` value is invalid, a py:exc:`ValueError`
162 is raised.
163
164 :param matrix: An iterable of bytearrays.
165 :param tuple(int, int) matrix_size: Tuple of width and height of the matrix.
166 :param int scale: The scaling factor (default: ``1``).
167 :param int border: The border size or ``None`` to specify the
168 default quiet zone (4 for QR Codes, 2 for Micro QR Codes).
169 :raises: :py:exc:`ValueError` if an illegal scale or border value is provided
170 """
171 from segno import encoder
172 check_valid_border(border)
173 scale = int(scale)
174 check_valid_scale(scale)
175 border = get_border(matrix_size, border)
176 width, height = matrix_size
177 is_square = width == height
178 is_micro = is_square and width < 21 # 21 == QR Code version 1
179 # Create an empty matrix with invalid 0x2 values
180 alignment_matrix = encoder.make_matrix(width, height, reserve_regions=False, add_timing=False)
181 encoder.add_alignment_patterns(alignment_matrix, width, height)
182
183 def get_bit(i, j):
184 # Check if we operate upon the matrix or the "virtual" border
185 if 0 <= i < height and 0 <= j < width:
186 val = matrix[i][j]
187 if not is_micro:
188 # Alignment pattern
189 alignment_val = alignment_matrix[i][j]
190 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):
195 return (consts.TYPE_VERSION_LIGHT, consts.TYPE_VERSION_DARK)[val]
196 # Dark module
197 if i == height - 8 and j == 8:
198 return consts.TYPE_DARKMODULE
199 # 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))):
202 return (consts.TYPE_TIMING_LIGHT, consts.TYPE_TIMING_DARK)[val]
203 # 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))):
206 return (consts.TYPE_FORMAT_LIGHT, consts.TYPE_FORMAT_DARK)[val]
207 # Finder pattern
208 # 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]
212 # Separator
213 # 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
216 return consts.TYPE_SEPARATOR
217 return (consts.TYPE_DATA_LIGHT, consts.TYPE_DATA_DARK)[val]
218 else:
219 return consts.TYPE_QUIET_ZONE
220
221 width_range, height_range = range(-border, width + border), range(-border, height + border)
222 for i in height_range:
223 row = tuple(chain.from_iterable(repeat(get_bit(i, j), scale) for j in width_range))
224 for s in repeat(None, scale):
225 yield row
plugin.video.jacktook/lib/vendor/segno/writers.py +1079 -0
……@@ -0,0 +1,1079 @@
1#
2# Copyright (c) 2016 - 2024 -- Lars Heuer
3# All rights reserved.
4#
5# License: BSD License
6#
7# type: ignore
8import io
9import re
10import zlib
11import codecs
12import base64
13import gzip
14from xml.sax.saxutils import quoteattr, escape
15from struct import pack
16from itertools import chain, repeat
17import functools
18from functools import partial
19from functools import reduce
20from operator import itemgetter
21from contextlib import contextmanager
22from collections import defaultdict
23import time
24from . import consts
25from .utils import matrix_to_lines, get_symbol_size, get_border, check_valid_scale, check_valid_border, matrix_iter, matrix_iter_verbose
26from itertools import zip_longest
27from urllib.parse import quote
28
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')
32
33# Standard creator name
34CREATOR = 'Segno <https://pypi.org/project/segno/>'
35
36
37@contextmanager
38def writable(file_or_path, mode, encoding=None):
39 f = file_or_path
40 must_close = False
41 try:
42 file_or_path.write
43 if encoding is not None:
44 f = codecs.getwriter(encoding)(file_or_path)
45 except AttributeError:
46 f = open(file_or_path, mode, encoding=encoding)
47 must_close = True
48 try:
49 yield f
50 finally:
51 if must_close:
52 f.close()
53
54
55def colorful(dark, light):
56 def decorate(f):
57 @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)
71 return f(matrix, matrix_size, out, cm, **kw)
72 return wrapper
73 return decorate
74
75
76def _valid_width_height_and_border(matrix_size, scale, border):
77 check_valid_scale(scale)
78 check_valid_border(border)
79 border = get_border(matrix_size, border)
80 width, height = get_symbol_size(matrix_size, scale, border)
81 return width, height, border
82
83
84@colorful(dark='#000', light=None)
85def write_svg(matrix, matrix_size, out, colormap, scale=1, border=None, xmldecl=True,
86 svgns=True, title=None, desc=None, svgid=None, svgclass='segno',
87 lineclass='qrline', omitsize=False, unit=None, encoding='utf-8',
88 svgversion=None, nl=True, draw_transparent=False):
89 def svg_color(clr):
90 return _color_to_webcolor(clr, allow_css3_colors=allow_css3_colors) if clr is not None else None
91
92 def matrix_to_lines_verbose():
93 j = -.5 # stroke width / 2
94 invalid_color = -1
95 for row in matrix_iter_verbose(matrix, matrix_size, scale=1, border=border):
96 last_color = invalid_color
97 x1, x2 = 0, 0
98 j += 1
99 for c in (colormap[mt] for mt in row):
100 if last_color != invalid_color and last_color != c:
101 yield last_color, (x1, x2, j)
102 x1 = x2
103 x2 += 1
104 last_color = c
105 yield last_color, (x1, x2, j)
106
107 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
108 unit = unit or ''
109 if unit and omitsize:
110 raise ValueError(f'The unit "{unit}" has no effect if the size '
111 '(width and height) is omitted.')
112 omit_encoding = encoding is None
113 if omit_encoding:
114 encoding = 'utf-8'
115 allow_css3_colors = svgversion is not None and svgversion >= 2.0
116 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
118 need_svg_group = scale != 1 and (need_background or is_multicolor)
119 if is_multicolor:
120 miter = matrix_to_lines_verbose()
121 else:
122 x, y = border, border + .5
123 dark = colormap[consts.TYPE_DATA_DARK]
124 miter = ((dark, (x1, x2, y1)) for (x1, y1), (x2, y2) in matrix_to_lines(matrix, x, y))
125 xy = defaultdict(lambda: (0, 0))
126 coordinates = defaultdict(list)
127 for clr, (x1, x2, y1) in miter:
128 x, y = xy[clr]
129 coordinates[clr].append((x1 - x, y1 - y, x2 - x1))
130 xy[clr] = x2, y1
131 if need_background:
132 coordinates[colormap[consts.TYPE_QUIET_ZONE]] = [(0, 0, width // scale)]
133 if not draw_transparent:
134 try:
135 del coordinates[None]
136 except KeyError:
137 pass
138 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)}')
142 for color, coord in coordinates.items():
143 path = p
144 clr = svg_color(color)
145 if clr is not None:
146 opacity = None
147 if isinstance(clr, tuple):
148 clr, opacity = clr
149 path += f' stroke={quoteattr(clr)}'
150 if opacity is not None:
151 path += f' stroke-opacity={quoteattr(str(opacity))}'
152 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))
157 path += '"/>'
158 paths[color] = path
159 if need_background:
160 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 = ''
165 if xmldecl:
166 svg += '<?xml version="1.0"'
167 if not omit_encoding:
168 svg += f' encoding={quoteattr(encoding)}'
169 svg += '?>\n'
170 svg += '<svg'
171 if svgns:
172 svg += ' xmlns="http://www.w3.org/2000/svg"'
173 if svgversion is not None and svgversion < 2.0:
174 svg += f' version={quoteattr(str(svgversion))}'
175 if not omitsize:
176 svg += f' width="{width}{unit}" height="{height}{unit}"'
177 if omitsize or unit:
178 svg += f' viewBox="0 0 {width} {height}"'
179 if svgid:
180 svg += f' id={quoteattr(svgid)}'
181 if svgclass:
182 svg += f' class={quoteattr(svgclass)}'
183 svg += '>'
184 if title is not None:
185 svg += f'<title>{escape(title)}</title>'
186 if desc is not None:
187 svg += f'<desc>{escape(desc)}</desc>'
188 if need_svg_group:
189 svg += f'<g{scale_info}>'
190 svg += ''.join(sorted(paths.values(), key=len))
191 if need_svg_group:
192 svg += '</g>'
193 svg += '</svg>'
194 if nl:
195 svg += '\n'
196 with writable(out, 'wt', encoding=encoding) as f:
197 f.write(svg)
198
199
200_replace_quotes = partial(re.compile(br'(=)"([^"]+)"').sub, br"\1'\2'")
201
202
203def as_svg_data_uri(matrix, matrix_size, scale=1, border=None,
204 xmldecl=False, svgns=True, title=None,
205 desc=None, svgid=None, svgclass='segno',
206 lineclass='qrline', omitsize=False, unit='',
207 encoding='utf-8', svgversion=None, nl=False,
208 encode_minimal=False, omit_charset=False, **kw):
209 encode = partial(quote, safe=b"") if not encode_minimal else partial(quote, safe=b" :/='")
210 buff = io.BytesIO()
211 write_svg(matrix, matrix_size, buff, scale=scale, border=border, xmldecl=xmldecl,
212 svgns=svgns, title=title, desc=desc, svgclass=svgclass,
213 lineclass=lineclass, omitsize=omitsize, encoding=encoding,
214 svgid=svgid, unit=unit, svgversion=svgversion, nl=nl, **kw)
215 return f'data:image/svg+xml{(";charset=" + encoding if not omit_charset else "")},' \
216 + encode(_replace_quotes(buff.getvalue()))
217
218
219def write_svg_debug(matrix, matrix_size, out, scale=15, border=None,
220 fallback_color='fuchsia', colormap=None,
221 add_legend=True): # pragma: no cover
222 clr_mapping = {
223 0x0: '#fff',
224 0x1: '#000',
225 0x2: 'red',
226 0x3: 'orange',
227 0x4: 'gold',
228 0x5: 'green',
229 }
230 if colormap is not None:
231 clr_mapping.update(colormap)
232 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
233 matrix_width, matrix_height = matrix_size
234 with writable(out, 'wt', encoding='utf-8') as f:
235 legend = []
236 write = f.write
237 write('<?xml version="1.0" encoding="utf-8"?>\n')
238 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>')
241 write(f'<g transform="scale({scale})">')
242 for i in range(matrix_height):
243 y = i + border
244 for j in range(matrix_width):
245 x = j + border
246 bit = matrix[i][j]
247 if add_legend and bit not in (0x0, 0x1):
248 legend.append((x, y, bit))
249 fill = clr_mapping.get(bit, fallback_color)
250 write(f'<rect x="{x}" y="{y}" width="1" height="1" fill="{fill}"/>')
251 for x, y, val in legend:
252 write(f'<text x="{x + .2}" y="{y + .9}">{val}</text>')
253 write('</g></svg>\n')
254
255
256def write_eps(matrix, matrix_size, out, scale=1, border=None, dark='#000', light=None):
257 import textwrap
258
259 def write_line(writemeth, content):
260 for line in textwrap.wrap(content, 254):
261 writemeth(line)
262 writemeth('\n')
263
264 def rgb_to_floats(clr):
265 def to_float(c):
266 if isinstance(c, float):
267 if not 0.0 <= c <= 1.0:
268 raise ValueError(f'Invalid color "{c}". Not in range 0 .. 1')
269 return c
270 return 1 / 255.0 * c if c != 1 else c
271
272 return tuple([to_float(i) for i in _color_to_rgb(clr)])
273
274 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
275 stroke_color_is_black = _color_is_black(dark)
276 stroke_color = dark if stroke_color_is_black else rgb_to_floats(dark)
277 with writable(out, 'wt') as f:
278 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')
286 if light is not None:
287 writeline('{0:f} {1:f} {2:f} setrgbcolor clippath fill'.format(*rgb_to_floats(light))) # noqa UP030
288 if stroke_color_is_black:
289 writeline('0 0 0 setrgbcolor')
290 if not stroke_color_is_black:
291 writeline('{0:f} {1:f} {2:f} setrgbcolor'.format(*stroke_color)) # noqa UP030
292 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
296 line_iter = matrix_to_lines(matrix, border, y, incby=-1)
297 (x1, y1), (x2, y2) = next(line_iter)
298 coord = [f'{x1} {y1} moveto {x2 - x1} 0 l']
299 append_coord = coord.append
300 x = x2
301 for (x1, y1), (x2, y2) in line_iter:
302 append_coord(f' {x1 - x} {int(y1 - y)} m {x2 - x1} 0 l')
303 x, y = x2, y2
304 writeline(''.join(coord))
305 writeline('stroke')
306 writeline('%%EOF')
307
308
309def as_png_data_uri(matrix, matrix_size, scale=1, border=None, compresslevel=9, **kw):
310 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')
316def write_png(matrix, matrix_size, out, colormap, scale=1, border=None, compresslevel=9, dpi=None):
317
318 def png_color(clr):
319 return _color_to_rgb_or_rgba(clr, alpha_float=False) if clr is not None else transparent
320
321 def chunk(name, data):
322 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))))
329
330 scale = int(scale)
331 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
332 if dpi:
333 dpi = int(dpi)
334 if dpi < 0:
335 raise ValueError('DPI value must not be negative')
336 dpi = int(dpi // 0.0254)
337
338 black = (0, 0, 0)
339 white = (255, 255, 255)
340 transparent = (-1, -1, -1, -1) # Invalid placeholder for transparent color
341 dark_idx = consts.TYPE_FINDER_PATTERN_DARK
342 qz_idx = consts.TYPE_QUIET_ZONE
343 clr_map = {k: png_color(colormap[k]) for k in colormap}
344 # Creating a palette here regardless of the image type (greyscale vs. index-colors)
345 palette = sorted(set(clr_map.values()), key=itemgetter(0, 1, 2))
346 is_transparent = transparent in palette
347 number_of_colors = len(palette)
348 # Check if greyscale mode is applicable
349 is_greyscale = number_of_colors == 2 and all(clr in (transparent, black, white) for clr in palette)
350 png_color_type = 0 if is_greyscale else 3
351 png_bit_depth = 1 # Assume a bit depth of 1 (may change if PLTE is used)
352 png_trans_idx = None
353 if not is_greyscale: # PLTE
354 if number_of_colors > 2:
355 png_bit_depth = 2 if number_of_colors < 5 else 4
356 palette.sort(key=len, reverse=True) # RGBA colors first
357 if is_transparent:
358 png_trans_idx = 0
359 rgb_values = _NAME2RGB.values() if len(palette[1]) == 3 else ((*clr, 0) for clr in _NAME2RGB.values())
360 # Choose a random color which becomes transparent.
361 transparent_color = next(clr for clr in rgb_values if clr not in palette)
362 palette[0] = transparent_color
363 # 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})
366 elif is_transparent: # Greyscale and transparent
367 if black in palette:
368 # Since black is zero, it should be the first entry
369 palette = [black, transparent]
370 png_trans_idx = palette.index(transparent)
371 if number_of_colors > 2:
372 # Need the more expensive matrix iterator
373 miter = matrix_iter_verbose(matrix, matrix_size, scale=1, border=0)
374 color_index = {module_type: palette.index(clr) for module_type, clr in clr_map.items()}
375 else:
376 # Just two colors, use the cheap iterator which returns 0x0 or 0x1
377 miter = iter(matrix)
378 # The code to create the image requires that TYPE_QUIET_ZONE is available
379 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])})
382 miter = ((color_index[b] for b in r) for r in miter)
383 horizontal_border = b''
384 vertical_border = b''
385 if border > 0:
386 # Calculate horizontal and vertical border
387 qz_value = color_index[qz_idx]
388 horizontal_border = scanline(repeat(qz_value, width)) * border * scale
389 vertical_border = [qz_value] * border * scale
390 same_as_above = b''
391 if scale > 1:
392 # 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)
394 miter = (chain(*(repeat(b, scale) for b in row)) for row in miter)
395 idat = bytearray(horizontal_border)
396 for row in miter:
397 # Chain precalculated left border with row and right border
398 idat += scanline(chain(vertical_border, row, vertical_border))
399 idat += same_as_above # This is b'' if no scaling factor was provided
400 idat += horizontal_border
401 with writable(out, 'wb') as f:
402 write = f.write
403 write(b'\211PNG\r\n\032\n') # Magic number
404 # Header:
405 # 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)))
407 if dpi:
408 write(chunk(b'pHYs', pack(b'>LLB', dpi, dpi, 1)))
409 if not is_greyscale:
410 write(chunk(b'PLTE', b''.join(pack(b'>3B', *clr[:3]) for clr in palette)))
411 # <https://www.w3.org/TR/PNG/#11tRNS>
412 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)))
414 elif is_transparent:
415 write(chunk(b'tRNS', pack(b'>B', png_trans_idx)))
416 elif is_transparent:
417 write(chunk(b'tRNS', pack(b'>1H', png_trans_idx)))
418 write(chunk(b'IDAT', zlib.compress(idat, compresslevel)))
419 write(chunk(b'IEND', b''))
420
421
422def write_pdf(matrix, matrix_size, out, scale=1, border=None, dark='#000',
423 light=None, compresslevel=9):
424
425 def write_string(writemeth, s):
426 writemeth(s.encode('ascii'))
427
428 def to_pdf_color(clr):
429 def to_float(c):
430 if isinstance(c, float):
431 if not 0.0 <= c <= 1.0:
432 raise ValueError(f'Invalid color "{c}". Not in range 0 .. 1')
433 return c
434 return 1 / 255.0 * c if c != 1 else c
435 return tuple([to_float(i) for i in _color_to_rgb(clr)])
436
437 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
438 creation_date = f"{time.strftime('%Y%m%d%H%M%S')}{(time.timezone // 3600):+03d}'{(abs(time.timezone) % 60):02d}'"
439 cmds = []
440 append_cmd = cmds.append
441 if light is not None:
442 # 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')
446 # Set the stroke color only iff it is not black (default)
447 if not _color_is_black(dark):
448 append_cmd('{} {} {} RG'.format(*to_pdf_color(dark)))
449 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
452 # Set the origin in the upper left corner
453 append_cmd(f'1 0 0 1 {border} {y} cm')
454 miter = matrix_to_lines(matrix, 0, 0, incby=-1)
455 # 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:
460 write = f.write
461 writestr = partial(write_string, write)
462 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'):
468 object_pos.append(f.tell())
469 writestr(f'{len(object_pos)} 0 {obj}')
470 write(graphic)
471 write(b'\r\nendstream\r\nendobj\r\n')
472 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')
475 object_pos.append(f.tell())
476 writestr(f'xref\r\n0 {len(object_pos)}\r\n0000000000 65535 f\r\n')
477 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')
480 xref_location = object_pos[-1]
481 writestr(f'startxref\r\n{xref_location}\r\n%%EOF\r\n')
482
483
484def write_txt(matrix, matrix_size, out, border=None, dark='1', light='0'):
485 row_iter = matrix_iter(matrix, matrix_size, scale=1, border=border)
486 colours = (str(light), str(dark))
487 with writable(out, 'wt') as f:
488 write = f.write
489 for row in row_iter:
490 write(''.join(colours[i] for i in row))
491 write('\n')
492
493
494def write_pbm(matrix, matrix_size, out, scale=1, border=None, plain=False):
495 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))
498
499 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
500 row_iter = matrix_iter(matrix, matrix_size, scale, border)
501 with writable(out, 'wb') as f:
502 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'))
506 if not plain:
507 for row in row_iter:
508 write(bytearray(pack_row(row)))
509 else:
510 for row in row_iter:
511 write(b''.join(str(i).encode('ascii') for i in row))
512 write(b'\n')
513
514
515def write_pam(matrix, matrix_size, out, scale=1, border=None, dark='#000', light='#fff'):
516 def invert_row_bits(row):
517 return bytearray([b ^ 0x1 for b in row])
518
519 def row_to_color_values(row, colours):
520 return b''.join(colours[b] for b in row)
521
522 if not dark:
523 raise ValueError(f'Invalid stroke color "{dark}"')
524 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
525 row_iter = matrix_iter(matrix, matrix_size, scale, border)
526 depth, maxval, tuple_type = 1, 1, 'BLACKANDWHITE'
527 transparency = False
528 stroke_color = _color_to_rgb_or_rgba(dark, alpha_float=False)
529 bg_color = _color_to_rgb_or_rgba(light, alpha_float=False) if light is not None else None
530 colored_stroke = not (_color_is_black(stroke_color) or _color_is_white(stroke_color))
531 if bg_color is None:
532 tuple_type = 'GRAYSCALE_ALPHA' if not colored_stroke else 'RGB_ALPHA'
533 transparency = True
534 bg_color = _invert_color(stroke_color[:3])
535 bg_color += (0,)
536 if len(stroke_color) != 4:
537 stroke_color += (255,)
538 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')
541 colours = None
542 if not is_rgb and transparency:
543 depth = 2
544 colours = (b'\x01\x00', b'\x00\x01')
545 elif is_rgb:
546 maxval = max(chain(stroke_color, bg_color))
547 depth = 3 if not transparency else 4
548 fmt = f'>{depth}B'.encode('ascii')
549 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:
552 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'))
561 for row in row_iter:
562 write(row_filter(row))
563
564
565@colorful(dark='#000', light='#fff')
566def write_ppm(matrix, matrix_size, out, colormap, scale=1, border=None):
567 scale = int(scale)
568 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
569 if None in colormap.values():
570 raise ValueError('Transparency is not supported')
571 for mt, clr in colormap.items():
572 colormap[mt] = _color_to_rgb(clr)
573 row_iter = matrix_iter_verbose(matrix, matrix_size, scale, border)
574 with writable(out, 'wb') as f:
575 write = f.write
576 write(f'P6 # Created by {CREATOR}\n{width} {height} 255\n'.encode('ascii'))
577 for row in row_iter:
578 write(b''.join(pack(b'>3B', *colormap[mt]) for mt in row))
579
580
581def write_xpm(matrix, matrix_size, out, scale=1, border=None, dark='#000',
582 light='#fff', name='img'):
583 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
584 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:
588 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')
594 for i, row in enumerate(row_iter):
595 write(''.join(chain(['"'], (" " if not b else "X" for b in row),
596 [f'"{("," if i < height - 1 else "")}\n'])))
597 write('};\n')
598
599
600def write_xbm(matrix, matrix_size, out, scale=1, border=None, name='img'):
601 width, height, border = _valid_width_height_and_border(matrix_size, scale, border)
602 row_iter = matrix_iter(matrix, matrix_size, scale, border)
603 with writable(out, 'wt') as f:
604 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')
608 for i, row in enumerate(row_iter, start=1):
609 iter_ = zip_longest(*[iter(row)] * 8, fillvalue=0x0)
610 # 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')
616
617
618def write_tex(matrix, matrix_size, out, scale=1, border=None, dark='black', unit='pt', url=None):
619 def point(x, y):
620 return f'\\pgfqpoint{{{x}{unit}}}{{{y}{unit}}}'
621
622 check_valid_scale(scale)
623 check_valid_border(border)
624 border = get_border(matrix_size, border)
625 end_marker = ''
626 with writable(out, 'wt') as f:
627 write = f.write
628 write(f'% Creator: {CREATOR}\n')
629 write(f'% Date: {time.strftime("%Y-%m-%dT%H:%M:%S")}\n')
630 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')
637 x, y = border, -border
638 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')
643
644
645def write_terminal(matrix, matrix_size, out, border=None):
646 with writable(out, 'wt') as f:
647 write = f.write
648 colours = [f'\033[{i}m' for i in (7, 49)]
649 for row in matrix_iter(matrix, matrix_size, scale=1, border=border):
650 prev_bit = -1
651 cnt = 0
652 for bit in row:
653 if bit == prev_bit:
654 cnt += 1
655 else:
656 if cnt:
657 write(colours[prev_bit])
658 write(' ' * cnt)
659 write('\033[0m') # reset color
660 prev_bit = bit
661 cnt = 1
662 if cnt:
663 write(colours[prev_bit])
664 write(' ' * cnt)
665 write('\033[0m') # reset color
666 write('\n')
667
668
669def write_terminal_win(matrix, matrix_size, border=None): # pragma: no cover
670 import sys
671 import struct
672 import ctypes
673 write = sys.stdout.write
674 std_out = ctypes.windll.kernel32.GetStdHandle(-11)
675 csbi = ctypes.create_string_buffer(22)
676 res = ctypes.windll.kernel32.GetConsoleScreenBufferInfo(std_out, csbi)
677 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]
681 set_color = partial(ctypes.windll.kernel32.SetConsoleTextAttribute, std_out)
682 colours = (240, default_color)
683 for row in matrix_iter(matrix, matrix_size, scale=1, border=border):
684 prev_bit = -1
685 cnt = 0
686 for bit in row:
687 if bit == prev_bit:
688 cnt += 1
689 else:
690 if cnt:
691 set_color(colours[prev_bit])
692 write(' ' * cnt)
693 prev_bit = bit
694 cnt = 1
695 if cnt:
696 set_color(colours[prev_bit])
697 write(' ' * cnt)
698 set_color(default_color) # reset color
699 write('\n')
700
701
702def 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 }
708 it = [matrix_iter(matrix, matrix_size, scale=1, border=border)] * 2
709 with writable(out, 'wt') as f:
710 write = f.write
711 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')
714
715
716def _color_to_rgb_or_rgba(color, alpha_float=True):
717 rgba = _color_to_rgba(color, alpha_float=alpha_float)
718 if rgba[3] in (1.0, 255):
719 return rgba[:3]
720 return rgba
721
722
723def _color_to_webcolor(color, allow_css3_colors=True, optimize=True):
724 if _color_is_black(color):
725 return '#000'
726 elif _color_is_white(color):
727 return '#fff'
728 clr = _color_to_rgb_or_rgba(color)
729 alpha_channel = None
730 if len(clr) == 4:
731 if allow_css3_colors:
732 return 'rgba({0},{1},{2},{3})'.format(*clr) # noqa UP030
733 alpha_channel = clr[3]
734 clr = clr[:3]
735 hx = '#{0:02x}{1:02x}{2:02x}'.format(*clr) # noqa UP030
736 if optimize:
737 if hx == '#d2b48c':
738 hx = 'tan' # shorter
739 elif hx == '#ff0000':
740 hx = 'red' # shorter
741 elif hx[1] == hx[2] and hx[3] == hx[4] and hx[5] == hx[6]:
742 hx = f'#{hx[1]}{hx[3]}{hx[5]}'
743 return hx if alpha_channel is None else (hx, alpha_channel)
744
745
746def color_to_rgb_hex(color):
747 return '#{0:02x}{1:02x}{2:02x}'.format(*_color_to_rgb(color)) # noqa UP030
748
749
750def _color_is_black(color):
751 try:
752 color = color.lower()
753 except AttributeError:
754 pass
755 return color in ('#000', '#000000', 'black', (0, 0, 0), (0, 0, 0, 255),
756 (0, 0, 0, 1.0))
757
758
759def _color_is_white(color):
760 try:
761 color = color.lower()
762 except AttributeError:
763 pass
764 return color in ('#fff', '#ffffff', 'white', (255, 255, 255),
765 (255, 255, 255, 255), (255, 255, 255, 1.0))
766
767
768def _color_to_rgb(color):
769 rgb = _color_to_rgb_or_rgba(color)
770 if len(rgb) != 3:
771 raise ValueError(f'The alpha channel {rgb[3]} in color "{color}" cannot be '
772 'converted to RGB')
773 return rgb
774
775
776def _color_to_rgba(color, alpha_float=True):
777 res = []
778 alpha_channel = (1.0,) if alpha_float else (255,)
779 if isinstance(color, tuple):
780 col_length = len(color)
781 is_valid = False
782 if 3 <= col_length <= 4:
783 for i, part in enumerate(color[:3]):
784 is_valid = 0 <= part <= 255
785 res.append(part)
786 if not is_valid or i == 2:
787 break
788 if is_valid:
789 if col_length == 4:
790 res.append(_alpha_value(color[3], alpha_float))
791 else:
792 res.append(alpha_channel[0])
793 if is_valid:
794 return tuple(res)
795 raise ValueError(f'Unsupported color "{color}"')
796 try:
797 return _NAME2RGB[color.lower()] + alpha_channel
798 except KeyError:
799 try:
800 clr = _hex_to_rgb_or_rgba(color, alpha_float=alpha_float)
801 if len(clr) == 4:
802 return clr
803 else:
804 return clr + alpha_channel
805 except ValueError:
806 raise ValueError(f'Unsupported color "{color}". Neither a known web '
807 'color name nor a color in hexadecimal format.')
808
809
810def _hex_to_rgb_or_rgba(color, alpha_float=True):
811 if color[0] == '#':
812 color = color[1:]
813 if 2 < len(color) < 5:
814 # Expand RGB -> RRGGBB and RGBA -> RRGGBBAA
815 color = ''.join([color[i] * 2 for i in range(len(color))])
816 color_len = len(color)
817 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)])
820 if alpha_float and color_len == 8:
821 res = res[:3] + (_alpha_value(res[3], alpha_float),)
822 return res
823
824
825_ALPHA_COMMONS = {255: 1.0, 128: .5, 64: .25, 32: .125, 16: .625, 0: 0.0}
826
827
828def _alpha_value(color, alpha_float):
829 if alpha_float:
830 if not isinstance(color, float):
831 if 0 <= color <= 255:
832 return _ALPHA_COMMONS.get(color, float('%.02f' % (color / 255.0)))
833 else:
834 if 0 <= color <= 1.0:
835 return color
836 else:
837 if not isinstance(color, float):
838 if 0 <= color <= 255:
839 return color
840 else:
841 if 0 <= color <= 1.0:
842 return color * 255.0
843 raise ValueError(f'Invalid alpha channel value: {color}')
844
845
846def _invert_color(rgb_or_rgba):
847 return tuple([255 - c for c in rgb_or_rgba])
848
849
850# <https://www.w3.org/TR/css-color-3/#svg-color>
851_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),
999}
1000
1001
1002def _make_colormap(matrix_width, matrix_height, dark, light,
1003 finder_dark=False, finder_light=False,
1004 data_dark=False, data_light=False,
1005 version_dark=False, version_light=False,
1006 format_dark=False, format_light=False,
1007 alignment_dark=False, alignment_light=False,
1008 timing_dark=False, timing_light=False,
1009 separator=False, dark_module=False,
1010 quiet_zone=False):
1011 unsupported = ()
1012 is_square = matrix_width == matrix_height
1013 if not is_square: # rMQR
1014 unsupported = [consts.TYPE_DARKMODULE, consts.TYPE_VERSION_DARK, consts.TYPE_VERSION_LIGHT]
1015 if matrix_width < 43: # rMQR R11x27, R13x27, …
1016 unsupported.extend((consts.TYPE_ALIGNMENT_PATTERN_DARK, consts.TYPE_ALIGNMENT_PATTERN_LIGHT))
1017 elif matrix_width < 45: # QR Code version 7
1018 unsupported = [consts.TYPE_VERSION_DARK, consts.TYPE_VERSION_LIGHT]
1019 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])
1023 mt2color = {
1024 consts.TYPE_FINDER_PATTERN_DARK: finder_dark if finder_dark is not False else dark,
1025 consts.TYPE_FINDER_PATTERN_LIGHT: finder_light if finder_light is not False else light,
1026 consts.TYPE_DATA_DARK: data_dark if data_dark is not False else dark,
1027 consts.TYPE_DATA_LIGHT: data_light if data_light is not False else light,
1028 consts.TYPE_VERSION_DARK: version_dark if version_dark is not False else dark,
1029 consts.TYPE_VERSION_LIGHT: version_light if version_light is not False else light,
1030 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,
1032 consts.TYPE_TIMING_DARK: timing_dark if timing_dark is not False else dark,
1033 consts.TYPE_TIMING_LIGHT: timing_light if timing_light is not False else light,
1034 consts.TYPE_FORMAT_DARK: format_dark if format_dark is not False else dark,
1035 consts.TYPE_FORMAT_LIGHT: format_light if format_light is not False else light,
1036 consts.TYPE_SEPARATOR: separator if separator is not False else light,
1037 consts.TYPE_DARKMODULE: dark_module if dark_module is not False else dark,
1038 consts.TYPE_QUIET_ZONE: quiet_zone if quiet_zone is not False else light,
1039 }
1040 return {mt: val for mt, val in mt2color.items() if mt not in unsupported}
1041
1042
1043_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,
1056}
1057
1058
1059def save(matrix, matrix_size, out, kind=None, **kw):
1060 is_stream = False
1061 if kind is None:
1062 try:
1063 fname = out.name
1064 is_stream = True
1065 except AttributeError:
1066 fname = out
1067 ext = fname[fname.rfind('.') + 1:].lower()
1068 else:
1069 ext = kind.lower()
1070 is_svgz = not is_stream and ext == 'svgz'
1071 try:
1072 serializer = _VALID_SERIALIZERS[ext if not is_svgz else 'svg']
1073 except KeyError:
1074 raise ValueError(f'Unknown file extension ".{ext}"')
1075 if is_svgz:
1076 with gzip.open(out, 'wb', compresslevel=kw.pop('compresslevel', 9)) as f:
1077 serializer(matrix, matrix_size, f, **kw)
1078 else:
1079 serializer(matrix, matrix_size, out, **kw)
plugin.video.jacktook/lib/vendor/torf/__init__.py +27 -0
……@@ -0,0 +1,27 @@
1# This file is part of torf.
2#
3# torf is free software: you can redistribute it and/or modify it under the
4# terms of the GNU General Public License as published by the Free Software
5# Foundation, either version 3 of the License, or (at your option) any later
6# version.
7#
8# torf is distributed in the hope that it will be useful, but WITHOUT ANY
9# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
10# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
11#
12# You should have received a copy of the GNU General Public License
13# along with torf. If not, see <https://www.gnu.org/licenses/>.
14
15# flake8: noqa
16
17"""
18Create and parse torrent files and magnet URIs
19"""
20
21__version__ = '4.2.0'
22
23from ._errors import *
24from ._magnet import Magnet
25from ._stream import TorrentFileStream
26from ._torrent import Torrent
27from ._utils import File, Filepath
plugin.video.jacktook/lib/vendor/torf/_errors.py +319 -0
……@@ -0,0 +1,319 @@
1# This file is part of torf.
2#
3# torf is free software: you can redistribute it and/or modify it under the
4# terms of the GNU General Public License as published by the Free Software
5# Foundation, either version 3 of the License, or (at your option) any later
6# version.
7#
8# torf is distributed in the hope that it will be useful, but WITHOUT ANY
9# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
10# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
11#
12# You should have received a copy of the GNU General Public License
13# along with torf. If not, see <https://www.gnu.org/licenses/>.
14
15import os
16
17
18class TorfError(Exception):
19 """Base exception for all exceptions raised by torf"""
20 def __init__(self, msg, *posargs, **kwargs):
21 super().__init__(msg)
22 self.posargs = posargs
23 self.kwargs = kwargs
24
25
26class URLError(TorfError):
27 """Invalid URL"""
28 def __init__(self, url):
29 self._url = url
30 super().__init__(f'{url}: Invalid URL', url)
31
32 @property
33 def url(self):
34 """The invalid URL"""
35 return self._url
36
37
38class PieceSizeError(TorfError):
39 """Invalid piece size"""
40 def __init__(self, size, min=None, max=None):
41 self._size = size
42 self._min = min
43 self._max = max
44 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)
47 else:
48 super().__init__(f'Piece size must be divisible by 16 KiB: {size}',
49 size)
50
51 @property
52 def size(self):
53 """The invalid piece size"""
54 return self._size
55
56 @property
57 def min(self):
58 """Smallest allowed piece size or ``None``"""
59 return self._min
60
61 @property
62 def max(self):
63 """Largest allowed piece size or ``None``"""
64 return self._max
65
66
67class MetainfoError(TorfError):
68 """Invalid torrent metainfo"""
69 def __init__(self, msg):
70 super().__init__(f'Invalid metainfo: {msg}', msg)
71
72
73class BdecodeError(TorfError):
74 """Failed to decode bencoded byte sequence"""
75 def __init__(self, filepath=None):
76 self._filepath = filepath
77 if filepath is None:
78 super().__init__('Invalid metainfo format')
79 else:
80 super().__init__(f'{filepath}: Invalid torrent file format', filepath)
81
82 @property
83 def filepath(self):
84 """Path of the offending torrent file or ``None``"""
85 return self._filepath
86
87
88class MagnetError(TorfError):
89 """Invalid magnet URI or value"""
90 def __init__(self, uri, reason=None):
91 self._uri = uri
92 self._reason = reason
93 if reason is not None:
94 super().__init__(f'{uri}: {reason}', uri, reason=reason)
95 else:
96 super().__init__(f'{uri}: Invalid magnet URI', uri)
97
98 @property
99 def uri(self):
100 """The invalid URI"""
101 return self._uri
102
103 @property
104 def reason(self):
105 """Why URI is invalid"""
106 return self._reason
107
108
109class PathError(TorfError):
110 """General invalid or unexpected path"""
111 def __init__(self, path, msg):
112 self._path = path
113 super().__init__(f'{path}: {msg}', path, msg)
114
115 @property
116 def path(self):
117 """Path of the offending file or directory"""
118 return self._path
119
120
121class CommonPathError(TorfError):
122 """Files don't share parent directory"""
123 def __init__(self, filepaths):
124 self._filepaths = filepaths
125 filepaths_str = ', '.join(str(fp) for fp in filepaths)
126 super().__init__(f'No common parent path: {filepaths_str}', filepaths)
127
128 @property
129 def filepaths(self):
130 """Paths to offending files"""
131 return self._filepaths
132
133
134class VerifyIsDirectoryError(TorfError):
135 """Expected file but found directory"""
136 def __init__(self, path):
137 self._path = path
138 super().__init__(f'{path}: Is a directory', path)
139
140 @property
141 def path(self):
142 """Path of the offending directory"""
143 return self._path
144
145
146class VerifyNotDirectoryError(TorfError):
147 """Expected (link to) directory, but found something else"""
148 def __init__(self, path):
149 self._path = path
150 super().__init__(f'{path}: Not a directory', path)
151
152 @property
153 def path(self):
154 """Path of the offending non-directory"""
155 return self._path
156
157
158class VerifyFileSizeError(TorfError):
159 """Unexpected file size"""
160 def __init__(self, filepath, actual_size, expected_size):
161 self._filepath = filepath
162 self._actual_size = actual_size
163 self._expected_size = expected_size
164 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)
167 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)
170 else:
171 raise RuntimeError(f'Unjustified: actual_size={actual_size} == expected_size={expected_size}')
172
173 @property
174 def filepath(self):
175 """Path of the offending file"""
176 return self._filepath
177
178 @property
179 def actual_size(self):
180 """Size as reported by the file system"""
181 return self._actual_size
182
183 @property
184 def expected_size(self):
185 """Size as specified in the metainfo"""
186 return self._expected_size
187
188
189class VerifyContentError(TorfError):
190 """On-disk data does not match hashes in metainfo"""
191 def __init__(self, filepath, piece_index, piece_size, file_sizes):
192 self._filepath = filepath
193 self._piece_index = piece_index
194 self._piece_size = piece_size
195 msg = f'Corruption in piece {piece_index+1}'
196
197 if len(file_sizes) < 1:
198 raise RuntimeError('file_sizes argument is empty: {file_sizes!r}')
199 elif len(file_sizes) == 1:
200 corrupt_files = (file_sizes[0][0],)
201 else:
202 corrupt_files = []
203
204 # Find the slice in the whole stream of files that contains the
205 # corruption (piece_index=0 is the first piece)
206 err_i_beg = piece_index * piece_size
207 err_i_end = err_i_beg + piece_size
208
209 # Find the files that are covered by the corrupt piece
210 cur_pos = 0
211 for filepath,filesize in file_sizes:
212 # `file` is possibly corrupt if:
213 # 1. The corrupt piece STARTS between the beginning and the end
214 # of the file in the stream.
215 # 2. The corrupt piece ENDS between the beginning and the end
216 # of the file in the stream.
217 # 3. Both beginning and end of the file are between beginning
218 # and end of the corrupt piece (i.e. file fits in one piece).
219 file_i_beg = cur_pos
220 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)):
224 corrupt_files.append(filepath)
225 cur_pos += filesize
226
227 if len(corrupt_files) == 1:
228 msg += f' in {corrupt_files[0]}'
229 else:
230 msg += (', at least one of these files is corrupt: ' +
231 ', '.join(str(f) for f in corrupt_files))
232
233 self._files = tuple(corrupt_files)
234 super().__init__(msg, filepath, piece_index, piece_size, file_sizes)
235
236 @property
237 def filepath(self):
238 """Path to file that caused the piece corruption"""
239 return self._filepath
240
241 @property
242 def piece_index(self):
243 """Index of the corrupt piece in the stream of concatenated files"""
244 return self._piece_index
245
246 @property
247 def piece_size(self):
248 """Size of the corrupt piece in bytes"""
249 return self._piece_size
250
251 @property
252 def files(self):
253 """Potentially corrupt neighboring files"""
254 return self._files
255
256
257class ReadError(TorfError):
258 """Unreadable file or stream"""
259 def __init__(self, errno, path=None):
260 self._errno = errno
261 self._path = path
262 msg = os.strerror(errno) if errno else 'Unable to read'
263 if path is None:
264 super().__init__(f'{msg}', errno)
265 else:
266 super().__init__(f'{path}: {msg}', errno, path)
267
268 @property
269 def path(self):
270 """Path of the offending file or ``None``"""
271 return self._path
272
273 @property
274 def errno(self):
275 """POSIX error number from errno.h"""
276 return self._errno
277
278
279class MemoryError(TorfError, MemoryError):
280 """
281 Out of memory
282
283 See also :class:`MemoryError`.
284 """
285
286
287class WriteError(TorfError):
288 """Unwritable file or stream"""
289 def __init__(self, errno, path=None):
290 self._errno = errno
291 self._path = path
292 msg = os.strerror(errno) if errno else 'Unable to write'
293 if path is None:
294 super().__init__(f'{msg}', path)
295 else:
296 super().__init__(f'{path}: {msg}', errno, path)
297
298 @property
299 def path(self):
300 """Path of the offending file or ``None``"""
301 return self._path
302
303 @property
304 def errno(self):
305 """POSIX error number from errno.h"""
306 return self._errno
307
308
309class ConnectionError(TorfError):
310 """Unwritable file or stream"""
311 def __init__(self, url, msg='Failed'):
312 self._url = url
313 self._msg = str(msg)
314 super().__init__(f'{url}: {msg}', url, msg)
315
316 @property
317 def url(self):
318 """URL that caused the exception"""
319 return self._url
plugin.video.jacktook/lib/vendor/torf/_generate.py +539 -0
……@@ -0,0 +1,539 @@
1# This file is part of torf.
2#
3# torf is free software: you can redistribute it and/or modify it under the
4# terms of the GNU General Public License as published by the Free Software
5# Foundation, either version 3 of the License, or (at your option) any later
6# version.
7#
8# torf is distributed in the hope that it will be useful, but WITHOUT ANY
9# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
10# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
11#
12# You should have received a copy of the GNU General Public License
13# along with torf. If not, see <https://www.gnu.org/licenses/>.
14
15import errno
16import logging
17import os
18import queue
19import threading
20from hashlib import sha1
21from time import monotonic as time_monotonic
22
23from . import _errors as errors
24from ._stream import TorrentFileStream
25
26QUEUE_CLOSED = object()
27
28_debug = logging.getLogger('torf').debug
29
30def _thread_name():
31 return threading.current_thread().name
32
33def _pretty_bytes(b):
34 if isinstance(b, (bytes, bytearray)) and len(b) > 8:
35 # return b[:8].hex() + '...' + b[-8:].hex()
36 return b[:8] + b'...' + b[-8:]
37 else:
38 return b
39
40
41class Worker:
42 """
43 :class:`threading.Thread` subclass that re-raises any exceptions from the
44 thread when joined
45 """
46
47 def __init__(self, name, worker, start=True, fail_ok=False):
48 self._exception = None
49 self._name = str(name)
50 self._worker = worker
51 self._thread = threading.Thread(name=self._name, target=self._run_and_catch_exceptions)
52 if start:
53 self.start(fail_ok=fail_ok)
54
55 @property
56 def exception(self):
57 return self._exception
58
59 @property
60 def name(self):
61 return self._name
62
63 @property
64 def is_running(self):
65 return self._thread.is_alive()
66
67 def _run_and_catch_exceptions(self):
68 try:
69 self._worker()
70 except BaseException as e:
71 self._exception = e
72
73 def start(self, fail_ok=False):
74 if not self._thread.is_alive():
75 try:
76 self._thread.start()
77 except RuntimeError as e:
78 if fail_ok:
79 _debug(f'{self.name}: Failed to start thread: {e!r} - but that\'s ok')
80 else:
81 _debug(f'{self.name}: Failed to start thread: {e!r}')
82 raise
83 else:
84 _debug(f'{self.name}: Started')
85
86 def join(self, *args, **kwargs):
87 if self.is_running:
88 self._thread.join(*args, **kwargs)
89 if self._exception:
90 raise self._exception
91
92
93class Reader(Worker):
94 """
95 :class:`Worker` subclass that reads files in pieces and pushes them to a
96 queue
97 """
98
99 def __init__(self, *, torrent, queue_size, path=None):
100 self._torrent = torrent
101 self._path = path
102 self._piece_queue = queue.Queue(maxsize=queue_size)
103 self._stop = False
104 self._memory_error_timestamp = -1
105 super().__init__(name='reader', worker=self._push_pieces)
106
107 def _push_pieces(self):
108 stream = TorrentFileStream(self._torrent)
109 try:
110 iter_pieces = stream.iter_pieces(self._path, oom_callback=self._handle_oom)
111 for piece_index, (piece, filepath, exceptions) in enumerate(iter_pieces):
112 # _debug(f'{_thread_name()}: Read #{piece_index}')
113 if self._stop:
114 _debug(f'{_thread_name()}: Stopped reading')
115 break
116 elif exceptions:
117 self._push_piece(piece_index=piece_index, filepath=filepath, exceptions=exceptions)
118 elif piece:
119 self._push_piece(piece_index=piece_index, filepath=filepath, piece=piece)
120 else:
121 # `piece` is None because of missing file, and the exception
122 # was already sent for the first `piece_index` of that file
123 self._push_piece(piece_index=piece_index, filepath=filepath)
124
125 # _debug(f'{_thread_name()}: {self._piece_queue.qsize()} pieces queued')
126
127 except BaseException as e:
128 _debug(f'{_thread_name()}: Exception while reading: {e!r}')
129 raise
130
131 finally:
132 self._piece_queue.put(QUEUE_CLOSED)
133 _debug(f'{_thread_name()}: Piece queue is now exhausted')
134 stream.close()
135
136 def _push_piece(self, *, piece_index, filepath, piece=None, exceptions=()):
137 # _debug(f'{_thread_name()}: Pushing #{piece_index}: {filepath}: {_pretty_bytes(piece)}, {exceptions!r}')
138 self._piece_queue.put((piece_index, filepath, piece, exceptions))
139
140 def _handle_oom(self, exception):
141 # Reduce piece_queue.maxsize by 1 every 100ms until the MemoryErrors stop
142 now = time_monotonic()
143 time_diff = now - self._memory_error_timestamp
144 if time_diff >= 0.1:
145 old_maxsize = self._piece_queue.maxsize
146 new_maxsize = max(1, int(old_maxsize * 0.9))
147 if new_maxsize != old_maxsize:
148 _debug(f'{_thread_name()}: Reducing piece_queue.maxsize to {new_maxsize}')
149 self._piece_queue.maxsize = new_maxsize
150 self._memory_error_timestamp = now
151 else:
152 raise errors.ReadError(errno.ENOMEM, exception)
153
154 def stop(self):
155 """Stop reading and close the piece queue"""
156 if not self._stop:
157 _debug(f'{_thread_name()}: {type(self).__name__}: Setting stop flag')
158 self._stop = True
159
160 @property
161 def piece_queue(self):
162 """
163 :class:`queue.Queue` instance that gets evenly sized pieces from the
164 concatenated stream of files
165 """
166 return self._piece_queue
167
168
169class HasherPool:
170 """
171 Wrapper around one or more :class:`Worker` instances that each read a piece
172 from :attr:`Reader.piece_queue`, feed it to :func:`~.hashlib.sha1`, and push
173 the resulting hash to :attr:`hash_queue`
174 """
175
176 def __init__(self, hasher_threads, piece_queue):
177 self._piece_queue = piece_queue
178 self._hash_queue = queue.Queue()
179 self._finalize_event = threading.Event()
180
181 # Janitor takes care of closing the hash queue, removing idle hashers, etc
182 self._janitor = Worker(
183 name='janitor',
184 worker=self._janitor_thread,
185 start=False,
186 )
187
188 # Hashers read from piece_queue and push to hash_queue
189 self._hashers = [
190 Worker(
191 name='hasher1',
192 # One hasher is vital an may not die from boredom
193 worker=lambda: self._hasher_thread(is_vital=True),
194 start=False,
195 ),
196 ]
197 for i in range(2, hasher_threads + 1):
198 self._hashers.append(
199 Worker(
200 name=f'hasher{i}',
201 # All other hashers should die if they are bored
202 worker=lambda: self._hasher_thread(is_vital=False),
203 start=False,
204 )
205 )
206
207 # Start threads manually after they were created to prevent race
208 # conditions and make sure all required threads are running
209 self._janitor.start(fail_ok=False)
210
211 # Hashers are allowed to fail (e.g. because of OS limits), but we need
212 # at least one to start successfully
213 self._hashers[0].start(fail_ok=False)
214 for hasher in self._hashers[1:]:
215 hasher.start(fail_ok=True)
216
217 def _hasher_thread(self, is_vital=True):
218 piece_queue = self._piece_queue
219 handle_piece = self._handle_piece
220 while True:
221 # _debug(f'{_thread_name()}: Waiting for next task')
222 try:
223 task = piece_queue.get(timeout=0.5)
224 except queue.Empty:
225 if not is_vital:
226 _debug(f'{_thread_name()}: I am bored, byeee!')
227 break
228 else:
229 _debug(f'{_thread_name()}: I am bored, but needed.')
230 else:
231 if task is QUEUE_CLOSED:
232 _debug(f'{_thread_name()}: piece_queue is closed')
233 # Repeat QUEUE_CLOSED to the next sibling. This ensures
234 # there is always one more QUEUE_CLOSED queued than running
235 # threads. Otherwise, one thread might consume multiple
236 # QUEUE_CLOSED and leave other threads running forvever.
237 piece_queue.put(QUEUE_CLOSED)
238 # Signal janitor to initiate shutdown procedure
239 self._finalize_event.set()
240 break
241 else:
242 handle_piece(*task)
243
244 def _handle_piece(self, piece_index, filepath, piece, exceptions):
245 if exceptions:
246 # _debug(f'{_thread_name()}: Forwarding exceptions for #{piece_index}: {exceptions!r}')
247 self._hash_queue.put((piece_index, filepath, None, exceptions))
248
249 elif piece:
250 piece_hash = sha1(piece).digest()
251 # _debug(f'{_thread_name()}: Hashed #{piece_index}: {_pretty_bytes(piece)} [{len(piece)} bytes] -> {piece_hash}')
252 self._hash_queue.put((piece_index, filepath, piece_hash, ()))
253
254 else:
255 # _debug(f'{_thread_name()}: Nothing to hash for #{piece_index}: {piece!r}')
256 self._hash_queue.put((piece_index, filepath, None, ()))
257
258 def _janitor_thread(self):
259 while True:
260 _debug(f'{_thread_name()}: Waiting for finalize event')
261 finalization_initiated = self._finalize_event.wait(timeout=1.0)
262 if finalization_initiated:
263 self._wait_for_hashers()
264 _debug(f'{_thread_name()}: Closing hash queue')
265 self._hash_queue.put(QUEUE_CLOSED)
266 break
267
268 else:
269 # Remove terminated idle hashers
270 for hasher in tuple(self._hashers):
271 if not hasher.is_running:
272 _debug(f'{_thread_name()}: Pruning {hasher.name}')
273 self._hashers.remove(hasher)
274
275 _debug(f'{_thread_name()}: Terminating')
276
277 def _wait_for_hashers(self):
278 while True:
279 # _debug(f'{_thread_name()}: Hashers running: {[h.name for h in self._hashers if h.is_running]}')
280 if all(not h.is_running for h in self._hashers):
281 _debug(f'{_thread_name()}: All hashers terminated')
282 break
283
284 def join(self):
285 """Block until all threads have terminated"""
286 for hasher in self._hashers:
287 _debug(f'{_thread_name()}: Joining {hasher.name}')
288 hasher.join()
289 _debug(f'{_thread_name()}: Joined all hashers')
290
291 _debug(f'{_thread_name()}: Joining {self._janitor.name}')
292 self._janitor.join()
293 _debug(f'{_thread_name()}: Joined {self._janitor.name}')
294
295 @property
296 def hash_queue(self):
297 """:class:`queue.Queue` instance that gets piece hashes"""
298 return self._hash_queue
299
300
301class Collector:
302 """
303 Consume items from :attr:`HasherPool.hash_queue` and ensure proper
304 termination of all threads if anything goes wrong or the user cancels the
305 operation
306 """
307
308 def __init__(self, torrent, reader, hashers, callback=None):
309 self._reader = reader
310 self._hashers = hashers
311 self._callback = callback
312 self._hashes_unsorted = []
313 self._pieces_seen = []
314 self._pieces_total = torrent.pieces
315
316 def collect(self):
317 """
318 Read piece hashes from :attr:`HasherPool.hash_queue`
319
320 When this method returns, :attr:`hashes` is an ordered sequence of
321 collected piece hashes.
322
323 Exceptions from :class:`Reader`, :class:`HasherPool` or the provided
324 callback are raised after all threads are terminated and joined.
325
326 :return: the same value as :attr:`hashes`
327 """
328 try:
329 hash_queue = self._hashers.hash_queue
330 while True:
331 # _debug(f'{_thread_name()}: Waiting for next piece hash')
332 task = hash_queue.get()
333 # _debug(f'{_thread_name()}: Got task: {task}')
334 if task is QUEUE_CLOSED:
335 break
336 else:
337 self._collect(*task)
338
339 except BaseException as e:
340 _debug(f'{_thread_name()}: Exception while dequeueing piece hashes: {e!r}')
341 self._cancel()
342 raise
343
344 finally:
345 self._finalize()
346
347 return self.hashes
348
349 def _collect(self, piece_index, filepath, piece_hash, exceptions):
350 # _debug(f'{_thread_name()}: Collecting #{piece_index}: {_pretty_bytes(piece_hash)}, {exceptions}')
351
352 # Remember which pieces where hashed to count them and for sanity checking
353 assert piece_index not in self._pieces_seen
354 self._pieces_seen.append(piece_index)
355
356 # Collect piece
357 if not exceptions and piece_hash:
358 self._hashes_unsorted.append((piece_index, piece_hash))
359
360 # If there is no callback, raise first exception
361 if exceptions and not self._callback:
362 raise exceptions[0]
363
364 # Report progress/exceptions and allow callback to cancel
365 if self._callback:
366 # _debug(f'{_thread_name()}: Collector callback: {self._callback}')
367 maybe_cancel = self._callback(
368 piece_index, len(self._pieces_seen), self._pieces_total,
369 filepath, piece_hash, exceptions,
370 )
371 # _debug(f'{_thread_name()}: Collector callback return value: {maybe_cancel}')
372 if maybe_cancel is not None:
373 self._cancel()
374
375 def _cancel(self):
376 # NOTE: We don't need to stop HasherPool or Collector.collect() because
377 # they will stop when Reader._push_pieces() pushes QUEUE_CLOSED.
378 # They will process the pieces in the queue, but that shouldn't
379 # take long unless the Reader's queue size is too big.
380 self._reader.stop()
381
382 def _finalize(self):
383 _debug(f'{_thread_name()}: Joining {self._reader}')
384 self._reader.join()
385 _debug(f'{_thread_name()}: Joining {self._hashers}')
386 self._hashers.join()
387 _debug(f'{_thread_name()}: hash_queue has {self._hashers.hash_queue.qsize()} items left')
388
389 @property
390 def hashes(self):
391 """Ordered sequence of piece hashes"""
392 return tuple(hash for index, hash in sorted(self._hashes_unsorted))
393
394
395class _IntervaledCallback:
396 """
397 Callable that calls `callback`, but only if at least `interval` seconds
398 elapsed since the previous call
399 """
400 def __init__(self, callback, interval=0):
401 self._callback = callback
402 self._interval = interval
403 self._prev_call_time = -1
404
405 def __call__(self, *args, force=False):
406 now = time_monotonic()
407 diff = now - self._prev_call_time
408 # _debug(f'{_thread_name()}: Callback? {force=} or {diff=} >= {self._interval=}')
409 if force or diff >= self._interval:
410 self._prev_call_time = now
411 # _debug(f'{_thread_name()}: Callback! {args=}')
412 return self._callback(*args)
413
414
415class _TranslatingCallback:
416 def __init__(self, callback, interval, torrent):
417 self._callback = callback
418 self._torrent = torrent
419 self._intervaled_callback = _IntervaledCallback(
420 callback=self._call_callback,
421 interval=interval,
422 )
423
424 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)
430
431 def _force_callback(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
432 # Figure out if we must ignore the interval for this call. This method
433 # is called for every hashed piece and should be as efficient as
434 # possible.
435 raise NotImplementedError('You must implement this method!')
436
437 def _call_callback(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
438 # Translate arguments for the actual callback. This method is only
439 # called at intervals (e.g. once per second).
440 raise NotImplementedError('You must implement this method!')
441
442
443class GenerateCallback(_TranslatingCallback):
444 """
445 Translate arguments from :class:`Collector` to what's specified by
446 :meth:`~.Torrent.generate`
447 """
448
449 def _force_callback(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
450 return exceptions or pieces_done >= pieces_total
451
452 def _call_callback(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
453 if exceptions:
454 # Torrent creation errors are always fatal and must be raised
455 raise exceptions[0]
456 elif self._callback:
457 # Report progress and allow callback to cancel
458 return self._callback(self._torrent, filepath, pieces_done, pieces_total)
459
460
461class VerifyCallback(_TranslatingCallback):
462 """
463 Translate arguments from :class:`Collector` to what's specified by
464 :meth:`~.Torrent.verify`
465 """
466 def __init__(self, *args, path, **kwargs):
467 super().__init__(*args, **kwargs)
468
469 # Store piece hashes from the torrent for quick access
470 self._exp_hashes = self._torrent.hashes
471
472 # Map expected file system paths to expected file sizes
473 # NOTE: The last segment in `path` is supposed to be the torrent name so
474 # we must remove the name stored in the torrent file from each
475 # `file`. This allows verification of any renamed file/directory
476 # against a torrent.
477 self._exp_file_sizes = tuple(
478 (
479 os.sep.join((str(path), *file.parts[1:])),
480 self._torrent.partial_size(file),
481 )
482 for file in self._torrent.files
483 )
484
485 def _force_callback(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
486 return (
487 # Always report exceptions
488 exceptions
489 # Always report completion
490 or pieces_done >= pieces_total
491 # Always report hash mismatch
492 or piece_hash is not None and piece_hash != self._exp_hashes[piece_index]
493 )
494
495 def _call_callback(self, piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions):
496 if (
497 # Don't add verification error if there are other errors
498 not exceptions
499 # Piece hash was calculated and doesn't match
500 and piece_hash is not None and piece_hash != self._exp_hashes[piece_index]
501 ):
502 exceptions = (errors.VerifyContentError(
503 filepath, piece_index, self._torrent.piece_size, self._exp_file_sizes,
504 ),)
505
506 if self._callback:
507 # Callback can raise exception or handle it otherwise
508 def call_callback(fpath, exception):
509 return self._callback(
510 self._torrent, fpath,
511 pieces_done, pieces_total, piece_index,
512 piece_hash, exception,
513 )
514
515 if exceptions:
516 # Call callback for each exception until it indicates
517 # cancellation by returning anything truthy
518 for exception in exceptions:
519 fpath = self._get_path_from_exception(exception)
520 maybe_cancel = call_callback(fpath, exception)
521 if maybe_cancel is not None:
522 return maybe_cancel
523 else:
524 # Report progress and return cancellation indicator
525 return call_callback(filepath, None)
526
527 elif exceptions:
528 # Default to raising first exception
529 raise exceptions[0]
530
531 @staticmethod
532 def _get_path_from_exception(exception):
533 for attr in ('filepath', 'path'):
534 try:
535 return getattr(exception, attr)
536 except AttributeError:
537 pass
538
539 raise RuntimeError(f'Failed to get path from {exception!r}')
plugin.video.jacktook/lib/vendor/torf/_magnet.py +420 -0
……@@ -0,0 +1,420 @@
1# This file is part of torf.
2#
3# torf is free software: you can redistribute it and/or modify it under the
4# terms of the GNU General Public License as published by the Free Software
5# Foundation, either version 3 of the License, or (at your option) any later
6# version.
7#
8# torf is distributed in the hope that it will be useful, but WITHOUT ANY
9# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
10# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
11#
12# You should have received a copy of the GNU General Public License
13# along with torf. If not, see <https://www.gnu.org/licenses/>.
14
15import base64
16import binascii
17import io
18import re
19import time
20import urllib
21from collections import abc, defaultdict
22
23from . import _errors as error
24from . import _utils as utils
25
26
27class Magnet():
28 """
29 BTIH Magnet URI
30
31 :param str xt: eXact Topic: Info hash (Base 16 or 32)
32 :param str dn: Display Name: Name of the torrent
33 :param int xl: eXact Length: Size in bytes
34 :param list tr: TRacker: Iterable of tracker URLs
35 :param str xs: eXact Source: Torrent file URL
36 :param str as\\_: Acceptable Source: Fallback torrent file URL
37 :param list ws: WebSeeds: Iterable of webseed URLs (see BEP19)
38 :param list kt: Keyword Topic: List of search keywords
39
40 All keyword arguments that start with ``x_`` go into the :attr:`x`
41 dictionary with the part after the underscore as the key. They appear as
42 "x.<name>" in the rendered URI.
43
44 References:
45 | https://www.bittorrent.org/beps/bep_0009.html
46 | https://en.wikipedia.org/wiki/Magnet_URL
47 | http://magnet-uri.sourceforge.net/magnet-draft-overview.txt
48 | https://wiki.theory.org/index.php/BitTorrent_Magnet-URI_Webseeding
49 | http://shareaza.sourceforge.net/mediawiki/index.php/Magnet_URI_scheme
50 """
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):
56 self._tr = utils.MonitoredList(type=utils.URL)
57 self._ws = utils.MonitoredList(type=utils.URL)
58 self.xt = xt
59 self.dn = dn
60 self.xl = xl
61 self.tr = tr
62 self.xs = xs
63 self.as_ = as_
64 self.ws = ws
65 self.kt = kt
66
67 self._x = defaultdict(lambda: None)
68 for key in tuple(kwargs):
69 if key.startswith('x_'):
70 self._x[key[2:]] = kwargs.pop(key)
71
72 if kwargs:
73 key, value = next(iter(kwargs.items()))
74 raise TypeError(f'Unrecognized argument: {key}={value!r}')
75
76 @property
77 def dn(self):
78 """Display Name: Name of the torrent or ``None``"""
79 return self._dn
80
81 @dn.setter
82 def dn(self, value):
83 self._dn = str(value).replace('\n', ' ') if value is not None else None
84
85 @property
86 def xt(self):
87 """
88 eXact Topic: URN containing the info hash as base 16 or base 32
89
90 Example:
91
92 urn:btih:3bb9561e35b06175bb6d2c2330578dc83846cc5d
93
94 For convenience, this property may be set to the info hash without the
95 ``urn:btih`` part.
96
97 :raises MagnetError: if set to an invalid value
98 """
99 return f'urn:btih:{self._infohash}'
100
101 @xt.setter
102 def xt(self, value):
103 value = str(value)
104 if self._INFOHASH_REGEX.match(value):
105 self._infohash = value
106 else:
107 match = self._XT_REGEX.match(value)
108 if match:
109 self._infohash = match.group(1)
110 if not hasattr(self, '_infohash'):
111 raise error.MagnetError(value, 'Invalid exact topic ("xt")')
112
113 @property
114 def infohash(self):
115 """
116 Info hash as base 16 or base 32
117
118 :raises MagnetError: if set to an invalid value
119 """
120 return self._infohash
121
122 @infohash.setter
123 def infohash(self, value):
124 value = str(value)
125 match = self._INFOHASH_REGEX.match(value)
126 if match:
127 self._infohash = value
128 else:
129 raise error.MagnetError(value, 'Invalid info hash')
130
131 @property
132 def xl(self):
133 """
134 eXact Length: Size in bytes or ``None``
135
136 :raises MagnetError: if set to an invalid value
137 """
138 return self._xl
139
140 @xl.setter
141 def xl(self, value):
142 if value is not None:
143 try:
144 value = int(value)
145 except ValueError:
146 raise error.MagnetError(value, 'Invalid exact length ("xl")')
147 else:
148 if value < 1:
149 raise error.MagnetError(value, 'Must be 1 or larger')
150 else:
151 self._xl = value
152 else:
153 self._xl = None
154
155 @property
156 def tr(self):
157 """
158 TRackers: List of tracker URLs, single tracker URL or ``None``
159
160 :raises URLError: if any of the URLs is invalid
161 """
162 return self._tr
163
164 @tr.setter
165 def tr(self, value):
166 if value is None:
167 self._tr.clear()
168 elif isinstance(value, str):
169 self._tr.replace((value,))
170 else:
171 self._tr.replace(value)
172
173 @property
174 def xs(self):
175 """
176 eXact Source: Torrent file URL or ``None``
177
178 :raises URLError: if the URL is invalid
179 """
180 return self._xs
181
182 @xs.setter
183 def xs(self, value):
184 self._xs = utils.URL(value) if value is not None else None
185
186 @property
187 def as_(self):
188 """
189 Acceptable Source: Fallback torrent file URL or ``None``
190
191 (The trailing underscore is needed because "as" is a keyword in Python.)
192
193 :raises URLError: if the URL is invalid
194 """
195 return self._as
196
197 @as_.setter
198 def as_(self, value):
199 self._as = utils.URL(value) if value is not None else None
200
201 @property
202 def ws(self):
203 """
204 WebSeeds: List of webseed URLs, single webseed URL or ``None``
205
206 See BEP19.
207
208 :raises URLError: if any of the URLs is invalid
209 """
210 return self._ws
211
212 @ws.setter
213 def ws(self, value):
214 if value is None:
215 self._ws.clear()
216 elif isinstance(value, str):
217 self._ws.replace((value,))
218 else:
219 self._ws.replace(value)
220
221 @property
222 def kt(self):
223 """Keyword Topic: List of search keywords or ``None``"""
224 return self._kt
225
226 @kt.setter
227 def kt(self, value):
228 if value is None:
229 self._kt = []
230 elif isinstance(value, str):
231 self._kt = [value]
232 elif isinstance(value, abc.Iterable):
233 self._kt = [str(v) for v in value] if value is not None else None
234 else:
235 raise error.MagnetError(value, 'Invalid keyword topic ("kt")')
236
237 @property
238 def x(self):
239 """
240 Mapping of custom keys to their values
241
242 For example, "x.pe" (a peer address) would be accessed as
243 ``magnet.x['pe']``.
244 """
245 return self._x
246
247 def torrent(self):
248 """:class:`Torrent` instance"""
249 # Prevent circular import issues
250 from ._torrent import Torrent
251 torrent = Torrent()
252 torrent.name = self.dn
253 if self.tr:
254 torrent.trackers = self.tr
255 if self.ws:
256 torrent.webseeds = self.ws
257 if self.xl:
258 torrent._metainfo['info']['length'] = self.xl
259 if hasattr(self, '_info'):
260 torrent.metainfo['info'] = self._info
261 elif len(self.infohash) == 40:
262 torrent._infohash = self.infohash
263 else:
264 # Convert base 32 to base 16 (SHA1)
265 torrent._infohash = base64.b16encode(
266 base64.b32decode(self.infohash)).decode('utf-8').lower()
267 return torrent
268
269 def get_info(self, validate=True, timeout=60, callback=None):
270 """
271 Download the torrent's "info" section
272
273 Try the following sources in this order: :attr:`xs`, :attr:`as`,
274 :attr:`tr`
275
276 :meth:`torrent` can only return a complete torrent if this method is
277 called first.
278
279 :param validate: Whether to ensure the downloaded "info" section is
280 valid
281 :param timeout: Give up after this many seconds
282 :type timeout: int, float
283 :param callback callable: Callable that is called with a
284 :class:`TorfError` instance if a source is specified but fails
285
286 :return: ``True`` if the "info" section was successfully downloaded,
287 ``False`` otherwise
288 """
289 def success():
290 return hasattr(self, '_info')
291
292 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))
296 # I couldn't find any documentation for the "/file?info_hash=..." GET request, but
297 # it seems to work for HTTP trackers.
298 # https://stackoverflow.com/a/1019588
299 for url in self.tr:
300 if url.scheme in ('http', 'https'):
301 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}')
303
304 start = time.monotonic()
305 for url in torrent_urls:
306 to = timeout - (time.monotonic() - start)
307 try:
308 torrent = utils.download(url, timeout=to)
309 except error.ConnectionError as e:
310 if callback:
311 callback(e)
312 else:
313 self._set_info_from_torrent(torrent, validate, callback)
314 if success() or to <= 0:
315 break
316
317 return success()
318
319 def _set_info_from_torrent(self, torrent_data, validate=True, callback=False):
320 """Extract "info" section from `torrent_data` for :meth:`torrent`"""
321 # Prevent circular import issues
322 from ._torrent import Torrent
323 stream = io.BytesIO(torrent_data)
324 try:
325 torrent = Torrent.read_stream(stream, validate=validate)
326 except error.TorfError as e:
327 if callback:
328 callback(e)
329 else:
330 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']
334
335 _KNOWN_PARAMETERS = ('xt', 'dn', 'xl', 'tr', 'xs', 'as', 'ws', 'kt')
336
337 @classmethod
338 def from_string(cls, uri):
339 """
340 Create :class:`Magnet` URI from string
341
342 :raises URLError: if `uri` contains an invalid URL (e.g. :attr:`tr`)
343 :raises MagnetError: if `uri` is not a valid magnet URI
344 """
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')
348 else:
349 query = urllib.parse.parse_qs(info.query)
350
351 # Check for unknown parameters
352 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')
355
356 if 'xt' not in query:
357 raise error.MagnetError(uri, 'Missing exact topic ("xt")')
358 elif len(query['xt']) > 1:
359 raise error.MagnetError(uri, 'Multiple exact topics ("xt")')
360 else:
361 self = cls(xt=query['xt'][0])
362
363 # 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())):
369 if param in query:
370 if len(query[param]) > 1:
371 raise error.MagnetError(uri, f'Multiple {name}s ("{param}")')
372 else:
373 setattr(self, attr, parse(query[param][0]))
374
375 # Parameters that accept multiple values
376 for param,name in (('tr', 'tracker'),
377 ('ws', 'webseed')):
378 if param in query:
379 setattr(self, param, query[param])
380
381 return self
382
383 def __str__(self):
384 uri = [f'magnet:?xt={self.xt}']
385
386 for key in ('dn', 'xl', 'xs', 'as_'):
387 value = getattr(self, f'{key}')
388 if value is not None:
389 if isinstance(value, str):
390 uri.append(f'{key}={utils.urlquote(value)}')
391 else:
392 uri.append(f'{key}={value}')
393
394 if self.kt:
395 uri.append(f'kt={"+".join(utils.urlquote(k) for k in self.kt)}')
396
397 for key in ('tr', 'ws'):
398 seq = getattr(self, f'{key}')
399 if seq is not None:
400 for item in seq:
401 uri.append(f'{key}={utils.urlquote(item)}')
402
403 for key,value in self._x.items():
404 uri.append(f'x.{key}={utils.urlquote(value)}')
405
406 return '&'.join(uri)
407
408 def __repr__(self):
409 clsname = type(self).__name__
410 kwargs = {}
411 for param in self._KNOWN_PARAMETERS:
412 if param == 'as':
413 param = 'as_'
414 value = getattr(self, param)
415 if value:
416 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})'
plugin.video.jacktook/lib/vendor/torf/_reuse.py +202 -0
……@@ -0,0 +1,202 @@
1import errno
2import os
3
4from . import _errors as error
5from . import _generate as generate
6from . import _stream as stream
7
8
9class find_torrent_files:
10 """Iterator over ``(torrent_file, torrent_file_counter, exception)`` tuples"""
11
12 def __init__(self, *paths, max_file_size=float('inf')):
13 self._paths = paths
14 self._counter = 0
15 self._max_file_size = max_file_size
16
17 def __iter__(self):
18 """
19 Find torrent files recursively beneath each path in `paths`
20
21 Each list item is a 4-tuple that contains the torrent file path or ``None``,
22 a counter that increments for each torrent file, the total number of torrent
23 files and an exception or ``None``.
24 """
25 for path in self._paths:
26 yield from self._find(path)
27
28 def _find(self, path):
29 if os.path.isdir(path):
30 try:
31 for name in os.listdir(path):
32 subpath = os.sep.join((str(path), name))
33 yield from self._find(subpath)
34 except OSError as e:
35 yield None, self._counter, error.ReadError(e.errno, str(path))
36
37 elif os.path.basename(path).lower().endswith('.torrent'):
38 try:
39 file_size = os.path.getsize(path)
40 except OSError:
41 self._counter += 1
42 yield path, self._counter, error.ReadError(errno.ENOENT, str(path))
43 else:
44 if file_size <= self._max_file_size:
45 self._counter += 1
46 yield path, self._counter, None
47
48 elif not os.path.exists(path):
49 yield None, self._counter, error.ReadError(errno.ENOENT, str(path))
50
51 @property
52 def total(self):
53 """Total number of torrents beneath all paths"""
54 # Get a sequence of all torrents without changing self._counter.
55 items = tuple(type(self)(*self._paths, max_file_size=self._max_file_size))
56 if items:
57 # Last item should contain the number of torrents found.
58 return items[-1][1]
59 else:
60 return 0
61
62
63def is_file_match(torrent, candidate):
64 """
65 Whether `torrent` contains the same files as `candidate`
66
67 Both arugments are :class:`~.Torrent` objects.
68
69 The torrents match if they both share the same ``name`` and ``files`` or
70 ``name`` and ``length`` fields in their :attr:`~.Torrent.metainfo`.
71 `candidate`'s :attr:`~.Torrent.piece_size` of must also not exceed
72 `torrent`'s :attr:`~.Torrent.piece_size_max`.
73
74 This is a quick check that doesn't require any system calls.
75 """
76 # Compare relative file paths and file sizes.
77 # Order of files is important.
78 torrent_info, candidate_info = torrent.metainfo['info'], candidate.metainfo['info']
79
80 # Don't bother doing anything else if the names are different
81 if torrent_info['name'] != candidate_info['name']:
82 return False
83
84 torrent_id = _get_filepaths_and_sizes(torrent_info)
85 candidate_id = _get_filepaths_and_sizes(candidate_info)
86 if torrent_id == candidate_id:
87 if torrent.piece_size_min <= candidate.piece_size <= torrent.piece_size_max:
88 return True
89
90 return False
91
92def _get_filepaths_and_sizes(info):
93 name = info['name']
94
95 # Singlefile torrent
96 length = info.get('length', None)
97 if length:
98 return [(name, length)]
99
100 # Multifile torrent
101 files = info.get('files', None)
102 if files:
103 files_and_sizes = []
104 for file in files:
105 files_and_sizes.append((
106 os.sep.join((name, *file['path'])),
107 file['length'],
108 ))
109 return sorted(files_and_sizes)
110
111 else:
112 raise RuntimeError(f'Unable to find files: {info!r}')
113
114
115def is_content_match(torrent, candidate):
116 """
117 Whether `torrent` contains the same files as `candidate`
118
119 Both arugments are :class:`~.Torrent` objects.
120
121 If a `candidate` matches, a few piece hashes from each file are compared to
122 the corresponding hashes from `candidate` to detect files name/size
123 collisions.
124
125 This is relatively slow and should only be used after :func:`is_file_match`
126 returned `True`.
127 """
128 if not torrent.path:
129 raise RuntimeError(f'Torrent does not have a file system path: {torrent!r}')
130
131 # Compare some piece hashes for each file
132 with stream.TorrentFileStream(candidate, content_path=torrent.path) as tfs:
133 check_piece_indexes = set()
134 for file in torrent.files:
135 all_file_piece_indexes = tfs.get_piece_indexes_of_file(file)
136 middle_piece_index = int(len(all_file_piece_indexes) / 2)
137 some_file_piece_indexes = (
138 all_file_piece_indexes[:1]
139 + [middle_piece_index]
140 + all_file_piece_indexes[-1:]
141 )
142 check_piece_indexes.update(some_file_piece_indexes)
143
144 for piece_index in sorted(check_piece_indexes):
145 if not tfs.verify_piece(piece_index):
146 return False
147 return True
148
149
150def copy(from_torrent, to_torrent):
151 """
152 Copy ``pieces``, ``piece length`` and ``files`` from `from_torrent` to
153 `to_torrent`
154 """
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']:
159 # Confirm both file lists are identical while ignoring order
160 def make_sortable(files):
161 return [tuple(f.items()) for f in files]
162
163 # Only include "length" and "files" fields
164 source_files = [
165 {'length': file['length'], 'path': file['path']}
166 for file in source_info['files']
167 ]
168
169 assert sorted(make_sortable(to_torrent.metainfo['info']['files'])) \
170 == sorted(make_sortable(source_files))
171
172 # Copy file order from `source_info`
173 to_torrent.metainfo['info']['files'] = source_files
174
175
176class ReuseCallback(generate._IntervaledCallback):
177 def __init__(self, *args, torrent, torrent_files_total, **kwargs):
178 super().__init__(*args, **kwargs)
179 self._torrent = torrent
180 self._torrent_files_total = torrent_files_total
181
182 def __call__(self, torrent_filepath, torrent_files_done, is_match, exception):
183 if self._callback:
184 force = bool(
185 # Call callback if there is an error, e.g. "Permission denied"
186 exception
187 # Call callback if we found a match of if we are verifying file contents
188 or is_match in (True, None)
189 # Call callback if this is the last torrent file
190 or torrent_files_done >= self._torrent_files_total
191 )
192 return super().__call__(
193 self._torrent,
194 torrent_filepath,
195 torrent_files_done,
196 self._torrent_files_total,
197 is_match,
198 exception,
199 force=force,
200 )
201 elif exception:
202 raise exception
plugin.video.jacktook/lib/vendor/torf/_stream.py +741 -0
……@@ -0,0 +1,741 @@
1import errno
2import functools
3import hashlib
4import itertools
5import math
6import os
7
8from . import _errors as error
9
10
11class TorrentFileStream:
12 """
13 Traverse concatenated files as they are described in a torrent
14
15 :param torrent: :class:`~.torf.Torrent` object
16
17 Files are opened on demand and kept open for re-use. It is recommended to
18 make use of the context manager protocol to make sure they are properly
19 closed when no longer needed.
20
21 Example:
22
23 >>> torrent = torf.Torrent(...)
24 >>> with TorrentFileStream(torrent) as tfs:
25 >>> # Get the 29th piece of the concatenated file stream
26 >>> piece = tfs.get_piece(29)
27 """
28
29 def __init__(self, torrent, content_path=None):
30 self._torrent = torrent
31 self._content_path = content_path
32 self._open_files = {}
33
34 def _get_content_path(self, content_path, none_ok=False, file=None):
35 # Get content_path argument from class or method call or from
36 # Torrent.path attribute
37 if content_path is not None:
38 content_path = content_path
39 elif self._content_path is not None:
40 content_path = self._content_path
41 elif self._torrent.path is not None:
42 content_path = self._torrent.path
43 elif none_ok:
44 content_path = None
45 else:
46 raise ValueError('Missing content_path argument and torrent has no path specified')
47
48 if self._torrent.mode == 'singlefile':
49 # Torrent contains no directory, just a file
50 return content_path or file
51
52 # Torrent contains directory with one or more files in it
53 if file is None:
54 return content_path
55 else:
56 # Append internal path from torrent file
57 if content_path:
58 # Use the torrent name from `content_path`, not the one from the
59 # torrent (i.e. the first path segment of `file`) so the user
60 # can operate on renamed a directory/file (files and
61 # subdirectories in multifile torrents still have to have the
62 # same names)
63 file_parts = list(file.parts)
64 assert len(file_parts) >= 2, file_parts
65 file_parts.pop(0)
66 content_file_path = os.path.join(content_path, *file_parts)
67 return type(file)(content_file_path, file.size)
68
69 else:
70 return file
71
72 def __enter__(self):
73 return self
74
75 def __exit__(self, exc_type, exc_value, traceback):
76 self.close()
77
78 def close(self):
79 """
80 Close all opened files
81
82 This is called automatically when the instance is used as a context
83 manager.
84 """
85 for filepath, fh in tuple(self._open_files.items()):
86 fh.close()
87 del self._open_files[filepath]
88
89 @property
90 def max_piece_index(self):
91 """Largest valid piece index (smallest is always 0)"""
92 return math.floor((self._torrent.size - 1) / self._torrent.piece_size)
93
94 def get_file_position(self, file):
95 """
96 Return index of first byte of `file` in stream of concatenated files
97
98 :param file: :class:`~torf.File` object
99
100 :raise ValueError: if `file` is not specified in the torrent
101 """
102 try:
103 file_index = self._torrent.files.index(file)
104 except ValueError:
105 raise ValueError(f'File not specified: {file}')
106 else:
107 stream_pos = sum(f.size for f in self._torrent.files[:file_index])
108 return stream_pos
109
110 def get_file_at_position(self, position, content_path=None):
111 """
112 Return file that belongs to the byte at `position` in stream of concatenated
113 files
114
115 :param position: Byte index in the stream; minimum is 0, maximum is the
116 torrent's size minus 1
117 :param content_path: Path to file or directory (defaults to class
118 argument of the same name, :attr:`~.Torrent.path` or the file path
119 from the torrent)
120 """
121 if position >= 0:
122 pos = 0
123 for file in self._torrent.files:
124 pos += file.size - 1
125 if pos >= position:
126 return self._get_content_path(content_path, none_ok=True, file=file)
127 else:
128 pos += 1
129
130 raise ValueError(f'position is out of bounds (0 - {self._torrent.size - 1}): {position}')
131
132 def get_piece_indexes_of_file(self, file, exclusive=False):
133 """
134 Return indexes of pieces that contain at least one byte of `file`
135
136 :param bool exclusive: Whether to include only indexes of pieces that
137 don't contain bytes of any other files, i.e. only return piece
138 indexes that belong to `file` exlusively
139
140 :param file: :class:`~torf.File` object
141 """
142 piece_size = self._torrent.piece_size
143 stream_pos = self.get_file_position(file)
144 first_piece_index = math.floor(stream_pos / piece_size)
145 last_piece_index = math.floor((stream_pos + file.size - 1) / piece_size)
146 piece_indexes = list(range(first_piece_index, last_piece_index + 1))
147
148 if exclusive:
149 # Remove first piece index if it's not exclusive
150 files_in_first_piece = self.get_files_at_piece_index(first_piece_index)
151 if files_in_first_piece != [file]:
152 piece_indexes.remove(first_piece_index)
153
154 # Remove last piece index if it's not exclusive
155 files_in_last_piece = self.get_files_at_piece_index(last_piece_index)
156 if last_piece_index in piece_indexes and files_in_last_piece != [file]:
157 piece_indexes.remove(last_piece_index)
158
159 return piece_indexes
160
161 def get_files_at_byte_range(self, first_byte_index, last_byte_index, content_path=None):
162 """
163 Return list of files that have at least one byte at `first_byte_index`,
164 `last_byte_index` or between those two in the stream of concatenated
165 files
166
167 :param content_path: Path to file or directory (defaults to class
168 argument of the same name, :attr:`~.Torrent.path` or the file path
169 from the torrent)
170 """
171 assert first_byte_index <= last_byte_index, (first_byte_index, last_byte_index)
172 pos = 0
173 files = []
174 for file in self._torrent.files:
175 file_first_byte_index = pos
176 file_last_byte_index = pos + file.size - 1
177 if (
178 # Is first byte of file inside of range?
179 first_byte_index <= file_first_byte_index <= last_byte_index or
180 # Is last byte of file inside of range?
181 first_byte_index <= file_last_byte_index <= last_byte_index or
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)
184 ):
185 content_file_path = self._get_content_path(content_path, none_ok=True, file=file)
186 files.append(content_file_path)
187 pos += file.size
188 return files
189
190 def get_byte_range_of_file(self, file):
191 """
192 Return index of first and last byte in the stream of concatenated files that
193 contains at least one byte of `file`
194 """
195 start = self.get_file_position(file)
196 return start, start + file.size - 1
197
198 def get_files_at_piece_index(self, piece_index, content_path=None):
199 """
200 Return list of files that have 1 or more bytes in piece at `piece_index`
201
202 :param piece_index: Index of the piece; minimum is 0, maximum is the
203 torrent's number of pieces minus 1
204 :param content_path: Path to file or directory (defaults to class
205 argument of the same name, :attr:`~.Torrent.path` or the file path
206 from the torrent)
207 """
208 if piece_index >= 0:
209 piece_size = self._torrent.piece_size
210 piece_start_pos = piece_index * piece_size
211 piece_end_pos = ((piece_index + 1) * piece_size) - 1
212 files = self.get_files_at_byte_range(
213 piece_start_pos,
214 piece_end_pos,
215 content_path=content_path,
216 )
217 if files:
218 return files
219
220 raise ValueError(f'piece_index is out of bounds (0 - {self.max_piece_index}): {piece_index}')
221
222 def get_absolute_piece_indexes(self, file, relative_piece_indexes):
223 """
224 Return list of validated absolute piece indexes
225
226 :param file: :class:`~torf.File` object
227 :param relative_piece_indexes: Sequence of piece indexes within `file`;
228 negative values address pieces at the end of `file`, e.g. [0, 12,
229 -1, -2]
230
231 Example:
232
233 >>> # Assume `file` starts in the 50th piece in the stream of
234 >>> # concatenated files and is 100 pieces long. `1000` and `-1000` are
235 >>> # ignored because they are out of bounds.
236 >>> tfs.get_absolute_piece_indexes(file, (0, 1, 70, 75, 1000, -1000, -3, -2, -1))
237 [50, 51, 120, 125, 147, 148, 149]
238 """
239 file_piece_indexes = self.get_piece_indexes_of_file(file)
240 pi_abs_min = file_piece_indexes[0]
241 pi_abs_max = file_piece_indexes[-1]
242 pi_rel_min = 0
243 pi_rel_max = pi_abs_max - pi_abs_min
244
245 validated_piece_indexes = set()
246 for pi_rel in relative_piece_indexes:
247 pi_rel = int(pi_rel)
248
249 # Convert negative to absolute index
250 if pi_rel < 0:
251 pi_rel = pi_rel_max - abs(pi_rel) + 1
252
253 # Ensure relative piece_index is within bounds
254 pi_rel = max(pi_rel_min, min(pi_rel_max, pi_rel))
255
256 # Convert to absolute piece_index
257 pi_abs = pi_abs_min + pi_rel
258 validated_piece_indexes.add(pi_abs)
259
260 return sorted(validated_piece_indexes)
261
262 def get_relative_piece_indexes(self, file, relative_piece_indexes):
263 """
264 Return list of validated relative piece indexes
265
266 :param file: :class:`~torf.File` object
267 :param relative_piece_indexes: Sequence of piece indexes within `file`;
268 negative values address pieces at the end of `file`, e.g. [0, 12,
269 -1, -2]
270
271 Example:
272
273 >>> # Assume `file` starts in the 50th piece in the stream of
274 >>> # concatenated files and is 100 pieces long. `1000` and `-1000` are
275 >>> # ignored because they are out of bounds.
276 >>> tfs.get_absolute_piece_indexes(file, (0, 1, 70, 75, 1000, -1000, -3, -2, -1))
277 [0, 1, 70, 75, 97, 98, 99]
278 """
279 validated_piece_indexes = set()
280 min_piece_index = 0
281 max_piece_index = math.floor((file.size - 1) / self._torrent.piece_size)
282 for rpi in relative_piece_indexes:
283 valid_rpi = int(rpi)
284 if rpi < 0:
285 valid_rpi = max_piece_index - abs(rpi) + 1
286 valid_rpi = max(min_piece_index, min(max_piece_index, valid_rpi))
287 validated_piece_indexes.add(valid_rpi)
288 return sorted(validated_piece_indexes)
289
290 def get_piece(self, piece_index, content_path=None):
291 """
292 Return piece at `piece_index` or `None` for nonexisting file(s)
293
294 :param piece_index: Index of the piece; minimum is 0, maximum is the
295 torrent's number of pieces minus 1
296 :param content_path: Path to file or directory to read piece from
297 (defaults to class argument of the same name or
298 :attr:`~.Torrent.path`)
299
300 :raise ReadError: if a file exists but cannot be read
301 :raise VerifyFileSizeError: if a file has unexpected size
302 """
303 piece_size = self._torrent.piece_size
304 torrent_size = sum(f.size for f in self._torrent.files)
305
306 min_piece_index = 0
307 max_piece_index = math.floor((torrent_size - 1) / piece_size)
308 if not min_piece_index <= piece_index <= max_piece_index:
309 raise ValueError(
310 'piece_index must be in range '
311 f'{min_piece_index} - {max_piece_index}: {piece_index}'
312 )
313
314 # Find out which files we need to read from
315 first_byte_index_of_piece = piece_index * piece_size
316 last_byte_index_of_piece = min(
317 first_byte_index_of_piece + piece_size - 1,
318 torrent_size - 1,
319 )
320 relevant_files = self.get_files_at_byte_range(
321 first_byte_index_of_piece,
322 last_byte_index_of_piece,
323 # Ensure we get the torrent path, not the file system path
324 content_path='',
325 )
326
327 # Find out where to start reading in the first relevant file
328 if len(relevant_files) == 1:
329 # Our piece belongs to a single file
330 file_pos = self.get_file_position(relevant_files[0])
331 seek_to = first_byte_index_of_piece - file_pos
332 else:
333 # Our piece is spread over multiple files
334 file = self.get_file_at_position(first_byte_index_of_piece, content_path='')
335 file_pos = self.get_file_position(file)
336 seek_to = file.size - ((file_pos + file.size) % piece_size)
337
338 # Read piece data from `relevant_files`
339 bytes_to_read = piece_size
340 piece = bytearray()
341 for file in relevant_files:
342 # Translate path within torrent into path within file system
343 filepath = self._get_content_path(content_path, none_ok=False, file=file)
344 fh = self._get_open_file(filepath)
345
346 # Complain about wrong file size. It's theoretically possible that a
347 # file with the wrong size can produce the correct pieces, but that
348 # would be unexpected.
349 actual_file_size = self._get_file_size_from_fs(filepath)
350 if actual_file_size != file.size:
351 raise error.VerifyFileSizeError(filepath, actual_file_size, file.size)
352
353 try:
354 fh.seek(seek_to)
355 seek_to = 0
356
357 content = fh.read(bytes_to_read)
358 bytes_to_read -= len(content)
359 piece.extend(content)
360 except OSError as e:
361 raise error.ReadError(e.errno, file)
362
363 # Ensure expected `piece` length
364 if last_byte_index_of_piece == torrent_size - 1:
365 exp_piece_size = torrent_size % piece_size
366 if exp_piece_size == 0:
367 exp_piece_size = piece_size
368 else:
369 exp_piece_size = piece_size
370 assert len(piece) == exp_piece_size, (len(piece), exp_piece_size)
371 return bytes(piece)
372
373 def _get_file_size_from_fs(self, filepath):
374 if os.path.exists(filepath):
375 try:
376 return os.path.getsize(filepath)
377 except OSError:
378 pass
379
380 # Maximum number of open files (1024 seems to be a common maximum)
381 max_open_files = 10
382
383 def _get_open_file(self, filepath):
384 if filepath not in self._open_files:
385 # Prevent "Too many open files" (EMFILE)
386 while len(self._open_files) > self.max_open_files:
387 old_filepath = tuple(self._open_files)[0]
388 self._open_files[old_filepath].close()
389 del self._open_files[old_filepath]
390
391 try:
392 self._open_files[filepath] = open(filepath, 'rb')
393 except OSError as e:
394 raise error.ReadError(e.errno, filepath)
395
396 return self._open_files.get(filepath, None)
397
398 def iter_pieces(self, content_path=None, oom_callback=None):
399 """
400 Iterate over `(piece, filepath, (exception1, exception2, ...))`
401
402 Each piece consists of :attr:`~.Torrent.piece_size` bytes, except for
403 the final piece in the stream of concatenated files, which may be
404 shorter.
405
406 Filepaths are generated from `content_path` and the relative file paths
407 from the torrent.
408
409 Exceptions are :class:`~.TorfError` subclasses.
410
411 If a file is not readable, pieces are `None` for each missing piece.
412 This usually includes the last piece of the previous file and the first
413 piece of the next file unless the unreadable file starts/ends right on a
414 piece boundary.
415
416 You can wrap this iterator in :func:`enumerate` to get the piece index
417 for each piece:
418
419 >>> for piece_index, (piece, filepath, exceptions) in stream.iter_pieces():
420 >>> ...
421
422 :param content_path: Path to file or directory to read pieces from
423 (defaults to class argument of the same name or
424 :attr:`~.Torrent.path`)
425 :param oom_callback: Callable that gets :class:`~.errors.MemoryError`
426 instance
427
428 Between calls to `oom_callback`, the piece that caused the exception
429 is read again and again until it fits into memory. This callback
430 offers a way to free more memory. If it fails, it is up to the
431 callback to raise the exception or deal with it in some other way.
432
433 If this is `None`, :class:`~.errors.MemoryError` is raised normally.
434
435 :raise ReadError: if file exists but is not readable
436 :raise VerifyFileSizeError: if file has unexpected size
437 """
438 trailing_bytes = b''
439 missing_pieces = _MissingPieces(torrent=self._torrent, stream=self)
440 skip_bytes = 0
441
442 for file in self._torrent.files:
443 if file in missing_pieces.bycatch_files:
444 continue
445
446 # Get expected file system path
447 filepath = self._get_content_path(content_path, none_ok=False, file=file)
448
449 # Get file handle or exception
450 fh = exception = None
451 actual_file_size = self._get_file_size_from_fs(filepath)
452 if actual_file_size is not None and file.size != actual_file_size:
453 exception = error.VerifyFileSizeError(filepath, actual_file_size, file.size)
454 else:
455 try:
456 fh = self._get_open_file(filepath)
457 except error.ReadError as e:
458 exception = e
459
460 # Make generator that yields `(piece, filepath, exceptions)` tuples
461 if fh:
462 # _debug(f'{file}: Reading {filepath}')
463 # Read pieces from opened file
464 pieces, skip_bytes = self._iter_from_file_handle(
465 fh,
466 prepend=trailing_bytes,
467 skip_bytes=skip_bytes,
468 oom_callback=oom_callback,
469 )
470 trailing_bytes = b''
471 piece_size = self._torrent.piece_size
472 for piece in pieces:
473 if len(piece) == piece_size:
474 yield (piece, filepath, ())
475 else:
476 trailing_bytes = piece
477
478 else:
479 # _debug(f'{file}: Faking {filepath}')
480 # We can't complete the current piece
481 trailing_bytes = b''
482 # Opening file failed
483 items, skip_bytes = missing_pieces(file, content_path, reason=exception)
484 for item in items:
485 yield item
486
487 # Yield last few bytes in stream unless stream size is perfectly
488 # divisible by piece size
489 if trailing_bytes:
490 yield (trailing_bytes, filepath, ())
491
492 def _iter_from_file_handle(self, fh, prepend, skip_bytes, oom_callback):
493 # Read pieces from from file handle.
494 # `prepend` is the incomplete piece from the previous file, i.e. the
495 # leading bytes of the next piece.
496 # `skip_bytes` is the number of bytes from `fh` to dump before
497 # reading the next piece.
498
499 if skip_bytes:
500 skipped = fh.seek(skip_bytes)
501 skip_bytes -= skipped
502
503 def iter_pieces(fh, prepend):
504 piece_size = self._torrent.piece_size
505 piece = b''
506
507 # Iterate over pieces in `prepend`ed bytes, store incomplete piece
508 # in `piece`
509 for pos in range(0, len(prepend), piece_size):
510 piece = prepend[pos:pos + piece_size]
511 if len(piece) == piece_size:
512 yield piece
513 piece = b''
514
515 try:
516 # Fill incomplete piece with first bytes from `fh`
517 if piece:
518 piece += self._read_from_fh(
519 fh=fh,
520 size=piece_size - len(piece),
521 oom_callback=oom_callback,
522 )
523 yield piece
524
525 # Iterate over `piece_size`ed chunks from `fh`
526 while True:
527 piece = self._read_from_fh(
528 fh=fh,
529 size=piece_size,
530 oom_callback=oom_callback,
531 )
532 if piece:
533 yield piece
534 else:
535 break # EOF
536
537 except OSError as e:
538 raise error.ReadError(e.errno, fh.name)
539
540 return iter_pieces(fh, prepend), skip_bytes
541
542 def _read_from_fh(self, fh, size, oom_callback):
543 while True:
544 try:
545 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 if oom_callback is None:
549 raise e
550 else:
551 oom_callback(e)
552
553 def get_piece_hash(self, piece_index, content_path=None):
554 """
555 Read piece at `piece_index` from file(s) and return its SHA1 hash
556
557 :param piece_index: Index of the piece; minimum is 0, maximum is the
558 torrent's number of pieces minus 1
559 :param content_path: Path to file or directory to read piece from
560 (defaults to class argument of the same name or
561 :attr:`~.Torrent.path`)
562
563 :raise ReadError: if a file exists but cannot be read
564 :raise VerifyFileSizeError: if a file has unexpected size
565
566 :return: :class:`bytes`
567 """
568 try:
569 piece = self.get_piece(piece_index, content_path=content_path)
570 except error.ReadError as e:
571 if e.errno is errno.ENOENT:
572 # No such file
573 return None
574 else:
575 # Other read error, e.g. permission denied
576 raise
577 else:
578 return hashlib.sha1(piece).digest()
579
580 def verify_piece(self, piece_index, content_path=None):
581 """
582 Generate SHA1 hash for piece at `piece_index` and compare to the expected
583 hash in the torrent
584
585 :param piece_index: Index of the piece; minimum is 0, maximum is the
586 torrent's number of pieces minus 1
587 :param content_path: Path to file or directory to read piece from
588 (defaults to class argument of the same name or
589 :attr:`~.Torrent.path`)
590
591 :raise ReadError: if a file exists but cannot be read
592 :raise VerifyFileSizeError: if a file has unexpected size
593
594 :return: result of the hash comparision (:class:`bool`) or `None` if a
595 file at `piece_index` does not exist
596 """
597 try:
598 stored_piece_hash = self._torrent.hashes[piece_index]
599 except IndexError:
600 raise ValueError(f'piece_index must be in range 0 - {self.max_piece_index}: {piece_index}')
601
602 generated_piece_hash = self.get_piece_hash(piece_index, content_path=content_path)
603 if generated_piece_hash is not None:
604 return stored_piece_hash == generated_piece_hash
605
606
607class _MissingPieces:
608 """Calculate the missing pieces for a given file"""
609
610 def __init__(self, torrent, stream):
611 self._torrent = torrent
612 self._stream = stream
613 self._piece_indexes_seen = set()
614 self._bycatch_files = []
615
616 def __call__(self, file, content_path, reason):
617 # Get the number of pieces covered by `file` minus all pieces we have
618 # already reported due to overlaps
619 piece_indexes = self._stream.get_piece_indexes_of_file(file)
620 for piece_index in piece_indexes:
621 if piece_index in self._piece_indexes_seen:
622 piece_indexes.remove(piece_index)
623 self._piece_indexes_seen.update(piece_indexes)
624
625 # Figure out which subsequent files are affected by the missing last
626 # piece of `file`
627 affected_files = self._stream.get_files_at_piece_index(piece_indexes[-1], content_path='')
628 affected_files.remove(file)
629 # _debug(f'{affected_files=}')
630
631 # Files that are processed as a side effect because they only exist in a
632 # piece that also belongs to `file`
633 bycatch_files = []
634
635 # Unless `file` is the last file or it ends perfectly at a piece
636 # boundary, we must calculate where the next piece starts in the next
637 # file
638 skip_bytes = 0
639
640 if affected_files:
641 # There are multiple files in the last piece of `file`
642 # NOTE: `next_file` is the first file in the next piece, not the
643 # file after `file` in the stream (remember: each piece can
644 # fit lots and lots of files)
645 next_file = affected_files[-1]
646 next_file_start, next_file_end = self._stream.get_byte_range_of_file(next_file)
647
648 # Stream index of the last byte of the last missing piece of `file`
649 next_piece_boundary_index = (
650 (piece_indexes[-1] * self._torrent.piece_size)
651 + self._torrent.piece_size - 1
652 )
653
654 if next_file_end > next_piece_boundary_index:
655 # The last file in this last missing piece continues in the next
656 # piece. When we read from that file to create the next piece,
657 # we must skip the first few bytes.
658 skip_bytes = next_piece_boundary_index - next_file_start + 1
659
660 # Mark all files between `file` and `next_file` as bycatch,
661 # excluding `file` and `next_file`
662 bycatch_files.extend(affected_files[:-1])
663 else:
664 # Include `next_file` in bycatch because it doesn't reach into
665 # the next piece
666 bycatch_files.extend(affected_files)
667
668 self._bycatch_files.extend(bycatch_files)
669
670 def iter_yields():
671 # _debug(f'Calculated missing pieces: {piece_indexes}')
672 # _debug(f'Calculated bycatch files: {bycatch_files}')
673 # _debug(f'Skipping {skip_bytes} bytes at the start of next file')
674 piece_count = len(piece_indexes)
675 it = itertools.chain(
676 self._first_yield(piece_count, file, content_path, bycatch_files, reason),
677 self._middle_yields(piece_count, file, content_path),
678 self._last_yield(piece_count, file, content_path, bycatch_files),
679 )
680 yield from it
681
682 return iter_yields(), skip_bytes
683
684 def _first_yield(self, piece_count, file, content_path, bycatch_files, reason):
685 assert isinstance(reason, BaseException), repr(reason)
686 exceptions = [reason]
687 if piece_count == 1:
688 # First piece is also last piece, so we must add bycatch exceptions
689 # to the original exception (`reason`)
690 bycatch_exceptions = self._get_bycatch_exceptions(bycatch_files, content_path)
691 # _debug(f'First yield: Stream has only one piece - adding bycatch exceptions: {bycatch_exceptions}')
692 exceptions.extend(bycatch_exceptions)
693 filepath = self._stream._get_content_path(content_path, none_ok=False, file=file)
694 yield (None, filepath, tuple(exceptions))
695
696 def _middle_yields(self, piece_count, file, content_path):
697 # Subtract first and last piece
698 middle_piece_count = piece_count - 2
699 # _debug(f'Middle yields: {max(0, middle_piece_count)} middle pieces found')
700 if middle_piece_count >= 1:
701 # Yield second to second-to-last pieces (exceptions are reported by
702 # _first/last_yield())
703 filepath = self._stream._get_content_path(content_path, none_ok=False, file=file)
704 middle_piece = (None, filepath, ())
705 for i in range(middle_piece_count):
706 yield middle_piece
707
708 def _last_yield(self, piece_count, file, content_path, bycatch_files):
709 # Yield bycatch exceptions unless _first_yield() already did it
710 if piece_count > 1:
711 # Report bycatch exceptions with last piece
712 exceptions = self._get_bycatch_exceptions(bycatch_files, content_path)
713 # _debug(f'Last yield: Exceptions: {exceptions}')
714 filepath = self._stream._get_content_path(content_path, none_ok=False, file=file)
715 yield (None, filepath, tuple(exceptions))
716 # else:
717 # _debug(f'Last yield: First piece is last piece')
718
719 def _get_bycatch_exceptions(self, bycatch_files, content_path):
720 exceptions = []
721 for bc_file in bycatch_files:
722 bc_filepath = self._stream._get_content_path(content_path, none_ok=False, file=bc_file)
723 actual_size = self._stream._get_file_size_from_fs(bc_filepath)
724 if actual_size is None:
725 # No such file
726 exceptions.append(error.ReadError(errno.ENOENT, bc_filepath))
727 elif bc_filepath.size != actual_size:
728 exceptions.append(error.VerifyFileSizeError(bc_filepath, actual_size, bc_filepath.size))
729 # if exceptions:
730 # _debug(f'bycatch: {exceptions[-1]!r}')
731 return exceptions
732
733 @property
734 def bycatch_files(self):
735 """
736 Files that only exist within a missing file's piece
737
738 It is important that these files are not read to maintain the correct
739 piece positions in the stream.
740 """
741 return tuple(self._bycatch_files)
plugin.video.jacktook/lib/vendor/torf/_torrent.py +1786 -0
……@@ -0,0 +1,1786 @@
1# This file is part of torf.
2#
3# torf is free software: you can redistribute it and/or modify it under the
4# terms of the GNU General Public License as published by the Free Software
5# Foundation, either version 3 of the License, or (at your option) any later
6# version.
7#
8# torf is distributed in the hope that it will be useful, but WITHOUT ANY
9# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
10# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
11#
12# You should have received a copy of the GNU General Public License
13# along with torf. If not, see <https://www.gnu.org/licenses/>.
14
15import base64
16import errno
17import hashlib
18import inspect
19import io
20import itertools
21import math
22import os
23import pathlib
24import re
25from collections import abc
26from datetime import datetime
27
28from lib.utils.torrent import flatbencode as bencode
29
30from . import __version__
31from . import _errors as error
32from . import _generate as generate
33from . import _reuse as reuse
34from . import _utils as utils
35
36_PACKAGE_NAME = __name__.split('.')[0]
37
38# os.sched_getaffinity() is only available on some Unix platforms.
39try:
40 NCORES = len(os.sched_getaffinity(0))
41except AttributeError:
42 import multiprocessing
43 NCORES = multiprocessing.cpu_count()
44
45DEFAULT_TORRENT_NAME = 'UNNAMED TORRENT'
46
47class Torrent():
48 """
49 Torrent metainfo representation
50
51 Create a new Torrent instance:
52
53 >>> from torf import Torrent
54 >>> torrent = Torrent('path/to/My Torrent',
55 ... trackers=['https://localhost:123/announce'],
56 ... comment='This is my first torrent. Be gentle.')
57
58 Convenient access to metainfo via properties:
59
60 >>> torrent.comment
61 'This is my first torrent. Be gentle.'
62 >>> torrent.comment = "This is my first torrent. Let's rock!"
63 >>> torrent.private = True
64
65 Full control over unencoded metainfo:
66
67 >>> torrent.metainfo['info']['private']
68 True
69 >>> torrent.metainfo['more stuff'] = {'foo': 12,
70 ... 'bar': ('x', 'y', 'z')}
71
72 Hash pieces and update progress once per second:
73
74 >>> def cb(torrent, filepath, pieces_done, pieces_total):
75 ... print(f'{pieces_done/pieces_total*100:3.0f} % done')
76 >>> success = torrent.generate(callback=cb, interval=1)
77 1 % done
78 2 % done
79 [...]
80 100 % done
81
82 Write torrent file:
83
84 >>> torrent.write('my_torrent.torrent')
85
86 Create magnet link:
87
88 >>> torrent.magnet()
89 'magnet:?xt=urn:btih:e167b1fbb42ea72f051f4f50432703308efb8fd1&dn=My+Torrent&xl=142631&tr=https%3A%2F%2Flocalhost%3A123%2Fannounce'
90
91 Read torrent from file:
92
93 >>> t = Torrent.read('my_torrent.torrent')
94 >>> t.comment
95 "This is my first torrent. Let's rock!"
96 >>> t.metainfo['more stuff']
97 {'bar': ['x', 'y', 'z'], 'foo': 12}
98 """
99
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):
108 self._path = None
109 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)}
114
115 self.piece_size_min = piece_size_min
116 self.piece_size_max = piece_size_max
117
118 self.trackers = trackers
119 self.webseeds = webseeds
120 self.httpseeds = httpseeds
121 self.private = private
122 self.comment = comment
123 self.creation_date = creation_date
124 self.created_by = created_by
125 self.source = source
126 self.randomize_infohash = randomize_infohash
127
128 self.exclude_globs = exclude_globs
129 self.exclude_regexs = exclude_regexs
130 self.include_globs = include_globs
131 self.include_regexs = include_regexs
132 self.path = path
133
134 # Values that are implicitly changed by setting self.path
135 if piece_size is not None:
136 self.piece_size = piece_size
137 if name is not None:
138 self.name = name
139
140 @property
141 def metainfo(self):
142 """
143 Unencoded torrent metainfo as mutable mapping
144
145 You can put anything in here as long as keys are convertable to
146 :class:`bytes` and values are convertable to :class:`bytes`,
147 :class:`int`, :class:`list` or :class:`dict`.
148
149 See also :meth:`convert` and :meth:`validate`.
150
151 The ``info`` key is guaranteed to exist.
152 """
153 if 'info' not in self._metainfo:
154 self._metainfo['info'] = {}
155 return self._metainfo
156
157 @property
158 def path(self):
159 """
160 File system path to torrent content
161
162 Files are filtered according to :attr:`exclude_globs`,
163 :attr:`exclude_regexs`, :attr:`include_globs` and
164 :attr:`include_regexs`.
165
166 Setting or manipulating this property updates
167 :attr:`metainfo`\\ ``['info']``:
168
169 - ``name``, ``piece length`` and ``files`` or ``length`` are set.
170 - ``pieces`` and ``md5sum`` are removed if they exist.
171
172 :raises ReadError: if :attr:`path` or any path underneath it is not
173 readable
174 """
175 return self._path
176
177 @path.setter
178 def path(self, value):
179 if value is None:
180 # Keep info about name and files, but forget where they are stored
181 self._path = None
182 self.metainfo['info'].pop('pieces', None)
183 else:
184 basepath = pathlib.Path(str(value))
185 filepaths = tuple(utils.File(fp, size=utils.real_size(fp))
186 for fp in utils.list_files(basepath))
187 self._set_files(filepaths, basepath)
188
189 @property
190 def location(self):
191 """
192 :attr:`path` without :attr:`name` or `None` if :attr:`path` is `None`
193 """
194 if self.path:
195 return self._path.parent
196
197 @property
198 def files(self):
199 """
200 List of relative paths in this torrent
201
202 Paths are :class:`File` objects and items are automatically
203 deduplicated. Every path starts with :attr:`name`.
204
205 Setting or manipulating this property updates
206 :attr:`metainfo`\\ ``['info']``:
207
208 - ``name``, ``piece length`` and ``files`` or ``length`` are set.
209 - ``pieces`` and ``md5sum`` are removed if they exist.
210
211 See :attr:`filepaths` for a list of file system paths.
212
213 :raises PathError: if any path is absolute
214 :raises CommonPathError: if not all files share a common parent
215 directory
216 :raises ValueError: if any file is not a :class:`File` object
217 """
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'])
226 else:
227 files = ()
228 return utils.Files(files, callback=self._files_changed)
229
230 def _files_changed(self, files):
231 self.files = files
232
233 @files.setter
234 def files(self, files):
235 if not isinstance(files, utils.Iterable):
236 raise ValueError(f'Not an Iterable: {files}')
237 for f in files:
238 if not isinstance(f, utils.File):
239 raise ValueError(f'Not a File object: {f}')
240 elif f.is_absolute():
241 raise error.PathError(f, msg='Not a relative path')
242
243 if not files:
244 self._set_files(files=())
245 else:
246 # os.path.commonpath() returns '' if there is no common path and
247 # raises ValueError if there are absolute and relative paths.
248 try:
249 basepath = os.path.commonpath(files)
250 except ValueError:
251 basepath = ''
252 if basepath == '':
253 raise error.CommonPathError(files)
254 self._set_files(files, pathlib.Path(basepath))
255
256 @property
257 def filepaths(self):
258 """
259 List of paths of existing files in :attr:`path` included in the torrent
260
261 Paths are :class:`Filepath` objects and items are automatically
262 deduplicated. Directories are resolved into a list of files.
263
264 Setting or manipulating this property updates
265 :attr:`metainfo`\\ ``['info']``:
266
267 - ``name``, ``piece length`` and ``files`` or ``length`` are set.
268 - ``pieces`` and ``md5sum`` are removed if they exist.
269
270 :raises ReadError: if any file path is not readable
271 """
272 filepaths = ()
273 if self.path is not None:
274 if self.mode == 'singlefile':
275 filepaths = (self.path,)
276 elif self.mode == 'multifile':
277 dirpath = self.path
278 filepaths = (os.path.join(dirpath, *fileinfo['path'])
279 for fileinfo in self.metainfo['info']['files'])
280 return utils.Filepaths(filepaths, callback=self._filepaths_changed)
281
282 def _filepaths_changed(self, filepaths):
283 self.filepaths = filepaths
284
285 @filepaths.setter
286 def filepaths(self, filepaths):
287 if not isinstance(filepaths, utils.Iterable):
288 raise ValueError(f'Not an Iterable: {filepaths}')
289
290 filepaths = utils.Filepaths(filepaths) # Resolve directories
291 if not filepaths:
292 self._set_files(files=())
293 else:
294 # Make all paths absolute so we can find the common path. Do not
295 # resolve symlinks so the user isn't confronted with unexpected
296 # paths in case of an error.
297 cwd = pathlib.Path.cwd()
298 filepaths_abs = tuple(fp if fp.is_absolute() else cwd / fp
299 for fp in filepaths)
300 try:
301 basepath = pathlib.Path(os.path.commonpath(filepaths_abs))
302 except ValueError:
303 raise error.CommonPathError(filepaths)
304 filepaths = tuple(utils.File(fp, size=utils.real_size(fp))
305 for fp in filepaths)
306 self._set_files(filepaths, basepath)
307
308 def _set_files(self, files, basepath=None):
309 """
310 Update ``name`` and ``files`` or ``length``, remove ``pieces`` and
311 ``md5sum`` in :attr:`metainfo`\\ ``['info']``
312
313 :param files: Sequence of :class:`File`
314 :param basepath: path-like that all paths in `files` start with; may be
315 ``None`` if ``files`` is empty
316 """
317 def abspath(p):
318 # Absolute path without resolved symlinks
319 if p.is_absolute():
320 return pathlib.Path(os.path.normpath(p))
321 else:
322 return pathlib.Path.cwd() / os.path.normpath(p)
323
324 def relpath_without_parent(p):
325 # Relative path without common parent directory
326 return pathlib.Path(abspath(p)).relative_to(abspath(basepath))
327
328 def relpath_with_parent(p):
329 # Relative path with common parent directory
330 return pathlib.Path(abspath(p)).relative_to(abspath(basepath).parent)
331
332 # 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'])
335 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'])
338 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)
342
343 info = self.metainfo['info']
344 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)
349 elif len(files) == 1 and files[0] == basepath:
350 # There is only one file and it is not in a directory.
351 # 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)
357 else:
358 if str(basepath) == os.curdir:
359 # Name of current working directory
360 name = pathlib.Path.cwd().name
361 elif str(basepath) == os.pardir:
362 # Name of logical parent directory
363 # NOTE: Path.resolve() returns the physical parent directory; if
364 # the parent directory is a symlink, we get an unexpected name
365 name = os.path.basename(os.path.dirname(os.getcwd()))
366 elif str(basepath).endswith(os.curdir) or str(basepath).endswith(os.pardir):
367 # Name of current/parent directory (logical parent, see NOTE above)
368 name = pathlib.Path(os.path.normpath(basepath)).name
369 else:
370 name = basepath.name
371
372 files_info = []
373 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)
381
382 # Set new path attribute if basepath exists
383 if basepath is not None and os.path.exists(basepath):
384 self._path = basepath
385 else:
386 self._path = None
387
388 # Calculate new piece size
389 self.piece_size = None
390
391 @property
392 def exclude_globs(self):
393 """
394 List of case-insensitive wildcard patterns to exclude
395
396 Include patterns take precedence over exclude patterns to allow
397 including files that match an exclude pattern.
398
399 Patterns are matched against paths in :attr:`files`.
400
401 ========== ================================
402 Wildcard Description
403 ========== ================================
404 ``*`` matches everything
405 ``?`` matches any single character
406 ``[SEQ]`` matches any character in ``SEQ``
407 ``[!SEQ]`` matches any char not in ``SEQ``
408 ========== ================================
409 """
410 return self._exclude['globs']
411
412 @exclude_globs.setter
413 def exclude_globs(self, value):
414 if not isinstance(value, utils.Iterable):
415 raise ValueError(f'Must be Iterable, not {type(value).__name__}: {value}')
416 self._exclude['globs'][:] = value
417
418 @property
419 def include_globs(self):
420 """
421 List of case-insensitive wildcard patterns to include
422
423 See :attr:`exclude_globs`.
424 """
425 return self._include['globs']
426
427 @include_globs.setter
428 def include_globs(self, value):
429 if not isinstance(value, utils.Iterable):
430 raise ValueError(f'Must be Iterable, not {type(value).__name__}: {value}')
431 self._include['globs'][:] = value
432
433 @property
434 def exclude_regexs(self):
435 """
436 List of regular expression patterns to exclude
437
438 Include patterns take precedence over exclude patterns to allow
439 including files that match an exclude pattern.
440
441 Patterns are matched against paths in :attr:`files`.
442
443 :raises re.error: if any regular expression is invalid
444 """
445 return self._exclude['regexs']
446
447 @exclude_regexs.setter
448 def exclude_regexs(self, value):
449 if not isinstance(value, utils.Iterable):
450 raise ValueError(f'Must be Iterable, not {type(value).__name__}: {value}')
451 self._exclude['regexs'][:] = value
452
453 @property
454 def include_regexs(self):
455 """
456 List of regular expression patterns to include
457
458 See :attr:`exclude_regexs`.
459 """
460 return self._include['regexs']
461
462 @include_regexs.setter
463 def include_regexs(self, value):
464 if not isinstance(value, utils.Iterable):
465 raise ValueError(f'Must be Iterable, not {type(value).__name__}: {value}')
466 self._include['regexs'][:] = value
467
468 def _filters_changed(self, _):
469 """Callback for MonitoredLists in Torrent._exclude"""
470 # Apply filters
471 if self.path is not None:
472 # Read file list from disk again
473 self.path = self.path
474 else:
475 # There are no existing files specified so we can just remove files
476 self.files = self.files
477
478 @property
479 def filetree(self):
480 """
481 :attr:`files` as a dictionary tree
482
483 Parent nodes are dictionaries and leaf nodes are :class:`File` objects.
484 The top node is always a dictionary with the single key :attr:`name`.
485
486 Example:
487
488 .. code:: python
489
490 {'Torrent': {'bar': {'baz.mp3': File('Torrent/bar/baz.mp3',
491 size=543210),
492 'baz.pdf': File('Torrent/bar/baz.pdf',
493 size=999)},
494 'foo.txt': File('Torrent/foo.txt',
495 size=123456)}}
496 """
497 tree = {} # Complete directory tree
498 paths = (tuple(f.parts) for f in self.files)
499 for path in paths:
500 dirpath = path[:-1] # Path without filename
501 filename = path[-1]
502 subtree = tree
503 for item in dirpath:
504 if item not in subtree:
505 subtree[item] = {}
506 subtree = subtree[item]
507 subtree[filename] = utils.File(path, size=self.partial_size(path))
508 return tree
509
510 @property
511 def name(self):
512 """
513 Name of the torrent
514
515 Default to last item in :attr:`path` or ``None`` if :attr:`path` is
516 ``None``.
517
518 If this property is set to ``None`` and :attr:`path` is not ``None``, it
519 is set to the default.
520
521 Setting this property sets or removes ``name`` in
522 :attr:`metainfo`\\ ``['info']``.
523 """
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)
527
528 @name.setter
529 def name(self, value):
530 if value is None:
531 self.metainfo['info'].pop('name', None)
532 self.name # Set default name
533 else:
534 self.metainfo['info']['name'] = str(value)
535
536 @property
537 def mode(self):
538 """
539 ``singlefile`` if this torrent contains one file that is not in a directory,
540 ``multifile`` if it contains one or more files in a directory, or
541 ``None`` if no content is specified (i.e. :attr:`files` is empty).
542 """
543 if 'length' in self.metainfo['info']:
544 return 'singlefile'
545 elif 'files' in self.metainfo['info']:
546 return 'multifile'
547
548 @property
549 def size(self):
550 """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'])
556 else:
557 return 0
558
559 def partial_size(self, path):
560 """
561 Return size of one or more files as specified in :attr:`metainfo`
562
563 :param path: Relative path within torrent, starting with :attr:`name`;
564 may point to file or directory
565 :type path: str, path-like or iterable
566
567 :raises PathError: if `path` is not known
568 """
569 if isinstance(path, str):
570 path = tuple(path.split(os.sep))
571 elif isinstance(path, os.PathLike):
572 path = tuple(path.parts)
573 elif isinstance(path, abc.Iterable):
574 path = tuple(str(part) for part in path)
575 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':
580 file_sizes = []
581 for info in self.metainfo['info']['files']:
582 this_path = (self.name,) + tuple(c for c in info['path'] if c)
583 if this_path == path:
584 # path points to file
585 return info['length']
586 elif utils.iterable_startswith(this_path, path):
587 # path points to directory
588 file_sizes.append(info['length'])
589 if file_sizes:
590 return sum(file_sizes)
591 raise error.PathError(os.path.join(*path), msg='Unknown path')
592
593 @property
594 def piece_size(self):
595 """
596 Length of each piece in bytes
597
598 If set to ``None`` and :attr:`size` is larger than 0, use the return
599 value of :attr:`calculate_piece_size`. If set to ``None`` and
600 :attr:`size` is smaller than 1, remove
601 :attr:`metainfo`\\ ``['info']``\\ ``['piece length']`` .
602
603 Setting this property sets or removes ``piece length`` in
604 :attr:`metainfo`\\ ``['info']``.
605 """
606 return self.metainfo['info'].get('piece length', 0)
607
608 @piece_size.setter
609 def piece_size(self, value):
610 if value is None:
611 if self.size <= 0:
612 self.metainfo['info'].pop('piece length', None)
613 return
614 else:
615 value = self.calculate_piece_size(
616 self.size,
617 min_size=self.piece_size_min,
618 max_size=self.piece_size_max,
619 )
620
621 try:
622 piece_length = int(value)
623 except (TypeError, ValueError):
624 raise ValueError(f'piece_size must be int, not {type(value).__name__}: {value!r}')
625 else:
626 if not utils.is_divisible_by_16_kib(piece_length):
627 raise error.PieceSizeError(piece_length)
628 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
633
634 @property
635 def piece_size_min(self):
636 """
637 Smallest allowed piece size
638
639 Setting :attr:`piece_size` to a smaller value raises
640 :class:`PieceSizeError`.
641 """
642 return self._piece_size_min
643
644 @piece_size_min.setter
645 def piece_size_min(self, piece_size_min):
646 if piece_size_min is None:
647 self._piece_size_min = type(self).piece_size_min_default
648 elif not utils.is_divisible_by_16_kib(piece_size_min):
649 raise error.PieceSizeError(piece_size_min)
650 else:
651 self._piece_size_min = int(piece_size_min)
652
653 @property
654 def piece_size_max(self):
655 """
656 Smallest allowed piece size
657
658 Setting :attr:`piece_size` to a smaller value raises
659 :class:`PieceSizeError`.
660 """
661 return self._piece_size_max
662
663 @piece_size_max.setter
664 def piece_size_max(self, piece_size_max):
665 if piece_size_max is None:
666 self._piece_size_max = type(self).piece_size_max_default
667 elif not utils.is_divisible_by_16_kib(piece_size_max):
668 raise error.PieceSizeError(piece_size_max)
669 else:
670 self._piece_size_max = int(piece_size_max)
671
672 piece_size_min_default = 16 * 1024 # 16 KiB
673 """
674 Smallest allowed piece size (default value)
675
676 Setting :attr:`piece_size` to a smaller value raises
677 :class:`PieceSizeError`.
678 """
679
680 piece_size_max_default = 16 * 1024 * 1024 # 16 MiB
681 """
682 Greatest allowed piece size (default value)
683
684 Setting :attr:`piece_size` to a greater value raises
685 :class:`PieceSizeError`.
686 """
687
688 @classmethod
689 def calculate_piece_size(cls, size, min_size=None, max_size=None):
690 """
691 Return the piece size for a total torrent size of ``size`` bytes
692
693 For torrents up to 1 GiB, the maximum number of pieces is 1024 which
694 means the maximum piece size is 1 MiB. With increasing torrent size
695 both the number of pieces and the maximum piece size are gradually
696 increased up to 10,240 pieces of 8 MiB. For torrents larger than 80 GiB
697 the piece size is :attr:`piece_size_max` with as many pieces as
698 necessary.
699
700 It is safe to override this method to implement a custom algorithm.
701
702 :param int min_size: Minimum piece size; defaults to
703 :attr:`Torrent.piece_size_min`
704 :param int max_size: Maximum piece size; defaults to
705 :attr:`Torrent.piece_size_max`
706
707 :return: calculated piece size
708 """
709 if size <= 2**30: # 1 GiB / 1024 pieces = 1 MiB max
710 pieces = size / 1024
711 elif size <= 4 * 2**30: # 4 GiB / 2048 pieces = 2 MiB max
712 pieces = size / 2048
713 elif size <= 6 * 2**30: # 6 GiB / 3072 pieces = 2 MiB max
714 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
718 pieces = size / 2048
719 elif size <= 32 * 2**30: # 32 GiB / 4096 pieces = 8 MiB max
720 pieces = size / 4096
721 elif size <= 64 * 2**30: # 64 GiB / 8192 pieces = 8 MiB max
722 pieces = size / 8192
723 else:
724 pieces = size / 10240
725
726 if min_size is None:
727 min_size = cls.piece_size_min_default
728 if max_size is None:
729 max_size = cls.piece_size_max_default
730
731 # Math is magic!
732 piece_size = 1 << max(0, math.ceil(math.log(pieces, 2)))
733 return int(min(max(piece_size, min_size), max_size))
734
735 @property
736 def pieces(self):
737 """Number of pieces the content is split into"""
738 size, piece_size = self.size, self.piece_size
739 if size and piece_size and size > 0 and piece_size > 0:
740 return math.ceil(size / piece_size)
741 else:
742 return 0
743
744 @property
745 def hashes(self):
746 """Tuple of SHA1 piece hashes as :class:`bytes`"""
747 hashes = self.metainfo['info'].get('pieces')
748 if isinstance(hashes, (bytes, bytearray)):
749 # Each hash is 20 bytes long
750 return tuple(bytes(hashes[pos : pos + 20])
751 for pos in range(0, len(hashes), 20))
752 else:
753 return ()
754
755 @property
756 def trackers(self):
757 """
758 List of tiers (lists) of announce URLs
759
760 http://bittorrent.org/beps/bep_0012.html
761
762 This is a smart list that ensures the proper list-of-lists structure,
763 validation and deduplication. You can set this property to a URL, an
764 iterable of URLs or an iterable of iterables of URLs (i.e. "tiers").
765
766 This property automatically sets :attr:`metainfo`\\ ``['announce']`` and
767 :attr:`metainfo`\\ ``['announce-list']`` when it is manipulated or set
768 according to these rules:
769
770 - If it contains a single URL, :attr:`metainfo`\\ ``['announce']`` is set
771 and :attr:`metainfo`\\ ``['announce-list']`` is removed if it exists.
772
773 - If it contains an iterable of URLs, :attr:`metainfo`\\ ``['announce']``
774 is set to the first URL and :attr:`metainfo`\\ ``['announce-list']`` is
775 set to a list of tiers, one tier for each URL.
776
777 - If it contains an iterable of iterables of URLs,
778 :attr:`metainfo`\\ ``['announce']`` is set to the first URL of the first
779 iterable and :attr:`metainfo`\\ ``['announce-list']`` is set to a list
780 of tiers, one tier for each iterable of URLs.
781
782 :raises URLError: if any of the announce URLs is invalid
783 :raises ValueError: if set to anything that isn't an iterable or a
784 string
785 """
786 tiers = list(self.metainfo.get('announce-list', ()))
787 announce = self.metainfo.get('announce', None)
788 flat_urls = tuple(url for tier in tiers for url in tier)
789 if announce is not None and announce not in flat_urls:
790 tiers.insert(0, [announce])
791 return utils.Trackers(tiers, callback=self._trackers_changed)
792
793 @trackers.setter
794 def trackers(self, value):
795 if value is None:
796 value = ()
797 if isinstance(value, abc.Iterable):
798 self._trackers_changed(utils.Trackers(value))
799 else:
800 raise ValueError(f'Must be Iterable, str or None, not {type(value).__name__}: {value}')
801
802 def _trackers_changed(self, trackers):
803 # Set "announce" to first tracker of first tier
804 try:
805 self.metainfo['announce'] = str(trackers[0][0])
806 except IndexError:
807 self.metainfo.pop('announce', None)
808
809 # Remove "announce-list" if there's only one tracker
810 if len(trackers.flat) <= 1:
811 self.metainfo.pop('announce-list', None)
812 else:
813 if 'announce-list' not in self.metainfo:
814 self.metainfo['announce-list'] = []
815 # Set announce-list without changing its identity
816 self.metainfo['announce-list'][:] = ([str(url) for url in tier]
817 for tier in trackers)
818
819 @property
820 def webseeds(self):
821 """
822 List of webseed URLs
823
824 http://bittorrent.org/beps/bep_0019.html
825
826 The list returned by this property automatically updates
827 :attr:`metainfo`\\ ``['url-list']`` when manipulated. Setting this
828 property sets :attr:`metainfo`\\ ``['url-list']``.
829
830 :raises URLError: if any URL is invalid
831 :raises ValueError: if set to anything that isn't an iterable or a
832 string
833 """
834 return utils.URLs(self.metainfo.get('url-list', ()),
835 callback=self._webseeds_changed)
836
837 @webseeds.setter
838 def webseeds(self, value):
839 if isinstance(value, str):
840 urls = utils.URLs((value,))
841 elif isinstance(value, abc.Iterable):
842 urls = utils.URLs(value)
843 elif value is None:
844 urls = utils.URLs(())
845 else:
846 raise ValueError(f'Must be Iterable, str or None, not {type(value).__name__}: {value}')
847 self._webseeds_changed(urls)
848
849 def _webseeds_changed(self, webseeds):
850 if webseeds:
851 self.metainfo['url-list'] = [str(url) for url in webseeds]
852 else:
853 self.metainfo.pop('url-list', None)
854
855 @property
856 def httpseeds(self):
857 """
858 List of webseed URLs
859
860 http://bittorrent.org/beps/bep_0017.html
861
862 The list returned by this property automatically updates
863 :attr:`metainfo`\\ ``['httpseeds']`` when manipulated. Setting this
864 property sets :attr:`metainfo`\\ ``['httpseeds']``.
865
866 :raises URLError: if any URL is invalid
867 :raises ValueError: if set to anything that isn't an iterable or a
868 string
869 """
870 return utils.URLs(self.metainfo.get('httpseeds', ()),
871 callback=self._httpseeds_changed)
872
873 @httpseeds.setter
874 def httpseeds(self, value):
875 if isinstance(value, str):
876 urls = utils.URLs((value,))
877 elif isinstance(value, abc.Iterable):
878 urls = utils.URLs(value)
879 elif value is None:
880 urls = utils.URLs(())
881 else:
882 raise ValueError(f'Must be Iterable, str or None, not {type(value).__name__}: {value}')
883 self._httpseeds_changed(urls)
884
885 def _httpseeds_changed(self, httpseeds):
886 if httpseeds:
887 self.metainfo['httpseeds'] = [str(url) for url in httpseeds]
888 else:
889 self.metainfo.pop('httpseeds', None)
890
891 @property
892 def private(self):
893 """
894 Whether torrent should use trackers exclusively for peer discovery
895
896 ``True`` or ``False`` if
897 :attr:`metainfo`\\ ``['info']``\\ ``['private']`` exists, ``None``
898 otherwise.
899 """
900 if 'private' in self.metainfo['info']:
901 return bool(self.metainfo['info']['private'])
902 else:
903 return None
904
905 @private.setter
906 def private(self, value):
907 if value is None:
908 self.metainfo['info'].pop('private', None)
909 else:
910 self.metainfo['info']['private'] = bool(value)
911
912 @property
913 def comment(self):
914 """
915 Comment string or ``None`` for no comment
916
917 Setting this property sets or removes :attr:`metainfo`\\ ``['comment']``.
918 """
919 return self.metainfo.get('comment', None)
920
921 @comment.setter
922 def comment(self, value):
923 if value is not None:
924 self.metainfo['comment'] = str(value)
925 else:
926 self.metainfo.pop('comment', None)
927
928 @property
929 def creation_date(self):
930 """
931 :class:`datetime.datetime` instance or ``None`` for no creation date
932
933 :class:`int` and :class:`float` are also allowed and converted with
934 :meth:`datetime.datetime.fromtimestamp`.
935
936 Setting this property sets or removes
937 :attr:`metainfo`\\ ``['creation date']``.
938 """
939 return self.metainfo.get('creation date', None)
940
941 @creation_date.setter
942 def creation_date(self, value):
943 if isinstance(value, (float, int)):
944 self.metainfo['creation date'] = datetime.fromtimestamp(value)
945 elif isinstance(value, datetime):
946 self.metainfo['creation date'] = value
947 elif not value:
948 self.metainfo.pop('creation date', None)
949 else:
950 raise ValueError(
951 'Must be None, int or datetime object, '
952 f'not {type(value).__name__}: {value!r}'
953 )
954
955 @property
956 def created_by(self):
957 """
958 Application name or ``None`` for no creator
959
960 Setting this property sets or removes
961 :attr:`metainfo`\\ ``['created by']``.
962 """
963 return self.metainfo.get('created by', None)
964
965 @created_by.setter
966 def created_by(self, value):
967 if value is not None:
968 self.metainfo['created by'] = str(value)
969 else:
970 self.metainfo.pop('created by', None)
971
972 @property
973 def source(self):
974 """
975 Source string or ``None`` for no source
976
977 Setting this property sets or removes
978 :attr:`metainfo`\\ ``['info']``\\ ``['created by']``.
979 """
980 return self.metainfo['info'].get('source', None)
981
982 @source.setter
983 def source(self, value):
984 if value is not None:
985 self.metainfo['info']['source'] = str(value)
986 else:
987 self.metainfo['info'].pop('source', None)
988
989 @property
990 def infohash(self):
991 """
992 SHA1 info hash
993
994 :raises MetainfoError: if :attr:`validate` fails or :attr:`metainfo` is
995 not bencodable
996 """
997 try:
998 # Try to calculate infohash
999 self.validate()
1000 try:
1001 info = utils.encode_dict(self.metainfo['info'])
1002 except ValueError as e:
1003 raise error.MetainfoError(e)
1004 else:
1005 return hashlib.sha1(bencode.encode(info)).hexdigest()
1006 except error.MetainfoError as e:
1007 # If we can't calculate infohash, see if it was explicitly specifed.
1008 # This is necessary to create a Torrent from a Magnet URI.
1009 try:
1010 return self._infohash
1011 except AttributeError:
1012 raise e
1013
1014 @property
1015 def infohash_base32(self):
1016 """Base 32 encoded SHA1 info hash"""
1017 return base64.b32encode(base64.b16decode(self.infohash.upper()))
1018
1019 @property
1020 def randomize_infohash(self):
1021 """
1022 Whether to ensure that :attr:`infohash` is always unique
1023
1024 This allows for cross-seeding without changing :attr:`piece_size`.
1025
1026 Setting this property to ``True`` sets
1027 :attr:`metainfo`\\ ``['info']``\\ ``['entropy']`` to a random integer.
1028 Setting it to ``False`` removes that field.
1029 """
1030 return bool(self.metainfo['info'].get('entropy', False))
1031
1032 @randomize_infohash.setter
1033 def randomize_infohash(self, value):
1034 if value:
1035 # According to BEP0003 "Integers have no size limitation", but some
1036 # parsers seem to have problems with large numbers.
1037 import random
1038 self.metainfo['info']['entropy'] = random.randint(int(-2e9), int(2e9))
1039 else:
1040 self.metainfo['info'].pop('entropy', None)
1041
1042 @property
1043 def is_ready(self):
1044 """Whether this torrent is ready to be exported to a file or magnet link"""
1045 try:
1046 self.validate()
1047 except error.MetainfoError:
1048 return False
1049 else:
1050 return True
1051
1052 def generate(self, threads=None, callback=None, interval=0):
1053 """
1054 Hash pieces and report progress to `callback`
1055
1056 This method sets :attr:`metainfo`\\ ``['info']``\\ ``['pieces']`` after
1057 all pieces are hashed successfully.
1058
1059 :param int threads: How many threads to use for hashing pieces or
1060 ``None`` to use one thread per available CPU core
1061 :param callable callback: Callable to report progress and/or abort
1062
1063 `callback` must accept 4 positional arguments:
1064
1065 1. Torrent instance (:class:`Torrent`)
1066 2. Path of the currently hashed file (:class:`str`)
1067 3. Number of hashed pieces (:class:`int`)
1068 4. Total number of pieces (:class:`int`)
1069
1070 If `callback` returns anything that is not ``None``, hashing is
1071 stopped.
1072 :param float interval: Minimum number of seconds between calls to
1073 `callback`; if 0, `callback` is called once per hashed piece
1074
1075 :raises PathError: if :attr:`path` contains only empty files/directories
1076 :raises ReadError: if :attr:`path` or any file beneath it is not
1077 readable
1078 :raises RuntimeError: if :attr:`path` is None
1079
1080 :return: ``True`` if all pieces were successfully hashed, ``False``
1081 otherwise
1082 """
1083 if self.path is None:
1084 raise RuntimeError('generate() called with no path specified')
1085 elif sum(utils.real_size(fp) for fp in self.filepaths) < 1:
1086 raise error.PathError(self.path, msg='Empty or all files excluded')
1087
1088 hasher_threads = threads or NCORES
1089
1090 # Read piece_size'd chunks from disk and send them to HasherPool
1091 reader = generate.Reader(torrent=self, queue_size=hasher_threads * 3)
1092
1093 # Multiple threads that get chunks from Reader, calculate the hashes,
1094 # and push them to a hash queue
1095 hashers = generate.HasherPool(
1096 hasher_threads=hasher_threads,
1097 piece_queue=reader.piece_queue,
1098 )
1099
1100 # Collect piece hashes from HasherPool and call `callback` for status
1101 # reporting/cancellation
1102 collector = generate.Collector(
1103 torrent=self,
1104 reader=reader,
1105 hashers=hashers,
1106 callback=generate.GenerateCallback(
1107 callback=callback,
1108 interval=interval,
1109 torrent=self,
1110 ),
1111 )
1112
1113 # Collect piece hashes
1114 piece_hashes = collector.collect()
1115 concatenated_piece_hashes = b''.join(piece_hashes)
1116 hashes_count = len(concatenated_piece_hashes) / 20
1117 if hashes_count == self.pieces:
1118 self.metainfo['info']['pieces'] = concatenated_piece_hashes
1119 return True
1120 elif hashes_count < self.pieces:
1121 # Hashing was cancelled
1122 return False
1123 else:
1124 raise RuntimeError('Unexpected number of hashes generated: '
1125 f'{hashes_count} instead of {self.pieces}')
1126
1127 def verify(self, path, threads=None, callback=None, interval=0):
1128 """
1129 Check if `path` contains all the data specified in this torrent
1130
1131 Generate hashes from file content and compare them to the ones stored in
1132 :attr:`metainfo`\\ ``['info']``\\ ``['pieces']``.
1133
1134 :param str path: Directory or file to read from
1135 :param int threads: How many threads to use for hashing pieces or
1136 ``None`` to use one thread per available CPU core
1137 :param callable callback: Callable to report progress and/or abort
1138
1139 `callback` must accept 7 positional arguments:
1140
1141 1. Torrent instance (:class:`Torrent`)
1142 2. File path in file system (:class:`str`)
1143 3. Number of checked pieces (:class:`int`)
1144 4. Total number of pieces (:class:`int`)
1145 5. Index of the current piece (:class:`int`)
1146 6. SHA1 hash of the current piece (:class:`bytes` or ``None``)
1147 7. Exception (:class:`TorfError` or ``None``)
1148
1149 If `callback` returns anything that is not ``None``, verification is
1150 stopped.
1151
1152 :param float interval: Minimum number of seconds between calls to
1153 `callback` (if 0, `callback` is called once per piece); this is
1154 ignored if an error is found
1155
1156 If a callback is specified, exceptions are not raised but passed to
1157 `callback` instead.
1158
1159 :raises VerifyContentError: if a file contains unexpected data
1160 :raises VerifyIsDirectoryError: if `path` is a directory and this
1161 torrent contains a single file
1162 :raises VerifyNotDirectoryError: if `path` is a file and this torrent
1163 contains a directory
1164 :raises ReadError: if a file is not readable
1165 :raises MetainfoError: if :meth:`validate` fails
1166
1167 :return: ``True`` if `path` is verified successfully, ``False``
1168 otherwise
1169 """
1170 # First make sure we are a valid torrent
1171 self.validate()
1172
1173 # Wrapper around callback function that compares hashes
1174 verify_callback = generate.VerifyCallback(
1175 callback=callback,
1176 interval=interval,
1177 torrent=self,
1178 path=path,
1179 )
1180
1181 def early_exception(exception):
1182 piece_index = 0
1183 pieces_done = 0
1184 pieces_total = self.pieces
1185 filepath = None
1186 piece_hash = None
1187 exceptions = (exception,)
1188 verify_callback(piece_index, pieces_done, pieces_total, filepath, piece_hash, exceptions)
1189
1190 if self.mode == 'singlefile' and os.path.isdir(path):
1191 early_exception(error.VerifyIsDirectoryError(path))
1192 return False
1193
1194 elif self.mode == 'multifile' and not os.path.isdir(path):
1195 early_exception(error.VerifyNotDirectoryError(path))
1196 return False
1197
1198 else:
1199 hasher_threads = threads or NCORES
1200
1201 # Read piece_size'd chunks from disk and send them to HasherPool
1202 reader = generate.Reader(
1203 torrent=self,
1204 queue_size=hasher_threads * 3,
1205 path=path,
1206 )
1207
1208 # Multiple threads that get chunks from Reader, calculate the hashes,
1209 # and push them to a hash queue
1210 hashers = generate.HasherPool(
1211 hasher_threads=hasher_threads,
1212 piece_queue=reader.piece_queue,
1213 )
1214
1215 # Collect piece hashes from HasherPool and call `callback` for status
1216 # reporting/cancellation
1217 collector = generate.Collector(
1218 torrent=self,
1219 reader=reader,
1220 hashers=hashers,
1221 callback=verify_callback,
1222 )
1223
1224 piece_hashes = collector.collect()
1225 return piece_hashes == self.hashes
1226
1227 def verify_filesize(self, path, callback=None):
1228 """
1229 Check if `path` has the expected file size
1230
1231 Walk through :attr:`files` and check if each file exists relative to
1232 `path`, is readable and has the correct size. Excess files in `path`
1233 are ignored.
1234
1235 This is fast and should find most manipulations, but :meth:`verify` is
1236 necessary to detect corruptions (e.g. due to bit rot).
1237
1238 :param str path: Directory or file to check
1239 :param callable callback: Callable to report progress and/or abort
1240
1241 `callback` must accept 6 positional arguments:
1242
1243 1. Torrent instance (:class:`Torrent`)
1244 2. File path in file system (:class:`str`)
1245 3. File path in torrent (:class:`str`)
1246 4. Number of checked files (:class:`int`)
1247 5. Total number of files (:class:`int`)
1248 6. Exception (:class:`TorfError`) or ``None``
1249
1250 If `callback` returns anything that is not ``None``, verification is
1251 stopped.
1252
1253 If a callback is specified, exceptions are not raised but passed to
1254 `callback` instead.
1255
1256 :raises VerifyFileSizeError: if a file has an unexpected size
1257 :raises VerifyIsDirectoryError: if `path` is a directory and this
1258 torrent contains a single file
1259 :raises ReadError: if any file's size can't be determined
1260 :raises MetainfoError: if :meth:`validate` fails
1261
1262 :return: ``True`` if `path` is verified successfully, ``False``
1263 otherwise
1264 """
1265 # First make sure we are a valid torrent
1266 self.validate()
1267
1268 # Generate an ordered list of file system paths and their corresponding
1269 # paths inside the torrent
1270 # NOTE: The last segment in `path` is supposed to be the torrent name so
1271 # we must remove the name from each `file`. This allows
1272 # verification of any renamed file/directory against a torrent.
1273 filepaths = tuple(
1274 (
1275 utils.File([path, *file.parts[1:]], file.size),
1276 file,
1277 )
1278 for file in self.files
1279 )
1280 files_total = len(filepaths)
1281
1282 def cancel(file_index, exception):
1283 if callback:
1284 fs_filepath = filepaths[file_index][0]
1285 torrent_filepath = filepaths[file_index][1]
1286 files_done = file_index + 1
1287 return_value = callback(self, fs_filepath, torrent_filepath,
1288 files_done, files_total, exception)
1289 if return_value is not None:
1290 return True
1291 elif exception:
1292 # Raise exception if there is no callback to handle it
1293 raise exception
1294 else:
1295 return False
1296
1297 exception = None
1298
1299 # If we expect a file, check if path is a file. We don't need to check
1300 # for a directory if we expect one because we are iterating over files
1301 # (filepaths), so the path "foo/bar/baz" will result in a ReadError if
1302 # "foo" or "foo/bar" is a file.
1303 if self.mode == 'singlefile' and os.path.isdir(path):
1304 exception = error.VerifyIsDirectoryError(path)
1305 cancel(file_index=0, exception=exception)
1306 return False
1307
1308 for file_index, (fs_filepath, torrent_filepath) in enumerate(filepaths):
1309 # Check if path exists
1310 if not os.path.exists(fs_filepath):
1311 exception = error.ReadError(errno.ENOENT, fs_filepath)
1312 if cancel(file_index, exception):
1313 return False
1314 else:
1315 continue
1316
1317 # Check file size
1318 fs_filepath_size = utils.real_size(fs_filepath)
1319 expected_size = self.partial_size(torrent_filepath)
1320 if fs_filepath_size != expected_size:
1321 exception = error.VerifyFileSizeError(fs_filepath, fs_filepath_size, expected_size)
1322 if cancel(file_index, exception):
1323 return False
1324 else:
1325 continue
1326
1327 # Report no error for current file
1328 if cancel(file_index, exception=None):
1329 return False
1330
1331 if exception:
1332 # `exception` is just an indicator of success/failure. At this point
1333 # it was already raised or passed to cancel().
1334 return False
1335 else:
1336 return True
1337
1338 def validate(self):
1339 """
1340 Check if all mandatory keys exist in :attr:`metainfo` and all standard keys
1341 have correct types
1342
1343 References:
1344 | http://bittorrent.org/beps/bep_0003.html
1345 | https://wiki.theory.org/index.php/BitTorrentSpecification#Metainfo_File_Structure
1346
1347 :raises MetainfoError: if :attr:`metainfo` would not generate a valid
1348 torrent file or magnet link
1349 """
1350 md = self.metainfo
1351 info = md['info']
1352
1353 # 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:
1368 raise error.MetainfoError("['info']['pieces'] is empty")
1369
1370 elif len(info['pieces']) % 20 != 0:
1371 raise error.MetainfoError("length of ['info']['pieces'] is not divisible by 20")
1372
1373 elif 'length' in info and 'files' in info:
1374 raise error.MetainfoError("['info'] includes both 'length' and 'files'")
1375
1376 elif 'length' in info:
1377 # 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)
1380
1381 # Validate expected number of pieces
1382 piece_count = int(len(info['pieces']) / 20)
1383 exp_piece_count = math.ceil(info['length'] / info['piece length'])
1384 if piece_count != exp_piece_count:
1385 raise error.MetainfoError(f'Expected {exp_piece_count} pieces but there are {piece_count}')
1386
1387 if self.path is not None:
1388 # Check if filepath actually points to a file
1389 if not os.path.isfile(self.path):
1390 raise error.MetainfoError(f"Metainfo includes {self.path} as file, but it is not a file")
1391
1392 # Check if size matches
1393 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}")
1397
1398 elif 'files' in info:
1399 # 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,))
1408
1409 # - validate() should ensure that ['info']['pieces'] is math.ceil(self.size /
1410 # 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'])
1414 if piece_count != exp_piece_count:
1415 raise error.MetainfoError(f'Expected {exp_piece_count} pieces but there are {piece_count}')
1416
1417 if self.path is not None:
1418 # Check if filepath actually points to a directory
1419 if not os.path.isdir(self.path):
1420 raise error.MetainfoError(f"Metainfo includes {self.path} as directory, but it is not a directory")
1421
1422 for i,fileinfo in enumerate(info['files']):
1423 filepath = os.path.join(self.path, os.path.join(*fileinfo['path']))
1424
1425 # Check if filepath exists and is a file
1426 if not os.path.exists(filepath):
1427 raise error.MetainfoError(f"Metainfo includes file that doesn't exist: {filepath}")
1428 if not os.path.isfile(filepath):
1429 raise error.MetainfoError(f"Metainfo includes file that isn't a file: {filepath}")
1430
1431 # Check if sizes match
1432 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}")
1436
1437 else:
1438 raise error.MetainfoError("Missing 'length' or 'files' in 'info'")
1439
1440 def convert(self):
1441 """
1442 Return :attr:`metainfo` with all keys encoded to :class:`bytes` and all
1443 values encoded to :class:`bytes`, :class:`int`, :class:`list` or
1444 :class:`OrderedDict`
1445
1446 :raises MetainfoError: if a value cannot be converted properly
1447 """
1448 try:
1449 return utils.encode_dict(self.metainfo)
1450 except ValueError as e:
1451 raise error.MetainfoError(e)
1452
1453 def dump(self, validate=True):
1454 """
1455 Create bencoded :attr:`metainfo` (i.e. the content of a torrent file)
1456
1457 :param bool validate: Whether to run :meth:`validate` first
1458
1459 :return: :attr:`metainfo` as bencoded :class:`bytes`
1460 """
1461 if validate:
1462 self.validate()
1463 return bencode.encode(self.convert())
1464
1465 def write_stream(self, stream, validate=True):
1466 """
1467 Write :attr:`metainfo` to a file-like object
1468
1469 :param stream: Writable file-like object (e.g. :class:`io.BytesIO`)
1470 :param bool validate: Whether to run :meth:`validate` first
1471
1472 :raises WriteError: if writing to `stream` fails
1473 :raises MetainfoError: if :attr:`metainfo` is invalid
1474 """
1475 content = self.dump(validate=validate)
1476 try:
1477 # Remove existing data from stream *after* dump() didn't raise
1478 # anything so we don't destroy it prematurely.
1479 if stream.seekable():
1480 stream.seek(0)
1481 stream.truncate(0)
1482 stream.write(content)
1483 except OSError as e:
1484 raise error.WriteError(e.errno)
1485
1486 def write(self, filepath, validate=True, overwrite=False):
1487 """
1488 Write :attr:`metainfo` to torrent file
1489
1490 :param filepath: Path of the torrent file
1491 :param bool validate: Whether to run :meth:`validate` first
1492 :param bool overwrite: Whether to silently overwrite `filepath` (only
1493 after all pieces were hashed successfully)
1494
1495 :raises WriteError: if writing to `filepath` fails
1496 :raises MetainfoError: if :attr:`metainfo` is invalid
1497 """
1498 if not overwrite and os.path.exists(filepath):
1499 raise error.WriteError(errno.EEXIST, filepath)
1500
1501 # Get file content before opening the file in case there are errors like
1502 # incomplete metainfo
1503 content = io.BytesIO()
1504 self.write_stream(content, validate=validate)
1505 content.seek(0)
1506 try:
1507 with open(filepath, 'wb') as f:
1508 f.write(content.read())
1509 except OSError as e:
1510 raise error.WriteError(e.errno, filepath)
1511
1512 def magnet(self, name=True, size=True, trackers=True, tracker=False):
1513 """
1514 :class:`Magnet` instance
1515
1516 :param bool name: Whether to include the name
1517 :param bool size: Whether to include the size
1518 :param bool trackers: Whether to include all trackers
1519 :param bool tracker: Whether to include only the first tracker of the
1520 first tier (overrides `trackers`)
1521
1522 :raises MetainfoError: if :attr:`metainfo` is invalid
1523 """
1524 kwargs = {'xt': 'urn:btih:' + self.infohash}
1525 if name:
1526 kwargs['dn'] = self.name
1527 if size:
1528 kwargs['xl'] = self.size
1529
1530 if tracker:
1531 kwargs['tr'] = (self.trackers[0][0],)
1532 elif trackers:
1533 kwargs['tr'] = (url
1534 for tier in self.trackers
1535 for url in tier)
1536
1537 if self.webseeds is not None:
1538 kwargs['ws'] = self.webseeds
1539
1540 # Prevent circular import issues
1541 from ._magnet import Magnet
1542 return Magnet(**kwargs)
1543
1544 # Maximum number of bytes that read() reads from torrent files. This limit
1545 # exists because we don't want to read gigabytes before raising an error.
1546 MAX_TORRENT_FILE_SIZE = int(10e6) # 10MB
1547
1548 @classmethod
1549 def read_stream(cls, stream, validate=True):
1550 """
1551 Read torrent metainfo from file-like object
1552
1553 :param stream: Readable file-like object (e.g. :class:`io.BytesIO`)
1554 :param bool validate: Whether to run :meth:`validate` on the new Torrent
1555 instance
1556
1557 NOTE: If the "info" field is not a dictionary,
1558 :class:`MetainfoError` is raised even if `validate` is set to
1559 False
1560
1561 :raises ReadError: if reading from `stream` fails
1562 :raises BdecodeError: if `stream` does not produce a valid bencoded byte
1563 sequence
1564 :raises MetainfoError: if `validate` is `True` and the read metainfo is
1565 invalid
1566
1567 :return: New :class:`Torrent` instance
1568 """
1569 try:
1570 content = stream.read(cls.MAX_TORRENT_FILE_SIZE)
1571 except OSError as e:
1572 raise error.ReadError(e.errno)
1573 else:
1574 try:
1575 metainfo_enc = bencode.decode(content)
1576 except (bencode.DecodingError, ValueError):
1577 raise error.BdecodeError()
1578 else:
1579 if not isinstance(metainfo_enc, abc.Mapping):
1580 raise error.BdecodeError()
1581
1582 # Extract 'pieces' from metainfo before decoding because it's the
1583 # 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')
1588 metainfo = utils.decode_dict(metainfo_enc)
1589 metainfo['info']['pieces'] = pieces
1590 else:
1591 metainfo = utils.decode_dict(metainfo_enc)
1592
1593 # "info" must be a dictionary. If validation is not wanted, it's OK
1594 # if it doesn't exist because the "metainfo" property will add it
1595 # automatically.
1596 utils.assert_type(metainfo, ('info',), (dict,), must_exist=validate)
1597
1598 torrent = cls()
1599 torrent._metainfo = metainfo
1600
1601 # Convert "creation date" to datetime.datetime and "private" to
1602 # 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']
1607
1608 if validate:
1609 torrent.validate()
1610
1611 return torrent
1612
1613 @classmethod
1614 def read(cls, filepath, validate=True):
1615 """
1616 Read torrent metainfo from file
1617
1618 :param filepath: Path of the torrent file
1619 :param bool validate: Whether to run :meth:`validate` on the new Torrent
1620 instance
1621
1622 :raises ReadError: if reading from `filepath` fails
1623 :raises BdecodeError: if `filepath` does not contain a valid bencoded byte
1624 sequence
1625 :raises MetainfoError: if `validate` is `True` and the read metainfo is
1626 invalid
1627
1628 :return: New :class:`Torrent` instance
1629 """
1630 try:
1631 with open(filepath, 'rb') as f:
1632 return cls.read_stream(f, validate=validate)
1633 except (OSError, error.ReadError) as e:
1634 raise error.ReadError(e.errno, filepath)
1635 except error.BdecodeError:
1636 raise error.BdecodeError(filepath)
1637
1638 def copy(self):
1639 """Create a new :class:`Torrent` instance with the same metainfo"""
1640 from copy import deepcopy
1641 cp = type(self)()
1642 cp._metainfo = deepcopy(self._metainfo)
1643 return cp
1644
1645 def reuse(self, path, callback=None, interval=0):
1646 """
1647 Copy ``pieces`` and ``piece length`` from existing torrent
1648
1649 If `path` is a directory, it is searched recursively for a matching
1650 ``.torrent`` file. Otherwise, it is :meth:`read` as a ``.torrent`` file.
1651
1652 `path` may also be an iterable, in which case each item is treated as
1653 described above until a match is found.
1654
1655 A matching torrent is found by searching for a torrent with the same
1656 :attr:`name` and :attr:`files` (file size must also match). If a match
1657 is found, compare three piece hashes per file to reduce the risk of a
1658 false positive.
1659
1660 .. warning:: This should almost always work in practice, but a false
1661 positive match is theoretically possible, and there is no way to
1662 avoid that.
1663
1664 .. note:: This will likely change :attr:`infohash` and turn this
1665 instance into a new torrent.
1666
1667 :param path: Path to torrent file or directory or iterable of
1668 file/directory paths
1669 :param callable callback: Callable to report progress and/or abort
1670
1671 `callback` must accept 6 positional arguments:
1672
1673 1. Torrent instance (:class:`Torrent`)
1674 2. Torrent file path (:class:`str`) or ``None``
1675 3. Number of checked torrent files (:class:`int`)
1676 4. Total number of torrent files (:class:`int`)
1677 5. Status indicator:
1678
1679 ``False`` - File name or size does not match
1680
1681 ``True`` - File name and size matches
1682
1683 ``None`` - Hashing file and comparing piece hashes
1684
1685 6. Exception (:class:`TorfError`) or ``None``
1686
1687 If `callback` returns anything that is not ``None``, stop searching.
1688 :param float interval: Minimum number of seconds between calls to
1689 `callback`; if 0, `callback` is called for each torrent file;
1690 `callback` is always called if `exception` is not ``None``
1691
1692 :raises ReadError: if reading a torrent file fails
1693 :raises BdecodeError: if parsing a torrent file fails
1694 :raises MetainfoError: if a torrent file contains invalid or
1695 insufficient metadata
1696
1697 :return: `True` if a matching torrent was found, `False` otherwise
1698 """
1699 if not self.path:
1700 raise RuntimeError('reuse() called with no path specified')
1701 elif not self.files:
1702 raise RuntimeError('reuse() called while file list is empty')
1703
1704 if isinstance(path, (str, pathlib.PurePath)):
1705 paths = [path]
1706 elif isinstance(path, abc.Iterable):
1707 paths = tuple(path)
1708 else:
1709 raise ValueError(f'Invalid path argument: {path!r}')
1710
1711 torrent_file_items = reuse.find_torrent_files(*paths, max_file_size=self.MAX_TORRENT_FILE_SIZE)
1712 maybe_call_callback = reuse.ReuseCallback(
1713 callback=callback,
1714 interval=interval,
1715 torrent=self,
1716 torrent_files_total=torrent_file_items.total,
1717 )
1718
1719 for candidate_path, files_done, exception in torrent_file_items:
1720 try:
1721 if candidate_path:
1722 candidate = Torrent.read(candidate_path)
1723 elif exception:
1724 raise exception
1725 else:
1726 raise RuntimeError('Both candidate_path and exception are None?!')
1727
1728 except (error.ReadError, error.BdecodeError, error.MetainfoError) as e:
1729 cancelled = maybe_call_callback(candidate_path, files_done, False, e)
1730 if cancelled is not None:
1731 break
1732
1733 else:
1734 assert exception is None
1735 if reuse.is_file_match(self, candidate):
1736 cancelled = maybe_call_callback(candidate_path, files_done, None, exception)
1737 if cancelled is not None:
1738 break
1739
1740 if reuse.is_content_match(self, candidate):
1741 maybe_call_callback(candidate_path, files_done, True, exception)
1742 reuse.copy(candidate, self)
1743 return True
1744 else:
1745 cancelled = maybe_call_callback(candidate_path, files_done, False, exception)
1746 if cancelled is not None:
1747 break
1748
1749 else:
1750 cancelled = maybe_call_callback(candidate_path, files_done, False, exception)
1751 if cancelled is not None:
1752 break
1753
1754 return False
1755
1756 def __repr__(self):
1757 sig = inspect.signature(self.__init__)
1758 args = []
1759
1760 def get_class_default(name):
1761 if hasattr(type(self), f'{param.name}_default'):
1762 return getattr(type(self), f'{param.name}_default')
1763 elif hasattr(type(self), param.name):
1764 return getattr(type(self), param.name)
1765
1766 for param in sig.parameters.values():
1767 value = getattr(self, param.name)
1768 default = param.default
1769 if default is param.empty:
1770 # Positional argument
1771 args.append(repr(value))
1772 elif (
1773 value
1774 # Keyword argument value is different from default?
1775 and default != value
1776 # Keyword argument value is different from class default
1777 and value != get_class_default(param.name)
1778 ):
1779 args.append(f'{param.name}={value!r}')
1780 return type(self).__name__ + '(' + ', '.join(args) + ')'
1781
1782 def __eq__(self, other):
1783 if isinstance(other, type(self)):
1784 return self._metainfo == other._metainfo
1785 else:
1786 return NotImplemented
plugin.video.jacktook/lib/vendor/torf/_utils.py +793 -0
……@@ -0,0 +1,793 @@
1# This file is part of torf.
2#
3# torf is free software: you can redistribute it and/or modify it under the
4# terms of the GNU General Public License as published by the Free Software
5# Foundation, either version 3 of the License, or (at your option) any later
6# version.
7#
8# torf is distributed in the hope that it will be useful, but WITHOUT ANY
9# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
10# A PARTICULAR PURPOSE. See the GNU General Public License for more details.
11#
12# You should have received a copy of the GNU General Public License
13# along with torf. If not, see <https://www.gnu.org/licenses/>.
14
15import abc
16import collections
17import contextlib
18import errno
19import fnmatch
20import functools
21import http.client
22import itertools
23import math
24import os
25import pathlib
26import re
27import socket
28import typing
29import urllib.error
30import urllib.parse
31import urllib.request
32from datetime import datetime
33from urllib.parse import quote_plus as urlquote # noqa: F401
34
35from . import _errors as error
36
37
38def is_divisible_by_16_kib(num):
39 """Return whether `num` is divisible by 16384 and positive"""
40 if num <= 0:
41 return False
42 return num % 16384 == 0
43
44def iterable_startswith(a, b):
45 a_len = len(a)
46 for i, b_item in enumerate(b):
47 if i >= a_len:
48 # a can't start with b if b is longer than a
49 return False
50 if a[i] != b_item:
51 return False
52 return True
53
54def flatten(items):
55 for item in items:
56 if isinstance(item, Iterable):
57 yield from flatten(item)
58 else:
59 yield item
60
61_md5sum_regex = re.compile(r'^[0-9a-fA-F]{32}$')
62def is_md5sum(value):
63 return bool(_md5sum_regex.match(value))
64
65
66def real_size(path):
67 """
68 Return size for `path`, which is a (link to a) file or directory
69
70 Raise ReadError on failure
71 """
72 if os.path.isdir(os.path.realpath(path)):
73 def onerror(exc):
74 raise error.ReadError(getattr(exc, 'errno', None),
75 getattr(exc, 'filename', None))
76
77 size = 0
78 walker = os.walk(path, followlinks=True, onerror=onerror)
79 for dirpath,dirnames,filenames in walker:
80 for filename in filenames:
81 filepath = os.path.join(dirpath, filename)
82 size += os.path.getsize(filepath)
83 return size
84 else:
85 try:
86 return os.path.getsize(path)
87 except OSError as exc:
88 raise error.ReadError(getattr(exc, 'errno', None),
89 getattr(exc, 'filename', None))
90
91def list_files(path):
92 """
93 Return list of sorted file paths in `path`
94
95 Raise ReadError if `path` or any file or directory underneath it is not
96 readable.
97 """
98 def assert_readable(path):
99 os_supports_effective_ids = os.access in os.supports_effective_ids
100 if not os.access(path, os.R_OK, effective_ids=os_supports_effective_ids):
101 raise error.ReadError(errno.EACCES, path)
102
103 if os.path.isfile(path):
104 assert_readable(path)
105 return [path]
106 else:
107 def onerror(exc):
108 raise error.ReadError(getattr(exc, 'errno', None),
109 getattr(exc, 'filename', None))
110 filepaths = []
111 for dirpath, dirnames, filenames in os.walk(path, onerror=onerror):
112 for filename in filenames:
113 filepath = os.path.join(dirpath, filename)
114 assert_readable(filepath)
115 filepaths.append(filepath)
116 return list(sorted(filepaths, key=lambda fp: str(fp).casefold()))
117
118
119def filter_files(items, getter=lambda f: f, hidden=True, empty=True,
120 exclude=(), include=()):
121 """
122 Return reduced copy of `items`
123
124 items: Iterable of file paths or abritrary objects that `getter` can turn
125 into a a file path
126 getter: Callable that takes an item of `filepaths` and returns a file path
127 exclude: Sequence of regular expressions or strings with wildcard characters
128 (see `fnmatch`) that are matched against full paths
129 include: Same as `exclude`, but instead of removing files, matching patterns
130 keep files even if they match a pattern in `excluude
131 hidden: Whether to include hidden files
132 empty: Whether to include empty files
133 """
134 def is_hidden(path):
135 for name in str(path).split(os.sep):
136 if name != '.' and name != '..' and name and name[0] == '.':
137 return True
138 return False
139
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))):
145 # Include patterns take precedence over exclude pattersn
146 if any(r.search(str(path)) for r in in_regexs):
147 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):
153 return True
154 return False
155
156 items = tuple(items)
157 filepaths = tuple(getter(i) for i in items)
158 try:
159 basepath = pathlib.Path(os.path.commonpath(filepaths))
160 except ValueError:
161 basepath = pathlib.Path().cwd()
162
163 items_filtered = []
164 for item in items:
165 filepath = getter(item)
166 relpath_without_base = pathlib.Path(os.path.relpath(filepath, basepath))
167 relpath_with_base = pathlib.Path(basepath.parent, filepath)
168 # Exclude hidden files and directories, but not hidden directories in
169 # `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):
177 continue
178 else:
179 items_filtered.append(item)
180 return items_filtered
181
182
183class MonitoredList(collections.abc.MutableSequence):
184 """List with change callback"""
185 def __init__(self, items=(), callback=None, type=None):
186 self._items = []
187 self._type = type
188 self._callback = callback
189 with self._callback_disabled():
190 self.replace(items)
191
192 @contextlib.contextmanager
193 def _callback_disabled(self):
194 cb = self._callback
195 self._callback = None
196 yield
197 self._callback = cb
198
199 def __getitem__(self, index):
200 return self._items[index]
201
202 def __delitem__(self, index):
203 del self._items[index]
204 if self._callback is not None:
205 self._callback(self)
206
207 def _coerce(self, value):
208 if self._type is not None:
209 return self._type(value)
210 else:
211 return value
212
213 def _filter_func(self, item):
214 if item not in self._items:
215 return item
216
217 def __setitem__(self, index, value):
218 if isinstance(value, Iterable):
219 value = map(self._filter_func, map(self._coerce, value))
220 else:
221 value = self._filter_func(self._coerce(value))
222 self._items[index] = value
223 if self._callback is not None:
224 self._callback(self)
225
226 def insert(self, index, value):
227 value = self._filter_func(self._coerce(value))
228 if value is not None:
229 self._items.insert(index, value)
230 if self._callback is not None:
231 self._callback(self)
232
233 def replace(self, items):
234 if not isinstance(items, Iterable):
235 raise ValueError(f'Not an iterable: {items!r}')
236 # Don't clear list before we know all new values are valid
237 items = tuple(map(self._coerce, items))
238 self._items.clear()
239 with self._callback_disabled():
240 self.extend(items)
241 if self._callback is not None:
242 self._callback(self)
243
244 def clear(self):
245 self._items.clear()
246 if self._callback is not None:
247 self._callback(self)
248
249 def __len__(self):
250 return len(self._items)
251
252 def __eq__(self, other):
253 if isinstance(other, type(self)):
254 return frozenset(other._items) == frozenset(self._items)
255 elif isinstance(other, collections.abc.Iterable):
256 return (len(other) == len(self._items) and
257 all(item in self._items for item in other))
258 else:
259 return False
260
261 def __ne__(self, other):
262 return not self.__eq__(other)
263
264 def __add__(self, other):
265 if isinstance(other, type(self)):
266 items = self._items + other._items
267 elif isinstance(other, Iterable):
268 items = self._items + list(other)
269 else:
270 items = self._items + [other]
271 return type(self)(items, callback=self._callback)
272
273 def __repr__(self):
274 return repr(self._items)
275
276
277class File(os.PathLike):
278 """Path-like that also stores the file size"""
279
280 def __fspath__(self):
281 return str(self._path)
282
283 def __reduce__(self):
284 # __reduce__() is needed to properly pickle File objects
285 state = (
286 # Preserve positional and keyword arguments
287 functools.partial(
288 self.__class__,
289 os.path.join(*self._path.parts),
290 size=self._size,
291 ),
292 # Mandatory positional args (already preserved by partial())
293 (),
294 )
295 return state
296
297 def __init__(self, path, size):
298 if isinstance(path, str):
299 self._path = pathlib.Path(path)
300 elif isinstance(path, os.PathLike):
301 self._path = path
302 elif isinstance(path, collections.abc.Iterable):
303 self._path = pathlib.Path(*path)
304 else:
305 raise ValueError(f'Path must be str, PathLike or Iterable, not {type(path).__name__}: {path}')
306
307 try:
308 self._size = int(size)
309 except (ValueError, TypeError):
310 raise ValueError(f'Size must be int, not {type(size).__name__}: {size}')
311
312 @property
313 def size(self):
314 return self._size
315
316 def __getattr__(self, name):
317 return getattr(self._path, name)
318
319 def __str__(self):
320 return str(self._path)
321
322 def __eq__(self, other):
323 if type(other) is type(self):
324 return self._path == other._path and self._size == other._size
325 elif isinstance(other, os.PathLike):
326 return self._path == other
327 else:
328 return NotImplemented
329
330 def __hash__(self):
331 return hash((self._path, self._size))
332
333 def __gt__(self, other):
334 if not isinstance(other, type(self)):
335 return NotImplemented
336 return self._path > other._path
337
338 def __lt__(self, other):
339 if not isinstance(other, type(self)):
340 return NotImplemented
341 return self._path < other._path
342
343 def __ge__(self, other):
344 if not isinstance(other, type(self)):
345 return NotImplemented
346 return self._path >= other._path
347
348 def __le__(self, other):
349 if not isinstance(other, type(self)):
350 return NotImplemented
351 return self._path <= other._path
352
353 def __repr__(self):
354 return f'{type(self).__name__}({repr(str(self._path))}, size={self._size})'
355
356
357class Files(MonitoredList):
358 """Deduplicated list of :class:`Files` objects"""
359 def __init__(self, files, callback=None):
360 if isinstance(files, str):
361 files = (files,)
362 else:
363 files = flatten(files)
364 super().__init__(files, callback=callback, type=File)
365
366 def _coerce(self, value):
367 if not isinstance(value, self._type):
368 raise ValueError(f'Not a File object: {value} ({type(value).__name__})')
369 else:
370 return value
371
372
373class Filepath(type(pathlib.Path())):
374 """Path-like that makes relative paths equal to their absolute versions"""
375 @classmethod
376 def _realpath(cls, path):
377 if os.path.islink(path):
378 return os.path.realpath(str(path))
379 elif os.path.isabs(path):
380 return str(path)
381 else:
382 return os.path.join(os.getcwd(), str(path))
383
384 def __eq__(self, other):
385 # Use fast cached path if possible
386 if isinstance(other, Filepath):
387 return hash(self) == hash(other)
388 else:
389 return self._realpath(self) == self._realpath(other)
390
391 def __hash__(self):
392 try:
393 return self.__hash
394 except AttributeError:
395 self.__hash = hash(self._realpath(self))
396 return self.__hash
397
398
399class Filepaths(MonitoredList):
400 """Deduplicated list of :class:`Filepath` objects with change callback"""
401 def __init__(self, filepaths, callback=None):
402 if isinstance(filepaths, str):
403 filepaths = (filepaths,)
404 else:
405 filepaths = list(flatten(filepaths))
406 super().__init__(filepaths, callback=callback, type=Filepath)
407
408 def __setitem__(self, index, path):
409 path = self._coerce(path)
410 # Remove files that are equal to or start with `path`. This removes
411 # directories recursively. If `path` exists as a file, it is removed
412 # and then added again.
413 path_removed = False
414 for f in tuple(self._items):
415 if path == f or path in f.parents:
416 self._items.remove(f)
417 path_removed = True
418 if path.is_dir():
419 self.insert(index, path)
420 else:
421 if path_removed:
422 super().insert(index, path)
423 else:
424 super().__setitem__(index, path)
425
426 def insert(self, index, path):
427 path = self._coerce(path)
428 try:
429 path_is_dir = path.is_dir()
430 except OSError as exc:
431 raise error.ReadError(getattr(exc, 'errno', None),
432 getattr(exc, 'filename', None))
433 if path_is_dir:
434 # Add files in directory recursively
435 with self._callback_disabled():
436 for i,child in enumerate(sorted(path.iterdir())):
437 self.insert(index + i, child)
438 if self._callback is not None:
439 self._callback(self)
440 else:
441 super().insert(index, path)
442
443
444def is_url(url):
445 """Return whether `url` is a valid URL"""
446 try:
447 u = urllib.parse.urlparse(url)
448 u.port # Trigger 'invalid port' exception
449 except Exception:
450 return False
451 else:
452 if not u.scheme or not u.netloc:
453 return False
454 return True
455
456class URL(str):
457 def __new__(cls, s):
458 return super().__new__(cls, str(s).replace(' ', '+'))
459
460 def __init__(self, url):
461 if not is_url(url):
462 raise error.URLError(url)
463 else:
464 self._parsed = urllib.parse.urlparse(url)
465
466 @property
467 def scheme(self): return self._parsed.scheme
468 @property
469 def netloc(self): return self._parsed.netloc
470 @property
471 def hostname(self): return self._parsed.hostname
472 @property
473 def port(self): return self._parsed.port
474 @property
475 def path(self): return self._parsed.path
476 @property
477 def params(self): return self._parsed.params
478 @property
479 def query(self): return self._parsed.query
480 @property
481 def fragment(self): return self._parsed.fragment
482
483class URLs(MonitoredList):
484 """Auto-flattening list of `:class:URL` objects with change callback"""
485 def __init__(self, urls, callback=None, _get_known_urls=lambda: ()):
486 self._get_known_urls = _get_known_urls
487 if isinstance(urls, str):
488 if not urls.strip():
489 urls = ()
490 else:
491 urls = (urls,)
492 else:
493 urls = flatten(urls)
494 super().__init__(urls, callback=callback, type=URL)
495
496 def _filter_func(self, url):
497 # _get_known_urls is a hack for the Trackers class to deduplicate across
498 # multiple tiers.
499 if url not in self._items and url not in self._get_known_urls():
500 return url
501
502
503class Trackers(collections.abc.MutableSequence):
504 """List of :class:`URLs` instances with change callback"""
505 def __init__(self, tiers, callback=None):
506 self._callback = None
507 self._tiers = []
508 if isinstance(tiers, str):
509 self.append((tiers,))
510 elif isinstance(tiers, collections.abc.Iterable):
511 for urls in tiers:
512 self.append(urls)
513 else:
514 raise ValueError(f'Must be str or Iterable, not {type(tiers).__name__}: {repr(tiers)}')
515 self._callback = callback
516
517 @property
518 def flat(self):
519 """Tuple of all URLs of all tiers"""
520 return tuple(flatten(self._tiers))
521
522 @contextlib.contextmanager
523 def _callback_disabled(self):
524 cb = self._callback
525 self._callback = None
526 yield
527 self._callback = cb
528
529 def _tier_changed(self, tier):
530 # Auto-remove empty tiers
531 if len(tier) == 0:
532 self._tiers.remove(tier)
533 if self._callback is not None:
534 self._callback(self)
535
536 def __getitem__(self, index):
537 return self._tiers[index]
538
539 def __setitem__(self, index, value):
540 tier = URLs(value, callback=self._tier_changed,
541 _get_known_urls=lambda self=self: self.flat)
542 if len(tier) > 0 and tier not in self._tiers:
543 self._tiers[index] = tier
544 if self._callback is not None:
545 self._callback(self)
546
547 def __delitem__(self, index):
548 del self._tiers[index]
549 if self._callback is not None:
550 self._callback(self)
551
552 def insert(self, index, value):
553 tier = URLs(value, callback=self._tier_changed,
554 _get_known_urls=lambda self=self: self.flat)
555 if len(tier) > 0 and tier not in self._tiers:
556 self._tiers.insert(index, tier)
557 if self._callback is not None:
558 self._callback(self)
559
560 def replace(self, tiers):
561 if not isinstance(tiers, Iterable):
562 raise ValueError(f'Not an iterable: {tiers!r}')
563 with self._callback_disabled():
564 self._tiers.clear()
565 for urls in tiers:
566 self.append(urls)
567 if self._callback is not None:
568 self._callback(self)
569
570 def clear(self):
571 self._tiers.clear()
572 if self._callback is not None:
573 self._callback(self)
574
575 def __len__(self):
576 return len(self._tiers)
577
578 def __eq__(self, other):
579 if isinstance(other, type(self)):
580 return other._tiers == self._tiers
581 elif isinstance(other, collections.abc.Iterable):
582 return list(other) == self._tiers
583 else:
584 return False
585
586 def __ne__(self, other):
587 return not self.__eq__(other)
588
589 def __add__(self, other):
590 if isinstance(other, type(self)):
591 other_tiers = other._tiers
592 elif isinstance(other, collections.abc.Iterable):
593 other_tiers = other
594 new_tiers = []
595 for tier1,x in itertools.zip_longest(self._tiers, other_tiers):
596 if tier1 is None:
597 tier1 = []
598 if isinstance(x, str) and len(x) > 1:
599 new_tier = tier1 + [x]
600 elif isinstance(x, collections.abc.Iterable):
601 new_tier = tier1 + list(x)
602 elif x is not None:
603 return NotImplemented
604 else:
605 new_tier = tier1
606 new_tiers.append(new_tier)
607 return type(self)(new_tiers, callback=self._callback)
608
609 def __repr__(self):
610 return repr(self._tiers)
611
612
613def download(url, timeout=60):
614 """
615 Download data from URL
616
617 :raises ConnectionError: if the download fails or the protocol is not
618 supported
619
620 :return: the downloaded data
621 """
622 if timeout <= 0:
623 raise error.ConnectionError(url, 'Timed out')
624 elif url.startswith('http://') or url.startswith('https://'):
625 return download_http(url, timeout=timeout)
626 else:
627 raise error.ConnectionError(url, 'Unsupported protocol')
628
629def download_http(url, timeout=60):
630 try:
631 response = urllib.request.urlopen(URL(url), timeout=timeout).read()
632 except urllib.error.URLError as e:
633 try:
634 msg = e.args[0].strerror
635 except (AttributeError, IndexError):
636 msg = (getattr(e, 'msg', None) or
637 getattr(e, 'strerror', None) or
638 'Failed')
639 raise error.ConnectionError(url, msg)
640 except socket.timeout:
641 raise error.ConnectionError(url, 'Timed out')
642 except http.client.HTTPException:
643 raise error.ConnectionError(url, 'No HTTP response')
644 except (OSError, IOError):
645 raise error.ConnectionError(url, 'Unknown error')
646 else:
647 return response
648
649
650class Iterable(abc.ABC):
651 """
652 Iterable that is not a :class:`str`
653
654 This allows you to write
655
656 isinstance(x, Iterable)
657
658 instead of
659
660 isinstance(x, collections.abc.Iterable) and not isinstance(x, str)
661 """
662 @classmethod
663 def __subclasshook__(cls, C):
664 if cls is Iterable:
665 if issubclass(C, collections.abc.Iterable) and not issubclass(C, str):
666 return True
667 return False
668
669
670def key_exists_in_list_or_dict(key, lst_or_dct):
671 """True if `lst_or_dct[key]` does not raise an Exception"""
672 if isinstance(lst_or_dct, collections.abc.Mapping) and key in lst_or_dct:
673 return True
674 elif isinstance(lst_or_dct, collections.abc.Sequence):
675 min_i, max_i = 0, len(lst_or_dct)
676 if min_i <= key < max_i:
677 return True
678 return False
679
680def assert_type(obj, keys, exp_types, must_exist=True, check=None):
681 """
682 Raise MetainfoError if value is not of a particular type
683
684 :param obj: The object to check
685 :type obj: sequence or mapping
686 :param keys: Sequence of keys so that ``obj[key[0]][key[1]]...`` resolves to
687 a value
688 :type obj: sequence
689 :param exp_types: Sequence of allowed types that the value specified by
690 `keys` must be an instance of
691 :type obj: sequence
692 :param bool must_exist: Whether to raise MetainfoError if `keys` does not
693 resolve to a value
694 :param callable check: Callable that gets the value specified by `keys` and
695 returns True if it is OK, False otherwise
696 """
697 keys = list(keys)
698 keychain = []
699 while len(keys[:-1]) > 0:
700 key = keys.pop(0)
701 try:
702 obj = obj[key]
703 except (KeyError, IndexError):
704 break
705 keychain.append(key)
706
707 keychain_str = ''.join(f'[{key!r}]' for key in keychain)
708 key = keys.pop(0)
709
710 if not key_exists_in_list_or_dict(key, obj):
711 if must_exist:
712 if keychain_str:
713 raise error.MetainfoError(f'Missing {key!r} in {keychain_str}')
714 else:
715 raise error.MetainfoError(f'Missing {key!r}')
716
717 elif not isinstance(obj[key], exp_types):
718 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__
721 else:
722 exp_types_str = ' or '.join(t.__name__ for t in exp_types)
723 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}')
726
727 elif check is not None and not check(obj[key]):
728 raise error.MetainfoError(f"{keychain_str}[{key!r}] is invalid: {obj[key]!r}")
729
730
731def decode_value(value):
732 if isinstance(value, collections.abc.ByteString):
733 # WARNING: Torrents can contain binary data (e.g. "pieces" field). You
734 # should handle and remove those separately beforehand.
735 return bytes.decode(value, encoding='utf-8', errors='replace')
736 elif isinstance(value, collections.abc.Sequence):
737 return decode_list(value)
738 elif isinstance(value, collections.abc.Mapping):
739 return decode_dict(value)
740 else:
741 return value
742
743def decode_list(lst):
744 lst_dec = []
745 for value in lst:
746 lst_dec.append(decode_value(value))
747 return lst_dec
748
749def decode_dict(dct):
750 dct_dec = {}
751 for key,value in dct.items():
752 value_dec = decode_value(value)
753 key_dec = decode_value(key)
754 dct_dec[key_dec] = value_dec
755 return dct_dec
756
757
758def encode_value(value):
759 if type(value) in ENCODE_ALLOWED_TYPES:
760 return value
761 else:
762 for cls,converter in ENCODE_CONVERTERS.items():
763 if isinstance(value, cls):
764 return converter(value)
765 raise ValueError(f'Invalid value: {value!r}')
766
767def encode_list(lst):
768 lst_enc = []
769 for i,value in enumerate(lst):
770 lst_enc.append(encode_value(value))
771 return lst_enc
772
773def encode_dict(dct):
774 dct_enc = collections.OrderedDict()
775 for key,value in sorted(dct.items()):
776 if not isinstance(key, str):
777 raise ValueError(f'Invalid key: {key!r}')
778 key_enc = str(key).encode('utf8')
779 value_enc = encode_value(value)
780 dct_enc[key_enc] = value_enc
781 return dct_enc
782
783ENCODE_ALLOWED_TYPES = (bytes, int)
784ENCODE_CONVERTERS = {
785 str: lambda val: str(val).encode(encoding='utf-8', errors='replace'),
786 float: int,
787 bool: int,
788 collections.abc.ByteString: bytes,
789 collections.abc.Mapping: encode_dict,
790 collections.abc.Sequence: encode_list,
791 collections.abc.Collection: encode_list,
792 datetime: lambda dt: int(dt.timestamp()),
793}
plugin.video.jacktook/plugin.video.jacktook.cached.sqlite +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/requirements-test.txt +4 -0
……@@ -0,0 +1,4 @@
1pytest
2pytest-mock
3pytest-cov
4requests
plugin.video.jacktook/resources/__init__.py +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/anime.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/clear.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/cloud.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/cloud2.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/continue_watching.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/donate.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/download.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/download2.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/genre.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/history.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/lang.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/library.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/magnet.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/magnet2.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/mdblist.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/movies.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/nextpage.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/search.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/settings.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/status.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/telegram.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/tmdb.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/torrentio.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/trakt.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/trending.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/img/tv.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/language/English/strings.po +4068 -0
……@@ -0,0 +1,4068 @@
1# Kodi Media Center language file
2# Addon Name: Jacktook
3# Addon id: plugin.video.jacktook
4# Addon Provider: Sam-Max
5#
6msgid ""
7msgstr ""
8"Report-Msgid-Bugs-To: \n"
9"POT-Creation-Date: 2023-09-19 19:53+0200\n"
10"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
11"Language: en\n"
12"MIME-Version: 1.0\n"
13"Content-Type: text/plain; charset=UTF-8\n"
14"Content-Transfer-Encoding: 8bit\n"
15"Plural-Forms: nplurals=2; plural=(n != 1);\n"
16
17# Settings
18msgctxt "#30000"
19msgid "Players"
20msgstr ""
21
22msgctxt "#30001"
23msgid "Clients"
24msgstr ""
25
26msgctxt "#30002"
27msgid "Torrent Client"
28msgstr ""
29
30msgctxt "#30020"
31msgid "Indexers"
32msgstr ""
33
34msgctxt "#30021"
35msgid "Indexer"
36msgstr ""
37
38msgctxt "#30022"
39msgid "Jackett Configuration:"
40msgstr ""
41
42msgctxt "#30023"
43msgid "Client Key"
44msgstr ""
45
46msgctxt "#30025"
47msgid "Host"
48msgstr ""
49
50msgctxt "#30026"
51msgid "Description length"
52msgstr ""
53
54msgctxt "#30027"
55msgid "Insecured connection"
56msgstr ""
57
58msgctxt "#30028"
59msgid "Sort by"
60msgstr ""
61
62msgctxt "#30029"
63msgid "Total Results"
64msgstr ""
65
66msgctxt "#30030"
67msgid "Timeout"
68msgstr ""
69
70msgctxt "#30031"
71msgid "Timeout"
72msgstr ""
73
74msgctxt "#30050"
75msgid "Prowlarr Configuration:"
76msgstr ""
77
78msgctxt "#30051"
79msgid "Client Key"
80msgstr ""
81
82msgctxt "#30052"
83msgid "Host"
84msgstr ""
85
86msgctxt "#30053"
87msgid "Indexers-Ids"
88msgstr ""
89
90msgctxt "#30054"
91msgid "Anime IndexersIds"
92msgstr ""
93
94msgctxt "#30100"
95msgid "TMDB"
96msgstr ""
97
98msgctxt "#30102"
99msgid "TMDB Configuration"
100msgstr ""
101
102msgctxt "#30103"
103msgid "Client Key"
104msgstr ""
105
106msgctxt "#30104"
107msgid "Clear tmdb cache"
108msgstr ""
109
110msgctxt "#30105"
111msgid "Cache expiration hours"
112msgstr ""
113
114msgctxt "#30106"
115msgid "Clear Cache"
116msgstr ""
117
118msgctxt "#30107"
119msgid "Enable Cache"
120msgstr ""
121
122msgctxt "#30140"
123msgid "AniList"
124msgstr ""
125
126msgctxt "#30142"
127msgid "AniList Configuration:"
128msgstr ""
129
130msgctxt "#30143"
131msgid "Client ID"
132msgstr ""
133
134msgctxt "#30144"
135msgid "Client Secret"
136msgstr ""
137
138msgctxt "#30150"
139msgid "Services"
140msgstr ""
141
142msgctxt "#30152"
143msgid "Real Debrid Configuration"
144msgstr ""
145
146msgctxt "#30154"
147msgid "Token"
148msgstr ""
149
150msgctxt "#30200"
151msgid "Fanart.TV"
152msgstr ""
153
154msgctxt "#30202"
155msgid "Fanart.TV Configuration:"
156msgstr ""
157
158msgctxt "#30203"
159msgid "Client ID"
160msgstr ""
161
162msgctxt "#30221"
163msgid "No Client Key provided for"
164msgstr ""
165
166msgctxt "#30223"
167msgid "You need to configure"
168msgstr ""
169
170msgctxt "#30228"
171msgid "The request to Torrentio failed."
172msgstr ""
173
174msgctxt "#30229"
175msgid "The request to Jackett failed."
176msgstr ""
177
178msgctxt "#30230"
179msgid "The request to Prowlarr failed."
180msgstr ""
181
182msgctxt "#30231"
183msgid "The request to Elhosted failed."
184msgstr ""
185
186msgctxt "#30232"
187msgid "The request to Jackgram failed."
188msgstr ""
189
190msgctxt "#30233"
191msgid "The request to Mediafusion failed."
192msgstr ""
193
194msgctxt "#30234"
195msgid "The request to Peerflix failed."
196msgstr ""
197
198msgctxt "#30235"
199msgid "Stremio"
200msgstr ""
201
202msgctxt "#30236"
203msgid "Stremio add-ons"
204msgstr ""
205
206msgctxt "#30237"
207msgid "Import your config"
208msgstr ""
209
210msgctxt "#30238"
211msgid "Manage add-ons"
212msgstr ""
213
214msgctxt "#30240"
215msgid "TMDB cache cleared"
216msgstr ""
217
218msgctxt "#30241"
219msgid "Search on TMDB:"
220msgstr ""
221
222msgctxt "#30242"
223msgid "Search on AniList:"
224msgstr ""
225
226msgctxt "#30243"
227msgid "Search for a keyword:"
228msgstr ""
229
230msgctxt "#30244"
231msgid "Cache cleared"
232msgstr ""
233
234msgctxt "#30249"
235msgid "Addon Jacktook Burst not found - (script.jacktook.burst)"
236msgstr ""
237
238msgctxt "#30250"
239msgid "Addon Torrest not found - (plugin.video.torrest)"
240msgstr ""
241
242msgctxt "#30251"
243msgid "No sources found to play"
244msgstr ""
245
246msgctxt "#30252"
247msgid "Addon Elementum not found - (plugin.video.elementum)"
248msgstr ""
249
250msgctxt "#30253"
251msgid "Addon Jacktorr not found - (plugin.video.jacktorr)"
252msgstr ""
253
254msgctxt "#30254"
255msgid "Do you want to install Elementum?"
256msgstr ""
257
258msgctxt "#30255"
259msgid "Do you want to install Jacktorr?"
260msgstr ""
261
262msgctxt "#30256"
263msgid "Do you want to install Torrest?"
264msgstr ""
265
266msgctxt "#30257"
267msgid "Do you want to install Jacktook Burst?"
268msgstr ""
269
270msgctxt "#30650"
271msgid "Queued"
272msgstr ""
273
274msgctxt "#30651"
275msgid "Checking"
276msgstr ""
277
278msgctxt "#30652"
279msgid "Finding"
280msgstr ""
281
282msgctxt "#30653"
283msgid "Downloading"
284msgstr ""
285
286msgctxt "#30654"
287msgid "Finished"
288msgstr ""
289
290msgctxt "#30655"
291msgid "Seeding"
292msgstr ""
293
294msgctxt "#30656"
295msgid "Allocating"
296msgstr ""
297
298msgctxt "#30657"
299msgid "Checking resume data"
300msgstr ""
301
302msgctxt "#30658"
303msgid "Paused"
304msgstr ""
305
306msgctxt "#30659"
307msgid "Buffering"
308msgstr ""
309
310msgctxt "#30660"
311msgid "Unknown"
312msgstr ""
313
314msgctxt "#30700"
315msgid "Buffer and play"
316msgstr ""
317
318msgctxt "#30701"
319msgid "Stop downloading"
320msgstr ""
321
322msgctxt "#30702"
323msgid "Downloads"
324msgstr ""
325
326msgctxt "#30703"
327msgid "Resume"
328msgstr ""
329
330msgctxt "#30704"
331msgid "Pause"
332msgstr ""
333
334msgctxt "#30705"
335msgid "Remove torrent"
336msgstr ""
337
338msgctxt "#30706"
339msgid "Remove torrent and files"
340msgstr ""
341
342msgctxt "#30707"
343msgid "Torrent status"
344msgstr ""
345
346msgctxt "#30708"
347msgid "Stop"
348msgstr ""
349
350msgctxt "#30709"
351msgid "Drop torrent"
352msgstr ""
353
354msgctxt "#30710"
355msgid "Include Season Packs"
356msgstr ""
357
358msgctxt "#30711"
359msgid "Torrent Settings"
360msgstr ""
361
362msgctxt "#30712"
363msgid "General"
364msgstr ""
365
366msgctxt "#30713"
367msgid "Enable"
368msgstr ""
369
370msgctxt "#30714"
371msgid "Jacktorr"
372msgstr ""
373
374msgctxt "#30715"
375msgid "Torrest"
376msgstr ""
377
378msgctxt "#30716"
379msgid "Elementum"
380msgstr ""
381
382msgctxt "#30717"
383msgid "General"
384msgstr ""
385
386msgctxt "#30718"
387msgid "Auto-Play"
388msgstr ""
389
390msgctxt "#30719"
391msgid "Enable Next Episode Dialog"
392msgstr ""
393
394msgctxt "#30720"
395msgid "Seconds left before show Next Episode Dialog"
396msgstr ""
397
398msgctxt "#30721"
399msgid "Cache Enabled"
400msgstr ""
401
402msgctxt "#30722"
403msgid "Cache Expiration (hours)"
404msgstr ""
405
406msgctxt "#30723"
407msgid "Cache"
408msgstr ""
409
410msgctxt "#30724"
411msgid "Update"
412msgstr ""
413
414msgctxt "#30725"
415msgid "Indexers Configuration"
416msgstr ""
417
418msgctxt "#30726"
419msgid "Sort"
420msgstr ""
421
422msgctxt "#30727"
423msgid "Quality"
424msgstr ""
425
426msgctxt "#30938"
427msgid "Silent Resume"
428msgstr ""
429
430msgctxt "#30939"
431msgid "Auto-resume from last position without asking"
432msgstr ""
433
434msgctxt "#30728"
435msgid "Seeds"
436msgstr ""
437
438msgctxt "#30729"
439msgid "Size"
440msgstr ""
441
442msgctxt "#30730"
443msgid "Cached"
444msgstr ""
445
446msgctxt "#30731"
447msgid "Date"
448msgstr ""
449
450msgctxt "#30732"
451msgid "None"
452msgstr ""
453
454msgctxt "#30733"
455msgid "Torrentio Configuration"
456msgstr ""
457
458msgctxt "#30734"
459msgid "Enable"
460msgstr ""
461
462msgctxt "#30736"
463msgid "Subtitle Language"
464msgstr ""
465
466msgctxt "#30737"
467msgid "GB"
468msgstr ""
469
470msgctxt "#30738"
471msgid "JP"
472msgstr ""
473
474msgctxt "#30739"
475msgid "RU"
476msgstr ""
477
478msgctxt "#30740"
479msgid "IT"
480msgstr ""
481
482msgctxt "#30741"
483msgid "PT"
484msgstr ""
485
486msgctxt "#30742"
487msgid "ES"
488msgstr ""
489
490msgctxt "#30743"
491msgid "MX"
492msgstr ""
493
494msgctxt "#30744"
495msgid "KR"
496msgstr ""
497
498msgctxt "#30745"
499msgid "CN"
500msgstr ""
501
502msgctxt "#30746"
503msgid "TW"
504msgstr ""
505
506msgctxt "#30747"
507msgid "FR"
508msgstr ""
509
510msgctxt "#30748"
511msgid "DE"
512msgstr ""
513
514msgctxt "#30749"
515msgid "NL"
516msgstr ""
517
518msgctxt "#30750"
519msgid "IN"
520msgstr ""
521
522msgctxt "#30751"
523msgid "PL"
524msgstr ""
525
526msgctxt "#30752"
527msgid "LT"
528msgstr ""
529
530msgctxt "#30753"
531msgid "CZ"
532msgstr ""
533
534msgctxt "#30754"
535msgid "SK"
536msgstr ""
537
538msgctxt "#30755"
539msgid "SI"
540msgstr ""
541
542msgctxt "#30756"
543msgid "HU"
544msgstr ""
545
546msgctxt "#30757"
547msgid "RO"
548msgstr ""
549
550msgctxt "#30758"
551msgid "HR"
552msgstr ""
553
554msgctxt "#30759"
555msgid "UA"
556msgstr ""
557
558msgctxt "#30760"
559msgid "GR"
560msgstr ""
561
562msgctxt "#30761"
563msgid "DK"
564msgstr ""
565
566msgctxt "#30762"
567msgid "FI"
568msgstr ""
569
570msgctxt "#30763"
571msgid "SE"
572msgstr ""
573
574msgctxt "#30764"
575msgid "NO"
576msgstr ""
577
578msgctxt "#30765"
579msgid "TR"
580msgstr ""
581
582msgctxt "#30766"
583msgid "Torrentio Providers"
584msgstr ""
585
586msgctxt "#30767"
587msgid "Jackgram Configuration"
588msgstr ""
589
590msgctxt "#30768"
591msgid "Host"
592msgstr ""
593
594msgctxt "#30769"
595msgid "Elfhosted/KnightCrawler Configuration"
596msgstr ""
597
598msgctxt "#30770"
599msgid "Language"
600msgstr ""
601
602msgctxt "#30771"
603msgid "Zilean Configuration"
604msgstr ""
605
606msgctxt "#30772"
607msgid "Zilean Host"
608msgstr ""
609
610msgctxt "#30774"
611msgid "Zilean Timeout"
612msgstr ""
613
614msgctxt "#30775"
615msgid "Jackett Configuration"
616msgstr ""
617
618msgctxt "#30776"
619msgid "Prowlarr Configuration"
620msgstr ""
621
622msgctxt "#30777"
623msgid "MediaFusion configuration"
624msgstr ""
625
626msgctxt "#30779"
627msgid "Manifest Url"
628msgstr ""
629
630msgctxt "#30780"
631msgid "Jacktook Burst Configuration"
632msgstr ""
633
634msgctxt "#30781"
635msgid "Burst Configuration"
636msgstr ""
637
638msgctxt "#30782"
639msgid "User"
640msgstr ""
641
642msgctxt "#30783"
643msgid "Authorize"
644msgstr ""
645
646msgctxt "#30784"
647msgid "Remove Authorization"
648msgstr ""
649
650msgctxt "#30785"
651msgid "Premiumize Configuration"
652msgstr ""
653
654msgctxt "#30786"
655msgid "Enabled"
656msgstr ""
657
658msgctxt "#30787"
659msgid "Token"
660msgstr ""
661
662msgctxt "#30788"
663msgid "TorBox Configuration"
664msgstr ""
665
666msgctxt "#30789"
667msgid "Debrider Configuration"
668msgstr ""
669
670msgctxt "#30790"
671msgid "Uncached"
672msgstr ""
673
674msgctxt "#30791"
675msgid "Show uncached torrents"
676msgstr ""
677
678msgctxt "#30792"
679msgid "Use Kodi Language"
680msgstr ""
681
682msgctxt "#30793"
683msgid "Trakt"
684msgstr ""
685
686msgctxt "#30794"
687msgid "Trakt Configuration"
688msgstr ""
689
690msgctxt "#30795"
691msgid "Expires"
692msgstr ""
693
694msgctxt "#30796"
695msgid "Refresh"
696msgstr ""
697
698msgctxt "#30797"
699msgid "Token"
700msgstr ""
701
702msgctxt "#30798"
703msgid "Trakt Client"
704msgstr ""
705
706msgctxt "#30800"
707msgid "Trakt Secret"
708msgstr ""
709
710msgctxt "#30802"
711msgid "Log out"
712msgstr ""
713
714msgctxt "#30804"
715msgid "Menus"
716msgstr ""
717
718msgctxt "#30805"
719msgid "Show Telegram Menu"
720msgstr ""
721
722msgctxt "#30806"
723msgid "Clear cache when update"
724msgstr ""
725
726msgctxt "#30807"
727msgid "Peerfix Configuration"
728msgstr ""
729
730msgctxt "#30894"
731msgid "Show Changelog"
732msgstr "Show Changelog"
733
734msgctxt "#30895"
735msgid "View the latest changes and updates to the addon."
736msgstr "View the latest changes and updates to the addon."
737
738msgctxt "#30896"
739msgid "Calendar"
740msgstr "Calendar"
741
742msgctxt "#30897"
743msgid "Use broadcast day adjustment"
744msgstr "Use broadcast day adjustment"
745
746msgctxt "#30898"
747msgid "Adjust broadcast day based on manual offset"
748msgstr "Adjust broadcast day based on manual offset"
749
750msgctxt "#30899"
751msgid "Broadcast Offset (Hours)"
752msgstr "Broadcast Offset (Hours)"
753
754msgctxt "#30900"
755msgid "Manual hour adjustment for broadcast day simulation"
756msgstr "Manual hour adjustment for broadcast day simulation"
757
758msgctxt "#30905"
759msgid "Trakt Calendar Previous Days"
760msgstr "Trakt Calendar Previous Days"
761
762msgctxt "#30906"
763msgid "Number of days in the past to show in the Trakt calendar."
764msgstr "Number of days in the past to show in the Trakt calendar."
765
766msgctxt "#30907"
767msgid "Trakt Calendar Future Days"
768msgstr "Trakt Calendar Future Days"
769
770msgctxt "#30908"
771msgid "Number of days in the future to show in the Trakt calendar."
772msgstr "Number of days in the future to show in the Trakt calendar."
773
774msgctxt "#30909"
775msgid "Show All Shows in Calendar"
776msgstr "Show All Shows in Calendar"
777
778msgctxt "#30918"
779msgid "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead."
780msgstr "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead."
781
782msgctxt "#30910"
783msgid "Show all airing shows instead of just your watchlist/collection."
784msgstr "Show all airing shows instead of just your watchlist/collection."
785
786msgctxt "#30911"
787msgid "Your Torbox account username/email."
788msgstr "Your Torbox account username/email."
789
790msgctxt "#30912"
791msgid "Authorize add-on to access Torbox account."
792msgstr "Authorize add-on to access Torbox account."
793
794msgctxt "#30913"
795msgid "Revoke Torbox account access."
796msgstr "Revoke Torbox account access."
797
798msgctxt "#30914"
799msgid "Trakt Sync Interval (minutes)"
800msgstr "Trakt Sync Interval (minutes)"
801
802msgctxt "#30915"
803msgid "How often Jacktook checks Trakt for remote activity changes while Kodi is running."
804msgstr "How often Jacktook checks Trakt for remote activity changes while Kodi is running."
805
806msgctxt "#30916"
807msgid "Account Info"
808msgstr "Account Info"
809
810msgctxt "#30917"
811msgid "View your Trakt account profile and library stats."
812msgstr "View your Trakt account profile and library stats."
813
814msgctxt "#30924"
815msgid "Trakt List Name"
816msgstr "Trakt List Name"
817
818msgctxt "#30925"
819msgid "Trakt List Description"
820msgstr "Trakt List Description"
821
822msgctxt "#30926"
823msgid "Create Trakt List"
824msgstr "Create Trakt List"
825
826msgctxt "#30927"
827msgid "Choose Trakt List"
828msgstr "Choose Trakt List"
829
830msgctxt "#30936"
831msgid "Enable or disable Trakt scrobbling and resume playback from the last tracked position."
832msgstr "Enable or disable Trakt scrobbling and resume playback from the last tracked position."
833
834msgctxt "#30937"
835msgid "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view."
836msgstr "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view."
837
838msgctxt "#40000"
839msgid "Arabic (United Arab Emirates)"
840msgstr ""
841
842msgctxt "#40001"
843msgid "Arabic (Saudi Arabia)"
844msgstr ""
845
846msgctxt "#40002"
847msgid "Belarusian (Belarus)"
848msgstr ""
849
850msgctxt "#40003"
851msgid "Bulgarian (Bulgaria)"
852msgstr ""
853
854msgctxt "#40004"
855msgid "Bengali (Bangladesh)"
856msgstr ""
857
858msgctxt "#40005"
859msgid "Catalan (Spain)"
860msgstr ""
861
862msgctxt "#40006"
863msgid "Chamorro (Guam)"
864msgstr ""
865
866msgctxt "#40007"
867msgid "Czech (Czech Republic)"
868msgstr ""
869
870msgctxt "#40008"
871msgid "Danish (Denmark)"
872msgstr ""
873
874msgctxt "#40009"
875msgid "German (Austria)"
876msgstr ""
877
878msgctxt "#40010"
879msgid "German (Switzerland)"
880msgstr ""
881
882msgctxt "#40011"
883msgid "German (Germany)"
884msgstr ""
885
886msgctxt "#40012"
887msgid "Greek (Greece)"
888msgstr ""
889
890msgctxt "#40013"
891msgid "English (Australia)"
892msgstr ""
893
894msgctxt "#40014"
895msgid "English (Canada)"
896msgstr ""
897
898msgctxt "#40015"
899msgid "English (United Kingdom)"
900msgstr ""
901
902msgctxt "#40016"
903msgid "English (Ireland)"
904msgstr ""
905
906msgctxt "#40017"
907msgid "English (New Zealand)"
908msgstr ""
909
910msgctxt "#40018"
911msgid "English (United States of America)"
912msgstr ""
913
914msgctxt "#40019"
915msgid "Esperanto (None)"
916msgstr ""
917
918msgctxt "#40020"
919msgid "Spanish (Spain)"
920msgstr ""
921
922msgctxt "#40021"
923msgid "Spanish (Mexico)"
924msgstr ""
925
926msgctxt "#40022"
927msgid "Estonian (Estonia)"
928msgstr ""
929
930msgctxt "#40023"
931msgid "Basque (Spain)"
932msgstr ""
933
934msgctxt "#40024"
935msgid "Persian (Iran)"
936msgstr ""
937
938msgctxt "#40025"
939msgid "Finnish (Finland)"
940msgstr ""
941
942msgctxt "#40026"
943msgid "French (Canada)"
944msgstr ""
945
946msgctxt "#40027"
947msgid "French (France)"
948msgstr ""
949
950msgctxt "#40028"
951msgid "Galician (Spain)"
952msgstr ""
953
954msgctxt "#40029"
955msgid "Hebrew (Israel)"
956msgstr ""
957
958msgctxt "#40030"
959msgid "Hindi (India)"
960msgstr ""
961
962msgctxt "#40031"
963msgid "Hungarian (Hungary)"
964msgstr ""
965
966msgctxt "#40032"
967msgid "Indonesian (Indonesia)"
968msgstr ""
969
970msgctxt "#40033"
971msgid "Italian (Italy)"
972msgstr ""
973
974msgctxt "#40034"
975msgid "Japanese (Japan)"
976msgstr ""
977
978msgctxt "#40035"
979msgid "Georgian (Georgia)"
980msgstr ""
981
982msgctxt "#40036"
983msgid "Kazakh (Kazakhstan)"
984msgstr ""
985
986msgctxt "#40037"
987msgid "Kannada (India)"
988msgstr ""
989
990msgctxt "#40038"
991msgid "Korean (South Korea)"
992msgstr ""
993
994msgctxt "#40039"
995msgid "Lithuanian (Lithuania)"
996msgstr ""
997
998msgctxt "#40040"
999msgid "Latvian (Latvia)"
1000msgstr ""
1001
1002msgctxt "#40041"
1003msgid "Malayalam (India)"
1004msgstr ""
1005
1006msgctxt "#40042"
1007msgid "Malay (Malaysia)"
1008msgstr ""
1009
1010msgctxt "#40043"
1011msgid "Malay (Singapore)"
1012msgstr ""
1013
1014msgctxt "#40044"
1015msgid "Norwegian Bokmål (Norway)"
1016msgstr ""
1017
1018msgctxt "#40045"
1019msgid "Dutch (Netherlands)"
1020msgstr ""
1021
1022msgctxt "#40046"
1023msgid "Norwegian (Norway)"
1024msgstr ""
1025
1026msgctxt "#40047"
1027msgid "Polish (Poland)"
1028msgstr ""
1029
1030msgctxt "#40048"
1031msgid "Portuguese (Brazil)"
1032msgstr ""
1033
1034msgctxt "#40049"
1035msgid "Portuguese (Portugal)"
1036msgstr ""
1037
1038msgctxt "#40050"
1039msgid "Romanian (Romania)"
1040msgstr ""
1041
1042msgctxt "#40051"
1043msgid "Russian (Russia)"
1044msgstr ""
1045
1046msgctxt "#40052"
1047msgid "Sinhalese (Sri Lanka)"
1048msgstr ""
1049
1050msgctxt "#40053"
1051msgid "Slovak (Slovakia)"
1052msgstr ""
1053
1054msgctxt "#40054"
1055msgid "Slovenian (Slovenia)"
1056msgstr ""
1057
1058msgctxt "#40055"
1059msgid "Serbian (Serbia)"
1060msgstr ""
1061
1062msgctxt "#40056"
1063msgid "Swedish (Sweden)"
1064msgstr ""
1065
1066msgctxt "#40057"
1067msgid "Tamil (India)"
1068msgstr ""
1069
1070msgctxt "#40058"
1071msgid "Telugu (India)"
1072msgstr ""
1073
1074msgctxt "#40059"
1075msgid "Thai (Thailand)"
1076msgstr ""
1077
1078msgctxt "#40060"
1079msgid "Tagalog (Philippines)"
1080msgstr ""
1081
1082msgctxt "#40061"
1083msgid "Turkish (Turkey)"
1084msgstr ""
1085
1086msgctxt "#40062"
1087msgid "Ukrainian (Ukraine)"
1088msgstr ""
1089
1090msgctxt "#40063"
1091msgid "Vietnamese (Vietnam)"
1092msgstr ""
1093
1094msgctxt "#40064"
1095msgid "Mandarin (China)"
1096msgstr ""
1097
1098msgctxt "#40065"
1099msgid "Mandarin (Hong Kong)"
1100msgstr ""
1101
1102msgctxt "#40066"
1103msgid "Mandarin (Taiwan)"
1104msgstr ""
1105
1106msgctxt "#40067"
1107msgid "Zulu (South Africa)"
1108msgstr ""
1109
1110msgctxt "#42418"
1111msgid "Language and country"
1112msgstr ""
1113
1114msgctxt "#42419"
1115msgid "The language and country settings used when looking up data from TMDb"
1116msgstr ""
1117
1118msgctxt "#42420"
1119msgid "EN"
1120msgstr ""
1121
1122msgctxt "#30801"
1123msgid "Zilean server address (include port if required)."
1124msgstr ""
1125
1126msgctxt "#30803"
1127msgid "Display torrents not yet cached on your debrid service."
1128msgstr ""
1129
1130msgctxt "#30808"
1131msgid "Fanart.tv Client Key (for artwork)."
1132msgstr ""
1133
1134msgctxt "#30809"
1135msgid "Improve performance by caching metadata."
1136msgstr ""
1137
1138msgctxt "#30810"
1139msgid "Seconds before Jackett requests time out (e.g., 30)."
1140msgstr ""
1141
1142msgctxt "#30811"
1143msgid "Trakt client secret from OAuth application settings."
1144msgstr ""
1145
1146msgctxt "#30812"
1147msgid "Limit TV show results to specific seasons/episodes."
1148msgstr ""
1149
1150msgctxt "#30813"
1151msgid "Hours before cached data is automatically refreshed (e.g., 24)."
1152msgstr ""
1153
1154msgctxt "#30814"
1155msgid "Premiumize Client Token (get from account page)."
1156msgstr ""
1157
1158msgctxt "#30815"
1159msgid "Jackett server URL (e.g., http://localhost)."
1160msgstr ""
1161
1162msgctxt "#30816"
1163msgid "Display Telegram integration options in UI."
1164msgstr ""
1165
1166msgctxt "#30817"
1167msgid "Your Real-Debrid account username/email."
1168msgstr ""
1169
1170msgctxt "#30818"
1171msgid "Trakt client ID from https://trakt.tv/oauth/apps."
1172msgstr ""
1173
1174msgctxt "#30819"
1175msgid "Connect Trakt.tv account to sync watch progress."
1176msgstr ""
1177
1178msgctxt "#30820"
1179msgid "Seconds before Zilean requests time out (e.g., 30)."
1180msgstr ""
1181
1182msgctxt "#30821"
1183msgid "Your Jackett Client Key (found in dashboard)."
1184msgstr ""
1185
1186msgctxt "#30822"
1187msgid "Sort results by criteria (e.g., 'date', 'title', 'size')."
1188msgstr ""
1189
1190msgctxt "#30823"
1191msgid "Configure Burst mode settings and providers."
1192msgstr ""
1193
1194msgctxt "#30824"
1195msgid "Enable Premiumize.me cloud service."
1196msgstr ""
1197
1198msgctxt "#30825"
1199msgid "Truncate descriptions after [X] characters (0 for full text)."
1200msgstr ""
1201
1202msgctxt "#30826"
1203msgid "Enable Prowlarr indexer management."
1204msgstr ""
1205
1206msgctxt "#30827"
1207msgid "Jackgram server URL (e.g., http://localhost:9117)."
1208msgstr ""
1209
1210msgctxt "#30828"
1211msgid "Debrider Client Key for authentication."
1212msgstr ""
1213
1214msgctxt "#30829"
1215msgid "Delay (in seconds) before next episode dialog appears (e.g., 10)."
1216msgstr ""
1217
1218msgctxt "#30830"
1219msgid "Specify the torrent client."
1220msgstr ""
1221
1222msgctxt "#30831"
1223msgid "Enable Debrider cached torrent support."
1224msgstr ""
1225
1226msgctxt "#30832"
1227msgid "Torbox Client Token (found in account settings)."
1228msgstr ""
1229
1230msgctxt "#30833"
1231msgid "Trakt refresh token (auto-managed)."
1232msgstr ""
1233
1234msgctxt "#30834"
1235msgid "Show confirmation dialog before playing next episode."
1236msgstr ""
1237
1238msgctxt "#30835"
1239msgid "Authorize add-on to access Real-Debrid account."
1240msgstr ""
1241
1242msgctxt "#30836"
1243msgid "Enable torrent-based media content"
1244msgstr ""
1245
1246msgctxt "#30837"
1247msgid "Indicates if Real-Debrid account is linked."
1248msgstr ""
1249
1250msgctxt "#30838"
1251msgid "Automatically play next media item when current playback ends."
1252msgstr ""
1253
1254msgctxt "#30839"
1255msgid "Check for and install the latest add-on updates."
1256msgstr ""
1257
1258msgctxt "#30840"
1259msgid "Comma-separated Prowlarr indexer IDs to use (e.g., 1,5,7)."
1260msgstr ""
1261
1262msgctxt "#30841"
1263msgid "Timeout (seconds) for Prowlarr requests (e.g., 20)."
1264msgstr ""
1265
1266msgctxt "#30842"
1267msgid "Trakt authorization expiration date (read-only)."
1268msgstr ""
1269
1270msgctxt "#30843"
1271msgid "Enable Torbox.de cloud torrent integration."
1272msgstr ""
1273
1274msgctxt "#30844"
1275msgid "Trakt Access Token (auto-filled after auth)."
1276msgstr ""
1277
1278msgctxt "#30845"
1279msgid "Link Premiumize.me account to add-on."
1280msgstr ""
1281
1282msgctxt "#30846"
1283msgid "Clear cached data automatically after updating the add-on."
1284msgstr ""
1285
1286msgctxt "#30847"
1287msgid "Click to delete all cached data immediately."
1288msgstr ""
1289
1290msgctxt "#30848"
1291msgid "Real-Debrid Client Token (auto-filled after auth)."
1292msgstr ""
1293
1294msgctxt "#30849"
1295msgid "Enable Jackett torrent proxy support."
1296msgstr ""
1297
1298msgctxt "#30850"
1299msgid "Enable Jackgram integration for enhanced search."
1300msgstr ""
1301
1302msgctxt "#30851"
1303msgid "Prowlarr instance URL (e.g., http://localhost)."
1304msgstr ""
1305
1306msgctxt "#30852"
1307msgid "Maximum number of search results to display (e.g., 50)."
1308msgstr ""
1309
1310msgctxt "#30853"
1311msgid "Revoke Real-Debrid account access."
1312msgstr ""
1313
1314msgctxt "#30854"
1315msgid "Prowlarr Client Key (found in Settings > General)."
1316msgstr ""
1317
1318msgctxt "#30855"
1319msgid "Trakt.tv username (auto-populated after auth)."
1320msgstr ""
1321
1322msgctxt "#30856"
1323msgid "Disconnect Trakt.tv account from add-on."
1324msgstr ""
1325
1326msgctxt "#30857"
1327msgid "Enable Zilean integration for metadata fetching."
1328msgstr ""
1329
1330msgctxt "#30858"
1331msgid "Your Client Key (required for metadata)"
1332msgstr ""
1333
1334msgctxt "#30859"
1335msgid "Enable Burst mode for accelerated searches."
1336msgstr ""
1337
1338msgctxt "#30860"
1339msgid "Others"
1340msgstr ""
1341
1342msgctxt "#30861"
1343msgid "Filters"
1344msgstr ""
1345
1346msgctxt "#30862"
1347msgid "Torrent (P2P) Configuration"
1348msgstr ""
1349
1350msgctxt "#30863"
1351msgid "Specify the torrent client"
1352msgstr ""
1353
1354msgctxt "#30864"
1355msgid "Sources"
1356msgstr ""
1357
1358msgctxt "#30865"
1359msgid "General Filters"
1360msgstr ""
1361
1362msgctxt "#30866"
1363msgid "Sign in to your Stremio account to access add-ons and personalized features."
1364msgstr "Inicia sesión en tu cuenta de Stremio para acceder a complementos y características personalizadas."
1365
1366msgctxt "#30867"
1367msgid "Sign out of your Stremio account. This will disconnect your account but won't delete your data."
1368msgstr "Cierra sesión en tu cuenta de Stremio. Esto desconectará tu cuenta, pero no eliminará tus datos."
1369
1370msgctxt "#30868"
1371msgid "Log Out of Stremio"
1372msgstr "Cerrar sesión en Stremio"
1373
1374msgctxt "#30869"
1375msgid "Enable Stremio add-ons integration."
1376msgstr "Habilitar la integración de complementos de Stremio."
1377
1378msgctxt "#30870"
1379msgid "Select Stream Addons"
1380msgstr ""
1381
1382msgctxt "#30871"
1383msgid "Log In to Stremio"
1384msgstr "Iniciar sesión en Stremio"
1385
1386msgctxt "#30872"
1387msgid "Enable or disable Stremio add-ons."
1388msgstr "Habilitar o deshabilitar los complementos de Stremio."
1389
1390msgctxt "#30873"
1391msgid "Select language for player subtitle selection"
1392msgstr ""
1393
1394msgctxt "#30874"
1395msgid "Update Addons"
1396msgstr ""
1397
1398msgctxt "#30875"
1399msgid "Select Catalogs Addons"
1400msgstr ""
1401
1402msgctxt "#30876"
1403msgid "Download directory"
1404msgstr ""
1405
1406msgctxt "#30877"
1407msgid "Enable Trakt.tv Scrobbling"
1408msgstr ""
1409
1410msgctxt "#30878"
1411msgid "Auto select subtitle when playing"
1412msgstr ""
1413
1414msgctxt "#30879"
1415msgid "Auto-play quality"
1416msgstr ""
1417
1418msgctxt "#30880"
1419msgid "1080p"
1420msgstr ""
1421
1422msgctxt "#30881"
1423msgid "720p"
1424msgstr ""
1425
1426msgctxt "#30882"
1427msgid "4k"
1428msgstr ""
1429
1430msgctxt "#30883"
1431msgid "480p"
1432msgstr ""
1433
1434msgctxt "#30884"
1435msgid "Subtitles Configuration"
1436msgstr ""
1437
1438msgctxt "#30885"
1439msgid "Addon Host"
1440msgstr ""
1441
1442msgctxt "#30886"
1443msgid "Subtitles"
1444msgstr ""
1445
1446msgctxt "#30887"
1447msgid "Primary Language"
1448msgstr ""
1449
1450msgctxt "#30888"
1451msgid "Secondary Language"
1452msgstr ""
1453
1454msgctxt "#30889"
1455msgid "Deepl Enabled"
1456msgstr ""
1457
1458msgctxt "#30890"
1459msgid "Deepl Client Key"
1460msgstr ""
1461
1462msgctxt "#30891"
1463msgid "Translator Configuration"
1464msgstr ""
1465
1466msgctxt "#30892"
1467msgid "Deepl Target Language"
1468msgstr ""
1469
1470msgctxt "#30893"
1471msgid "Stremio Subtitle Configuration"
1472msgstr ""
1473
1474msgctxt "#90001"
1475msgid "Add Custom Addon"
1476msgstr "Add Custom Addon"
1477
1478msgctxt "#90002"
1479msgid "Add a custom Stremio addon by entering its URL"
1480msgstr "Agrega un complemento personalizado de Stremio ingresando su URL"
1481
1482msgctxt "#90003"
1483msgid "Auto-select audio when playing"
1484msgstr ""
1485
1486msgctxt "#90004"
1487msgid "Audio Language"
1488msgstr ""
1489
1490msgctxt "#90005"
1491msgid "Select audio language for playing the video"
1492msgstr ""
1493
1494msgctxt "#90006"
1495msgid "Search"
1496msgstr ""
1497
1498msgctxt "#90007"
1499msgid "TV Shows"
1500msgstr ""
1501
1502msgctxt "#90008"
1503msgid "Movies"
1504msgstr ""
1505
1506msgctxt "#90009"
1507msgid "Anime"
1508msgstr ""
1509
1510msgctxt "#90010"
1511msgid "Live TV"
1512msgstr ""
1513
1514msgctxt "#90011"
1515msgid "Direct Search"
1516msgstr ""
1517
1518msgctxt "#90012"
1519msgid "Torrents"
1520msgstr ""
1521
1522msgctxt "#90013"
1523msgid "Telegram"
1524msgstr ""
1525
1526msgctxt "#90014"
1527msgid "Cloud"
1528msgstr ""
1529
1530msgctxt "#90015"
1531msgid "Downloads"
1532msgstr ""
1533
1534msgctxt "#90016"
1535msgid "Settings"
1536msgstr ""
1537
1538msgctxt "#90017"
1539msgid "History"
1540msgstr ""
1541
1542msgctxt "#90018"
1543msgid "Donate"
1544msgstr ""
1545
1546msgctxt "#90019"
1547msgid "Recently Watched Files"
1548msgstr ""
1549
1550msgctxt "#90020"
1551msgid "Recently Watched Titles"
1552msgstr ""
1553
1554msgctxt "#90021"
1555msgid "TV Calendar"
1556msgstr ""
1557
1558
1559msgctxt "#90022"
1560msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:"
1561msgstr ""
1562
1563msgctxt "#90023"
1564msgid "Support Jacktook"
1565msgstr ""
1566
1567msgctxt "#90024"
1568msgid "Trending Recent"
1569msgstr ""
1570
1571msgctxt "#90025"
1572msgid "Genres"
1573msgstr ""
1574
1575msgctxt "#90026"
1576msgid "Calendar"
1577msgstr ""
1578
1579msgctxt "#90027"
1580msgid "Years"
1581msgstr ""
1582
1583msgctxt "#90028"
1584msgid "Trending"
1585msgstr ""
1586
1587msgctxt "#90029"
1588msgid "Most Watched"
1589msgstr ""
1590
1591msgctxt "#90030"
1592msgid "Most Favorited"
1593msgstr ""
1594
1595msgctxt "#90031"
1596msgid "Popular Lists"
1597msgstr ""
1598
1599msgctxt "#90032"
1600msgid "Trending Lists"
1601msgstr ""
1602
1603msgctxt "#90033"
1604msgid "Recomendations"
1605msgstr ""
1606
1607msgctxt "#90034"
1608msgid "Watched History"
1609msgstr ""
1610
1611msgctxt "#90035"
1612msgid "Watchlist"
1613msgstr ""
1614
1615msgctxt "#90036"
1616msgid "Top 10 BoxOffice"
1617msgstr ""
1618
1619msgctxt "#90037"
1620msgid "Popular"
1621msgstr ""
1622
1623msgctxt "#90038"
1624msgid "Popular Recent"
1625msgstr ""
1626
1627msgctxt "#90039"
1628msgid "Airing"
1629msgstr ""
1630
1631msgctxt "#90040"
1632msgid "Years"
1633msgstr ""
1634
1635msgctxt "#90041"
1636msgid "Genres"
1637msgstr ""
1638
1639msgctxt "#90042"
1640msgid "Top Rated"
1641msgstr ""
1642
1643msgctxt "#90043"
1644msgid "Most Watched"
1645msgstr ""
1646
1647msgctxt "#90044"
1648msgid "Torrentio Configuration"
1649msgstr ""
1650
1651msgctxt "#90045"
1652msgid "Enable Torrentio Settings"
1653msgstr ""
1654
1655msgctxt "#90046"
1656msgid "Enable or disable the Torrentio Configuration"
1657msgstr ""
1658
1659msgctxt "#90047"
1660msgid "Select Torrentio Providers"
1661msgstr ""
1662
1663msgctxt "#90048"
1664msgid "Choose which providers to include in the stream request."
1665msgstr ""
1666
1667# Context menus
1668
1669msgctxt "#90049"
1670msgid "Scrape again"
1671msgstr ""
1672
1673msgctxt "#90050"
1674msgid "Recommendations"
1675msgstr ""
1676
1677msgctxt "#90051"
1678msgid "Similar"
1679msgstr ""
1680
1681msgctxt "#90052"
1682msgid "Thread Number"
1683msgstr ""
1684
1685msgctxt "#90053"
1686msgid "Select the number of threads (1-30)"
1687msgstr ""
1688
1689msgctxt "#90054"
1690msgid "Kodi logs"
1691msgstr ""
1692
1693msgctxt "#90055"
1694msgid "Kodi old logs"
1695msgstr ""
1696
1697msgctxt "#90056"
1698msgid "Displays Kodi logs to help diagnose issues."
1699msgstr ""
1700
1701msgctxt "#90057"
1702msgid "Logs"
1703msgstr ""
1704
1705msgctxt "#90058"
1706msgid "Filter by Size"
1707msgstr ""
1708
1709msgctxt "#90059"
1710msgid "Enable Size Filter"
1711msgstr ""
1712
1713msgctxt "#90060"
1714msgid "Minimum Size (MB)"
1715msgstr ""
1716
1717msgctxt "#90061"
1718msgid "Maximum Size (MB)"
1719msgstr ""
1720
1721msgctxt "#90062"
1722msgid "Enable Cached Search"
1723msgstr ""
1724
1725msgctxt "#90063"
1726msgid "Only show cached streams from external services"
1727msgstr ""
1728
1729msgctxt "#90064"
1730msgid "Popular"
1731msgstr ""
1732
1733msgctxt "#90065"
1734msgid "Languages"
1735msgstr ""
1736
1737msgctxt "#90066"
1738msgid "Networks"
1739msgstr ""
1740
1741msgctxt "#90067"
1742msgid "Collections"
1743msgstr ""
1744
1745msgctxt "#90068"
1746msgid "Search for collections:"
1747msgstr ""
1748
1749msgctxt "#90069"
1750msgid "Main Menu"
1751msgstr ""
1752
1753msgctxt "#90070"
1754msgid "Last Titles"
1755msgstr ""
1756
1757msgctxt "#90071"
1758msgid "Last Files"
1759msgstr ""
1760
1761msgctxt "#90072"
1762msgid "Trakt Lists"
1763msgstr ""
1764
1765msgctxt "#90073"
1766msgid "Metadata"
1767msgstr ""
1768
1769msgctxt "#90074"
1770msgid "Top Lists"
1771msgstr ""
1772
1773msgctxt "#90075"
1774msgid "User Lists"
1775msgstr ""
1776
1777msgctxt "#90076"
1778msgid "Search Lists"
1779msgstr ""
1780
1781msgctxt "#90077"
1782msgid "MDbList Configuration"
1783msgstr ""
1784
1785msgctxt "#90078"
1786msgid "People"
1787msgstr ""
1788
1789msgctxt "#90079"
1790msgid "Popular People"
1791msgstr ""
1792
1793msgctxt "#90080"
1794msgid "Latest People"
1795msgstr ""
1796
1797msgctxt "#90081"
1798msgid "Search People"
1799msgstr ""
1800
1801msgctxt "#90082"
1802msgid "Download subtitles"
1803msgstr ""
1804
1805msgctxt "#90083"
1806msgid "Download video"
1807msgstr ""
1808
1809msgctxt "#90084"
1810msgid "Browse into"
1811msgstr ""
1812
1813msgctxt "#90085"
1814msgid "Include TV show Specials"
1815msgstr ""
1816
1817msgctxt "#90086"
1818msgid "Airing Today"
1819msgstr ""
1820
1821msgctxt "#90099"
1822msgid "Filter by Source Type"
1823msgstr ""
1824
1825# BluRay/UHD
1826msgctxt "#90100"
1827msgid "Include BluRay/UHD sources"
1828msgstr ""
1829
1830msgctxt "#90105"
1831msgid "Includes releases such as: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX"
1832msgstr ""
1833
1834# WEB/HD
1835msgctxt "#90101"
1836msgid "Include WEB/HD sources"
1837msgstr ""
1838
1839msgctxt "#90106"
1840msgid "Includes releases such as: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux"
1841msgstr ""
1842
1843# DVD/TV/SAT
1844msgctxt "#90102"
1845msgid "Include DVD/TV/SAT sources"
1846msgstr ""
1847
1848msgctxt "#90107"
1849msgid "Includes releases such as: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV"
1850msgstr ""
1851
1852# CAM/Screener
1853msgctxt "#90103"
1854msgid "Include CAM/Screener sources"
1855msgstr ""
1856
1857msgctxt "#90108"
1858msgid "Includes releases such as: CAM, TeleSync, TeleCine, SCR"
1859msgstr ""
1860
1861# Unknown
1862msgctxt "#90104"
1863msgid "Include Unknown sources"
1864msgstr ""
1865
1866msgctxt "#90109"
1867msgid "Includes uncategorized or unidentified sources"
1868msgstr ""
1869
1870msgctxt "#90110"
1871msgid "Do you want to clear your watched history?."
1872msgstr ""
1873
1874msgctxt "#90111"
1875msgid "Do you want to clear your history?."
1876msgstr ""
1877
1878msgctxt "#90112"
1879msgid "Clear History"
1880msgstr ""
1881
1882msgctxt "#90113"
1883msgid "Click to delete all watched history immediately."
1884msgstr ""
1885
1886msgctxt "#90114"
1887msgid "History cleared"
1888msgstr ""
1889
1890msgctxt "#90115"
1891msgid "Source Select"
1892msgstr ""
1893
1894msgctxt "#90116"
1895msgid "Open Settings"
1896msgstr ""
1897
1898msgctxt "#90117"
1899msgid "Select Torrentio Providers"
1900msgstr ""
1901
1902msgctxt "#90118"
1903msgid "Port"
1904msgstr ""
1905
1906msgctxt "#90119"
1907msgid "Jackett port (default:9117)"
1908msgstr ""
1909
1910msgctxt "#90120"
1911msgid "Prowlarr port (default:9696)"
1912msgstr ""
1913
1914msgctxt "#90121"
1915msgid "All Debrid Configuration"
1916msgstr ""
1917
1918msgctxt "#90122"
1919msgid "Auto download subtitles"
1920msgstr ""
1921
1922msgctxt "#90123"
1923msgid "Stremio Timeout"
1924msgstr ""
1925
1926msgctxt "#90124"
1927msgid "Webdav Client Configuration"
1928msgstr ""
1929
1930msgctxt "#90126"
1931msgid "Host"
1932msgstr ""
1933
1934msgctxt "#90127"
1935msgid "Username"
1936msgstr ""
1937
1938msgctxt "#90128"
1939msgid "Password"
1940msgstr ""
1941
1942msgctxt "#90129"
1943msgid "Test Connection"
1944msgstr ""
1945
1946msgctxt "#90131"
1947msgid "Webdav"
1948msgstr ""
1949
1950msgctxt "#90132"
1951msgid "Remote Path"
1952msgstr ""
1953
1954msgctxt "#90133"
1955msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)."
1956msgstr ""
1957
1958msgctxt "#90134"
1959msgid "The username required for authenticating with your WebDAV server"
1960msgstr ""
1961
1962msgctxt "#90135"
1963msgid "The password for the specified username to authenticate with your WebDAV server"
1964msgstr ""
1965
1966msgctxt "#90136"
1967msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)."
1968msgstr ""
1969
1970msgctxt "#90137"
1971msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials."
1972msgstr ""
1973
1974msgctxt "#90138"
1975msgid "Remove Custom Addon"
1976msgstr "Remove Custom Addon"
1977
1978msgctxt "#90139"
1979msgid "Playback"
1980msgstr "Playback"
1981
1982msgctxt "#90140"
1983msgid "Super quick play"
1984msgstr ""
1985
1986msgctxt "#90141"
1987msgid "Re-ask to play last played video"
1988msgstr ""
1989
1990msgctxt "#90142"
1991msgid "Replay Dialog"
1992msgstr "Replay Dialog"
1993
1994msgctxt "#90143"
1995msgid "Do you want to play last watched?"
1996msgstr "Do you want to play last watched?"
1997
1998msgctxt "#90144"
1999msgid "Could not resolve playback URL"
2000msgstr "Could not resolve playback URL"
2001
2002msgctxt "#90145"
2003msgid "Precaching Enabled"
2004msgstr "Precaching Enabled"
2005
2006msgctxt "#90146"
2007msgid "Enable or disable precaching of next episodes."
2008msgstr "Enable or disable precaching of next episodes."
2009
2010msgctxt "#90147"
2011msgid "Precaching Episode Count"
2012msgstr "Precaching Episode Count"
2013
2014msgctxt "#90148"
2015msgid "Number of next episodes to precache."
2016msgstr "Number of next episodes to precache."
2017
2018msgctxt "#30901"
2019msgid "Show Only Stremio Catalogs"
2020msgstr ""
2021
2022msgctxt "#30902"
2023msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus."
2024msgstr ""
2025
2026msgctxt "#30903"
2027msgid "Select Live TV Addons"
2028msgstr ""
2029
2030msgctxt "#30904"
2031msgid "Choose specific Stremio addons for Live TV content"
2032msgstr ""
2033
2034msgctxt "#30920"
2035msgid "Clear Trakt Cache"
2036msgstr "Clear Trakt Cache"
2037
2038msgctxt "#30921"
2039msgid "Clear TMDB Cache"
2040msgstr "Clear TMDB Cache"
2041
2042msgctxt "#30922"
2043msgid "Clear Providers Cache"
2044msgstr "Clear Providers Cache"
2045
2046msgctxt "#30923"
2047msgid "Clear Settings Cache"
2048msgstr "Clear Settings Cache"
2049
2050msgctxt "#90149"
2051msgid "Maintenance"
2052msgstr "Maintenance"
2053
2054msgctxt "#90150"
2055msgid "Stremio: General"
2056msgstr "Stremio: General"
2057
2058msgctxt "#90151"
2059msgid "Stremio: Account"
2060msgstr "Stremio: Account"
2061
2062msgctxt "#90152"
2063msgid "Stremio: Addons & Catalogs"
2064msgstr "Stremio: Addons & Catalogs"
2065
2066msgctxt "#90153"
2067msgid "Stremio: Torrentio"
2068msgstr "Stremio: Torrentio"
2069
2070msgctxt "#90154"
2071msgid "Trending Recent"
2072msgstr ""
2073
2074msgctxt "#90155"
2075msgid "Stremio: Filters"
2076msgstr "Stremio: Filters"
2077
2078msgctxt "#32300"
2079msgid "Filter by Quality"
2080msgstr ""
2081
2082msgctxt "#32301"
2083msgid "Filter by Provider"
2084msgstr ""
2085
2086msgctxt "#32302"
2087msgid "Filter by Source"
2088msgstr ""
2089
2090msgctxt "#32303"
2091msgid "Filter by Language"
2092msgstr ""
2093
2094msgctxt "#32304"
2095msgid "Filter by Torrents"
2096msgstr ""
2097
2098msgctxt "#32305"
2099msgid "Filter by Debrid"
2100msgstr ""
2101
2102msgctxt "#32306"
2103msgid "Reset Filter"
2104msgstr ""
2105
2106msgctxt "#30930"
2107msgid "Ping Addons"
2108msgstr "Ping Addons"
2109
2110msgctxt "#30931"
2111msgid "Check availability of addons and show only reachable ones"
2112msgstr "Check availability of addons and show only reachable ones"
2113
2114msgctxt "#90164"
2115msgid "Skip Intro / Recap"
2116msgstr "Skip Intro / Recap"
2117
2118msgctxt "#90165"
2119msgid "Enable Skip Intro"
2120msgstr "Enable Skip Intro"
2121
2122msgctxt "#90166"
2123msgid "Detect intros and recaps using IntroDB and offer to skip them during playback"
2124msgstr "Detect intros and recaps using IntroDB and offer to skip them during playback"
2125
2126msgctxt "#90167"
2127msgid "Auto-skip"
2128msgstr "Auto-skip"
2129
2130msgctxt "#90168"
2131msgid "Automatically skip intros and recaps without showing the button"
2132msgstr "Automatically skip intros and recaps without showing the button"
2133
2134msgctxt "#90160"
2135msgid "Skip Intro"
2136msgstr "Skip Intro"
2137
2138msgctxt "#90161"
2139msgid "Skip Recap"
2140msgstr "Skip Recap"
2141
2142msgctxt "#90162"
2143msgid "Manage from Phone"
2144msgstr "Manage from Phone"
2145
2146msgctxt "#90163"
2147msgid "Start a local web server and show a QR code to manage Stremio sources from your phone"
2148msgstr "Start a local web server and show a QR code to manage Stremio sources from your phone"
2149
2150msgctxt "#90200"
2151msgid "Continue Watching"
2152msgstr "Continue Watching"
2153
2154msgctxt "#90201"
2155msgid "Library"
2156msgstr "Library"
2157
2158msgctxt "#90202"
2159msgid "My Shows"
2160msgstr "My Shows"
2161
2162msgctxt "#90203"
2163msgid "My Movies"
2164msgstr "My Movies"
2165
2166msgctxt "#90204"
2167msgid "Remove from Library"
2168msgstr "Remove from Library"
2169
2170msgctxt "#90205"
2171msgid "Add to Library"
2172msgstr "Add to Library"
2173
2174msgctxt "#90206"
2175msgid "Remove"
2176msgstr "Remove"
2177
2178msgctxt "#90207"
2179msgid "Search Movies & TV"
2180msgstr ""
2181
2182msgctxt "#90208"
2183msgid "Recent Searches"
2184msgstr ""
2185
2186msgctxt "#90209"
2187msgid "Recent Direct Searches"
2188msgstr ""
2189
2190msgctxt "#90210"
2191msgid "Clear All Search History"
2192msgstr ""
2193
2194msgctxt "#90211"
2195msgid "Bypass Stremio Addons Native Sorting"
2196msgstr ""
2197
2198msgctxt "#90212"
2199msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds."
2200msgstr ""
2201
2202msgctxt "#90213"
2203msgid "Select Bypassed Addons"
2204msgstr ""
2205
2206msgctxt "#90214"
2207msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering."
2208msgstr ""
2209
2210msgctxt "#90215"
2211msgid "Downgrade"
2212msgstr ""
2213
2214msgctxt "#90671"
2215msgid "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails."
2216msgstr ""
2217
2218msgctxt "#30932"
2219msgid "Update Action"
2220msgstr ""
2221
2222msgctxt "#30933"
2223msgid "Choose how to handle background update checks."
2224msgstr ""
2225
2226msgctxt "#30934"
2227msgid "Ask"
2228msgstr ""
2229
2230msgctxt "#30935"
2231msgid "Notify"
2232msgstr ""
2233
2234msgctxt "#90216"
2235msgid "Volume Check Enabled"
2236msgstr "Volume Check Enabled"
2237
2238msgctxt "#90217"
2239msgid "Automatically limits volume if it is above the threshold before playback."
2240msgstr "Automatically limits volume if it is above the threshold before playback."
2241
2242msgctxt "#90218"
2243msgid "Volume Threshold"
2244msgstr "Volume Threshold"
2245
2246msgctxt "#90219"
2247msgid "The volume percentage above which the warning triggers."
2248msgstr "The volume percentage above which the warning triggers."
2249
2250msgctxt "#90220"
2251msgid "High Volume Warning"
2252msgstr "High Volume Warning"
2253
2254msgctxt "#90221"
2255msgid "High volume detected. Adjusting to %s%% for safety."
2256msgstr "High volume detected. Adjusting to %s%% for safety."
2257
2258msgctxt "#90222"
2259msgid "Fanart.tv Configuration"
2260msgstr ""
2261
2262msgctxt "#90223"
2263msgid "Client Key"
2264msgstr ""
2265
2266msgctxt "#90224"
2267msgid "Your personal Fanart.tv client key for higher quality assets."
2268msgstr ""
2269
2270msgctxt "#90225"
2271msgid "Debrid Notifications"
2272msgstr ""
2273
2274msgctxt "#90226"
2275msgid "Debrid Expiration Notification"
2276msgstr ""
2277
2278msgctxt "#90227"
2279msgid "Enable or disable notifications when your Debrid subscription is about to expire."
2280msgstr ""
2281
2282msgctxt "#90228"
2283msgid "Expiration Threshold (Days)"
2284msgstr ""
2285
2286msgctxt "#90229"
2287msgid "How many days before expiration to start notifying."
2288msgstr ""
2289
2290msgctxt "#90230"
2291msgid "%s expires in %d days."
2292msgstr ""
2293
2294msgctxt "#90231"
2295msgid "%s expires today!"
2296msgstr ""
2297
2298msgctxt "#90232"
2299msgid "Easynews"
2300msgstr ""
2301
2302msgctxt "#90233"
2303msgid "Enable EasyNews as a direct stream provider."
2304msgstr ""
2305
2306msgctxt "#90234"
2307msgid "Your EasyNews username."
2308msgstr ""
2309
2310msgctxt "#90235"
2311msgid "Your EasyNews password."
2312msgstr ""
2313
2314msgctxt "#90236"
2315msgid "Request timeout in seconds."
2316msgstr ""
2317
2318msgctxt "#90237"
2319msgid "Account Information"
2320msgstr ""
2321
2322msgctxt "#90238"
2323msgid "Search Dialog Style"
2324msgstr "Search Dialog Style"
2325
2326msgctxt "#90239"
2327msgid "Background"
2328msgstr "Background"
2329
2330msgctxt "#90240"
2331msgid "Detailed Status"
2332msgstr "Detailed Status"
2333
2334msgctxt "#90241"
2335msgid "Search Status"
2336msgstr "Search Status"
2337
2338msgctxt "#90242"
2339msgid "Cancel"
2340msgstr "Cancel"
2341
2342msgctxt "#90243"
2343msgid "Results found: "
2344msgstr "Results found: "
2345
2346msgctxt "#90244"
2347msgid "Pending"
2348msgstr "Pending"
2349
2350msgctxt "#90245"
2351msgid "In Progress"
2352msgstr "In Progress"
2353
2354msgctxt "#90246"
2355msgid "Completed"
2356msgstr "Completed"
2357
2358msgctxt "#90247"
2359msgid "Failed"
2360msgstr "Failed"
2361
2362msgctxt "#90248"
2363msgid "Cancelled"
2364msgstr "Cancelled"
2365
2366msgctxt "#90249"
2367msgid "Your personal Jackgram API token for authentication."
2368msgstr "Your personal Jackgram API token for authentication."
2369
2370msgctxt "#90250"
2371msgid "Subtitles Found"
2372msgstr "Subtitles Found"
2373
2374msgctxt "#90251"
2375msgid "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?"
2376msgstr "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?"
2377
2378msgctxt "#90252"
2379msgid "No subtitles found"
2380msgstr "No subtitles found"
2381
2382msgctxt "#90253"
2383msgid "No subtitles selected"
2384msgstr "No subtitles selected"
2385
2386msgctxt "#90254"
2387msgid "Translate Subtitles"
2388msgstr "Translate Subtitles"
2389
2390msgctxt "#90255"
2391msgid "Do you want to also translate the selected subtitles?"
2392msgstr "Do you want to also translate the selected subtitles?"
2393
2394msgctxt "#90256"
2395msgid "Select Subtitle to Download"
2396msgstr "Select Subtitle to Download"
2397
2398msgctxt "#90257"
2399msgid "Subtitles Loaded"
2400msgstr "Subtitles Loaded"
2401
2402msgctxt "#90258"
2403msgid "Backup"
2404msgstr "Backup"
2405
2406msgctxt "#90259"
2407msgid "Export Backup"
2408msgstr "Export Backup"
2409
2410msgctxt "#90260"
2411msgid "Export addon settings and custom Stremio addons to a JSON backup file."
2412msgstr "Export addon settings and custom Stremio addons to a JSON backup file."
2413
2414msgctxt "#90261"
2415msgid "Restore Backup"
2416msgstr "Restore Backup"
2417
2418msgctxt "#90262"
2419msgid "Restore addon settings and replace custom Stremio addons from a JSON backup file."
2420msgstr "Restore addon settings and replace custom Stremio addons from a JSON backup file."
2421
2422msgctxt "#90263"
2423msgid "Remove Sensitive Data"
2424msgstr "Remove Sensitive Data"
2425
2426msgctxt "#90264"
2427msgid "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups."
2428msgstr "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups."
2429
2430msgctxt "#90265"
2431msgid "Settings backup exported"
2432msgstr "Settings backup exported"
2433
2434msgctxt "#90266"
2435msgid "Settings backup restored"
2436msgstr "Settings backup restored"
2437
2438msgctxt "#90267"
2439msgid "Settings Backup Failed"
2440msgstr "Settings Backup Failed"
2441
2442msgctxt "#90268"
2443msgid "Select export folder"
2444msgstr "Select export folder"
2445
2446msgctxt "#90269"
2447msgid "Select backup file"
2448msgstr "Select backup file"
2449
2450msgctxt "#90270"
2451msgid "Backup file is invalid."
2452msgstr "Backup file is invalid."
2453
2454msgctxt "#90271"
2455msgid "No export folder selected."
2456msgstr "No export folder selected."
2457
2458msgctxt "#90272"
2459msgid "No backup file selected."
2460msgstr "No backup file selected."
2461
2462msgctxt "#90273"
2463msgid "Unable to export settings backup."
2464msgstr "Unable to export settings backup."
2465
2466msgctxt "#90274"
2467msgid "Unable to restore settings backup."
2468msgstr "Unable to restore settings backup."
2469
2470msgctxt "#90275"
2471msgid "Restore Backup"
2472msgstr "Restore Backup"
2473
2474msgctxt "#90276"
2475msgid "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?"
2476msgstr "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?"
2477
2478msgctxt "#90277"
2479msgid "Restore cancelled"
2480msgstr "Restore cancelled"
2481
2482msgctxt "#90278"
2483msgid "Reset All Settings"
2484msgstr "Reset All Settings"
2485
2486msgctxt "#90279"
2487msgid "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?"
2488msgstr "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?"
2489
2490msgctxt "#90280"
2491msgid "Reset cancelled"
2492msgstr "Reset cancelled"
2493
2494msgctxt "#90281"
2495msgid "All addon settings reset"
2496msgstr "All addon settings reset"
2497
2498msgctxt "#90282"
2499msgid "Reset Failed"
2500msgstr "Reset Failed"
2501
2502msgctxt "#90283"
2503msgid "Unable to reset addon settings."
2504msgstr "Unable to reset addon settings."
2505
2506msgctxt "#90284"
2507msgid "Reset every addon setting back to its default value and clear custom Stremio addons."
2508msgstr "Reset every addon setting back to its default value and clear custom Stremio addons."
2509
2510msgctxt "#90285"
2511msgid "Factory Reset"
2512msgstr "Factory Reset"
2513
2514msgctxt "#90286"
2515msgid "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?"
2516msgstr "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?"
2517
2518msgctxt "#90287"
2519msgid "Factory reset cancelled"
2520msgstr "Factory reset cancelled"
2521
2522msgctxt "#90288"
2523msgid "Factory reset completed"
2524msgstr "Factory reset completed"
2525
2526msgctxt "#90289"
2527msgid "Factory Reset Failed"
2528msgstr "Factory Reset Failed"
2529
2530msgctxt "#90290"
2531msgid "Unable to complete factory reset."
2532msgstr "Unable to complete factory reset."
2533
2534msgctxt "#90291"
2535msgid "Reset settings to defaults, clear caches, and wipe local addon history/database data."
2536msgstr "Reset settings to defaults, clear caches, and wipe local addon history/database data."
2537
2538msgctxt "#90292"
2539msgid "Trakt Library"
2540msgstr "Trakt Library"
2541
2542msgctxt "#90293"
2543msgid "Trakt Discovery"
2544msgstr "Trakt Discovery"
2545
2546msgctxt "#90294"
2547msgid "Collection"
2548msgstr "Collection"
2549
2550msgctxt "#90295"
2551msgid "Favorites"
2552msgstr "Favorites"
2553
2554msgctxt "#90296"
2555msgid "Up Next"
2556msgstr "Up Next"
2557
2558msgctxt "#90297"
2559msgid "My Lists"
2560msgstr "My Lists"
2561
2562msgctxt "#90298"
2563msgid "Liked Lists"
2564msgstr "Liked Lists"
2565
2566msgctxt "#90331"
2567msgid "Stremio Addons"
2568msgstr "Stremio Addons"
2569
2570msgctxt "#90332"
2571msgid "No addons available yet. Use Log In to Stremio or Add Custom Addon first."
2572msgstr "No addons available yet. Use Log In to Stremio or Add Custom Addon first."
2573
2574msgctxt "#90333"
2575msgid "Stremio Stream Addons"
2576msgstr "Stremio Stream Addons"
2577
2578msgctxt "#90334"
2579msgid "Stremio Catalog Addons"
2580msgstr "Stremio Catalog Addons"
2581
2582msgctxt "#90335"
2583msgid "Stremio Live TV Addons"
2584msgstr "Stremio Live TV Addons"
2585
2586msgctxt "#90336"
2587msgid "Refresh addons imported from your Stremio account. Custom addons remain unchanged."
2588msgstr "Refresh addons imported from your Stremio account. Custom addons remain unchanged."
2589
2590msgctxt "#90337"
2591msgid "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first."
2592msgstr "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first."
2593
2594msgctxt "#90338"
2595msgid "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first."
2596msgstr "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first."
2597
2598msgctxt "#90339"
2599msgid "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first."
2600msgstr "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first."
2601
2602msgctxt "#90340"
2603msgid "Remove custom Stremio addons you previously added manually."
2604msgstr "Remove custom Stremio addons you previously added manually."
2605
2606msgctxt "#90341"
2607msgid "Choose Torrent Client"
2608msgstr "Choose Torrent Client"
2609
2610msgctxt "#90342"
2611msgid "Clear Database Cache"
2612msgstr "Clear Database Cache"
2613
2614msgctxt "#90343"
2615msgid "Clear Stremio Cache"
2616msgstr "Clear Stremio Cache"
2617
2618msgctxt "#90344"
2619msgid "Clear Debrid Cache"
2620msgstr "Clear Debrid Cache"
2621
2622msgctxt "#90345"
2623msgid "Clear MDBList Cache"
2624msgstr "Clear MDBList Cache"
2625
2626msgctxt "#90346"
2627msgid "Restore backup from"
2628msgstr "Restore backup from"
2629
2630msgctxt "#90347"
2631msgid "File"
2632msgstr "File"
2633
2634msgctxt "#90348"
2635msgid "URL"
2636msgstr "URL"
2637
2638msgctxt "#90349"
2639msgid "Enter backup URL"
2640msgstr "Enter backup URL"
2641
2642msgctxt "#90350"
2643msgid "No backup URL provided."
2644msgstr "No backup URL provided."
2645
2646msgctxt "#90351"
2647msgid "Unable to download settings backup."
2648msgstr "Unable to download settings backup."
2649
2650msgctxt "#90352"
2651msgid "Source Select Style"
2652msgstr "Source Select Style"
2653
2654msgctxt "#90353"
2655msgid "Modern"
2656msgstr "Modern"
2657
2658msgctxt "#90354"
2659msgid "OLED"
2660msgstr "OLED"
2661
2662msgctxt "#90355"
2663msgid "Cyberpunk"
2664msgstr "Cyberpunk"
2665
2666msgctxt "#90356"
2667msgid "Forest"
2668msgstr "Forest"
2669
2670msgctxt "#90357"
2671msgid "Gold"
2672msgstr "Gold"
2673
2674msgctxt "#90358"
2675msgid "No playable results found"
2676msgstr "No playable results found"
2677
2678msgctxt "#90359"
2679msgid "Add to TorrServer"
2680msgstr "Add to TorrServer"
2681
2682msgctxt "#90360"
2683msgid "Added to TorrServer"
2684msgstr "Added to TorrServer"
2685
2686msgctxt "#90361"
2687msgid "Could not resolve torrent details"
2688msgstr "Could not resolve torrent details"
2689
2690msgctxt "#90362"
2691msgid "Added to %s cloud"
2692msgstr "Added to %s cloud"
2693
2694msgctxt "#90363"
2695msgid "Choose Debrid Service"
2696msgstr "Choose Debrid Service"
2697
2698msgctxt "#90364"
2699msgid "No supported debrid service is enabled"
2700msgstr "No supported debrid service is enabled"
2701
2702msgctxt "#90365"
2703msgid "Download to Debrid"
2704msgstr "Download to Debrid"
2705
2706msgctxt "#90366"
2707msgid "Include Unknown quality"
2708msgstr "Include Unknown quality"
2709
2710msgctxt "#90367"
2711msgid "Includes releases where video quality could not be identified"
2712msgstr "Includes releases where video quality could not be identified"
2713
2714msgctxt "#90368"
2715msgid "Search by Keyword"
2716msgstr "Search by Keyword"
2717
2718msgctxt "#90369"
2719msgid "Latest Movies"
2720msgstr "Latest Movies"
2721
2722msgctxt "#90370"
2723msgid "Latest Series"
2724msgstr "Latest Series"
2725
2726msgctxt "#90371"
2727msgid "Raw Files"
2728msgstr "Raw Files"
2729
2730msgctxt "#90372"
2731msgid "MDblist"
2732msgstr "MDblist"
2733
2734msgctxt "#90373"
2735msgid "Active Downloads"
2736msgstr "Active Downloads"
2737
2738msgctxt "#90374"
2739msgid "Cancel Download"
2740msgstr "Cancel Download"
2741
2742msgctxt "#90375"
2743msgid "Delete File"
2744msgstr "Delete File"
2745
2746msgctxt "#90376"
2747msgid "Search Collections"
2748msgstr "Search Collections"
2749
2750msgctxt "#90377"
2751msgid "Popular Collections"
2752msgstr "Popular Collections"
2753
2754msgctxt "#90378"
2755msgid "Top Rated Collections"
2756msgstr "Top Rated Collections"
2757
2758msgctxt "#90379"
2759msgid "Recommendations"
2760msgstr "Recommendations"
2761
2762msgctxt "#90380"
2763msgid "Similar"
2764msgstr "Similar"
2765
2766msgctxt "#90381"
2767msgid "Keyword: %s"
2768msgstr "Keyword: %s"
2769
2770msgctxt "#90382"
2771msgid "Please set a valid Trakt Client ID Key"
2772msgstr "Please set a valid Trakt Client ID Key"
2773
2774msgctxt "#90383"
2775msgid "Please set a valid Trakt Client Secret Key"
2776msgstr "Please set a valid Trakt Client Secret Key"
2777
2778msgctxt "#90384"
2779msgid "Trakt Error Authorizing"
2780msgstr "Trakt Error Authorizing"
2781
2782msgctxt "#90385"
2783msgid "Trakt Account Authorized"
2784msgstr "Trakt Account Authorized"
2785
2786msgctxt "#90386"
2787msgid "You are now logged out from Trakt.tv"
2788msgstr "You are now logged out from Trakt.tv"
2789
2790msgctxt "#90387"
2791msgid "Failed to mark as watched"
2792msgstr "Failed to mark as watched"
2793
2794msgctxt "#90388"
2795msgid "Marked as watched"
2796msgstr "Marked as watched"
2797
2798msgctxt "#90389"
2799msgid "No results found"
2800msgstr "No results found"
2801
2802msgctxt "#90390"
2803msgid "Invalid mode"
2804msgstr "Invalid mode"
2805
2806msgctxt "#90391"
2807msgid "No recommendations found"
2808msgstr "No recommendations found"
2809
2810msgctxt "#90392"
2811msgid "No similar items found"
2812msgstr "No similar items found"
2813
2814msgctxt "#90393"
2815msgid "TMDB metadata not found"
2816msgstr "TMDB metadata not found"
2817
2818msgctxt "#90394"
2819msgid "No TV shows found"
2820msgstr "No TV shows found"
2821
2822msgctxt "#90395"
2823msgid "No shows airing today"
2824msgstr "No shows airing today"
2825
2826msgctxt "#90396"
2827msgid "Invalid collection query"
2828msgstr "Invalid collection query"
2829
2830msgctxt "#90397"
2831msgid "No keywords found"
2832msgstr "No keywords found"
2833
2834msgctxt "#90398"
2835msgid "No keyword ID provided"
2836msgstr "No keyword ID provided"
2837
2838msgctxt "#90399"
2839msgid "No results found for this keyword"
2840msgstr "No results found for this keyword"
2841
2842msgctxt "#90400"
2843msgid "No TMDB ID found"
2844msgstr "No TMDB ID found"
2845
2846msgctxt "#90401"
2847msgid "Unsupported media type or missing external_ids"
2848msgstr "Unsupported media type or missing external_ids"
2849
2850msgctxt "#90402"
2851msgid "Refreshed Metadata"
2852msgstr "Refreshed Metadata"
2853
2854msgctxt "#90403"
2855msgid "No Stremio addons selected"
2856msgstr "No Stremio addons selected"
2857
2858msgctxt "#90404"
2859msgid "No suitable source found for auto play."
2860msgstr "No suitable source found for auto play."
2861
2862msgctxt "#90405"
2863msgid "No sources found with the preferred quality."
2864msgstr "No sources found with the preferred quality."
2865
2866msgctxt "#90406"
2867msgid "No languages found"
2868msgstr "No languages found"
2869
2870msgctxt "#90407"
2871msgid "Failed to resolve playback source"
2872msgstr "Failed to resolve playback source"
2873
2874msgctxt "#90408"
2875msgid "Trakt not authorized. Please authenticate."
2876msgstr "Trakt not authorized. Please authenticate."
2877
2878msgctxt "#90409"
2879msgid "Added to Trakt Watchlist"
2880msgstr "Added to Trakt Watchlist"
2881
2882msgctxt "#90410"
2883msgid "Error modifying Trakt Watchlist"
2884msgstr "Error modifying Trakt Watchlist"
2885
2886msgctxt "#90411"
2887msgid "Marked as Watched on Trakt"
2888msgstr "Marked as Watched on Trakt"
2889
2890msgctxt "#90412"
2891msgid "Error modifying Trakt Watched status"
2892msgstr "Error modifying Trakt Watched status"
2893
2894msgctxt "#90413"
2895msgid "Play"
2896msgstr "Play"
2897
2898msgctxt "#90414"
2899msgid "Trailer"
2900msgstr "Trailer"
2901
2902msgctxt "#90415"
2903msgid "Watched"
2904msgstr "Watched"
2905
2906msgctxt "#90416"
2907msgid "Refresh"
2908msgstr "Refresh"
2909
2910msgctxt "#90418"
2911msgid "No credits found for this ID"
2912msgstr "No credits found for this ID"
2913
2914msgctxt "#90419"
2915msgid "Credited People"
2916msgstr "Credited People"
2917
2918msgctxt "#90420"
2919msgid "Person Details"
2920msgstr "Person Details"
2921
2922msgctxt "#90421"
2923msgid "Person not found"
2924msgstr "Person not found"
2925
2926msgctxt "#90422"
2927msgid "Jackgram not activated"
2928msgstr "Jackgram not activated"
2929
2930msgctxt "#90423"
2931msgid "Not yet implemented"
2932msgstr "Not yet implemented"
2933
2934msgctxt "#90424"
2935msgid "Unsupported debrid type"
2936msgstr "Unsupported debrid type"
2937
2938msgctxt "#90425"
2939msgid "Easynews credentials required"
2940msgstr "Easynews credentials required"
2941
2942msgctxt "#90426"
2943msgid "No Corrupt or Missing Databases"
2944msgstr "No Corrupt or Missing Databases"
2945
2946msgctxt "#90427"
2947msgid "Otaku (script.otaku.mappings) not found"
2948msgstr "Otaku (script.otaku.mappings) not found"
2949
2950msgctxt "#90428"
2951msgid "Database read error. File may be corrupted."
2952msgstr "Database read error. File may be corrupted."
2953
2954msgctxt "#90429"
2955msgid "Failed to delete data. Database error."
2956msgstr "Failed to delete data. Database error."
2957
2958msgctxt "#90430"
2959msgid "Failed to clear cache. Database in use."
2960msgstr "Failed to clear cache. Database in use."
2961
2962msgctxt "#90431"
2963msgid "No items found in this list"
2964msgstr "No items found in this list"
2965
2966msgctxt "#90432"
2967msgid "Databases Rebuilt"
2968msgstr "Databases Rebuilt"
2969
2970msgctxt "#90433"
2971msgid "[B]Following Databases Rebuilt:[/B][CR][CR]%s"
2972msgstr "[B]Following Databases Rebuilt:[/B][CR][CR]%s"
2973
2974msgctxt "#90434"
2975msgid "Added to Trakt watchlist"
2976msgstr "Added to Trakt watchlist"
2977
2978msgctxt "#90435"
2979msgid "Failed to add to Trakt watchlist"
2980msgstr "Failed to add to Trakt watchlist"
2981
2982msgctxt "#90436"
2983msgid "Removed from Trakt watchlist"
2984msgstr "Removed from Trakt watchlist"
2985
2986msgctxt "#90437"
2987msgid "Failed to remove from Trakt watchlist"
2988msgstr "Failed to remove from Trakt watchlist"
2989
2990msgctxt "#90438"
2991msgid "Failed to mark as watched on Trakt"
2992msgstr "Failed to mark as watched on Trakt"
2993
2994msgctxt "#90439"
2995msgid "Marked as unwatched on Trakt"
2996msgstr "Marked as unwatched on Trakt"
2997
2998msgctxt "#90440"
2999msgid "Failed to mark as unwatched on Trakt"
3000msgstr "Failed to mark as unwatched on Trakt"
3001
3002msgctxt "#90441"
3003msgid "Added to Trakt collection"
3004msgstr "Added to Trakt collection"
3005
3006msgctxt "#90442"
3007msgid "Removed from Trakt collection"
3008msgstr "Removed from Trakt collection"
3009
3010msgctxt "#90443"
3011msgid "Failed to add to Trakt collection"
3012msgstr "Failed to add to Trakt collection"
3013
3014msgctxt "#90444"
3015msgid "Failed to remove from Trakt collection"
3016msgstr "Failed to remove from Trakt collection"
3017
3018msgctxt "#90445"
3019msgid "Added to Trakt favorites"
3020msgstr "Added to Trakt favorites"
3021
3022msgctxt "#90446"
3023msgid "Removed from Trakt favorites"
3024msgstr "Removed from Trakt favorites"
3025
3026msgctxt "#90447"
3027msgid "Failed to add to Trakt favorites"
3028msgstr "Failed to add to Trakt favorites"
3029
3030msgctxt "#90448"
3031msgid "Failed to remove from Trakt favorites"
3032msgstr "Failed to remove from Trakt favorites"
3033
3034msgctxt "#90449"
3035msgid "Created Trakt list"
3036msgstr "Created Trakt list"
3037
3038msgctxt "#90450"
3039msgid "Failed to create Trakt list"
3040msgstr "Failed to create Trakt list"
3041
3042msgctxt "#90451"
3043msgid "Deleted Trakt list"
3044msgstr "Deleted Trakt list"
3045
3046msgctxt "#90452"
3047msgid "Failed to delete Trakt list"
3048msgstr "Failed to delete Trakt list"
3049
3050msgctxt "#90453"
3051msgid "Liked Trakt list"
3052msgstr "Liked Trakt list"
3053
3054msgctxt "#90454"
3055msgid "Failed to like Trakt list"
3056msgstr "Failed to like Trakt list"
3057
3058msgctxt "#90455"
3059msgid "Unliked Trakt list"
3060msgstr "Unliked Trakt list"
3061
3062msgctxt "#90456"
3063msgid "Failed to unlike Trakt list"
3064msgstr "Failed to unlike Trakt list"
3065
3066msgctxt "#90457"
3067msgid "Added to Trakt list"
3068msgstr "Added to Trakt list"
3069
3070msgctxt "#90458"
3071msgid "OpenSubtitles Stremio: No results found."
3072msgstr ""
3073
3074msgctxt "#90459"
3075msgid "Failed to add to Trakt list"
3076msgstr "Failed to add to Trakt list"
3077
3078msgctxt "#90460"
3079msgid "Subtitle Manager: No subtitles available for this video."
3080msgstr ""
3081
3082msgctxt "#90461"
3083msgid "No Trakt lists found"
3084msgstr "No Trakt lists found"
3085
3086msgctxt "#90462"
3087msgid "Item not found in Trakt collection"
3088msgstr "Item not found in Trakt collection"
3089
3090msgctxt "#90463"
3091msgid "Trakt could not match this item for collection"
3092msgstr "Trakt could not match this item for collection"
3093
3094msgctxt "#90464"
3095msgid "No Trakt collection changes were applied"
3096msgstr "No Trakt collection changes were applied"
3097
3098msgctxt "#90465"
3099msgid "Could not build Trakt collection payload for show"
3100msgstr "Could not build Trakt collection payload for show"
3101
3102msgctxt "#90466"
3103msgid "Delete this Trakt list?"
3104msgstr "Delete this Trakt list?"
3105
3106msgctxt "#90468"
3107msgid "Search menu"
3108msgstr ""
3109
3110msgctxt "#90469"
3111msgid "Continue watching menu"
3112msgstr ""
3113
3114msgctxt "#90470"
3115msgid "TV Shows Items"
3116msgstr ""
3117
3118msgctxt "#90471"
3119msgid "Movies Items"
3120msgstr ""
3121
3122msgctxt "#90472"
3123msgid "Anime menu"
3124msgstr ""
3125
3126msgctxt "#90473"
3127msgid "TV Menu"
3128msgstr ""
3129
3130msgctxt "#90474"
3131msgid "Library menu"
3132msgstr ""
3133
3134msgctxt "#90475"
3135msgid "Torrents Management"
3136msgstr ""
3137
3138msgctxt "#90476"
3139msgid "Telegram menu"
3140msgstr ""
3141
3142msgctxt "#90477"
3143msgid "Cloud Storage"
3144msgstr ""
3145
3146msgctxt "#90478"
3147msgid "Downloads Management"
3148msgstr ""
3149
3150msgctxt "#90479"
3151msgid "Settings Management"
3152msgstr ""
3153
3154msgctxt "#90480"
3155msgid "History Menu"
3156msgstr ""
3157
3158msgctxt "#90481"
3159msgid "Files History"
3160msgstr ""
3161
3162msgctxt "#90482"
3163msgid "Titles History"
3164msgstr ""
3165
3166msgctxt "#90483"
3167msgid "Titles Calendar"
3168msgstr ""
3169
3170msgctxt "#90484"
3171msgid "My Shows"
3172msgstr ""
3173
3174msgctxt "#90485"
3175msgid "My Movies"
3176msgstr ""
3177
3178msgctxt "#90486"
3179msgid "Upcoming Episodes"
3180msgstr ""
3181
3182msgctxt "#90487"
3183msgid "Discovery List"
3184msgstr ""
3185
3186msgctxt "#90488"
3187msgid "Library List"
3188msgstr ""
3189
3190msgctxt "#90489"
3191msgid "Trakt Recommendations"
3192msgstr ""
3193
3194msgctxt "#90490"
3195msgid "Trending Lists"
3196msgstr ""
3197
3198msgctxt "#90491"
3199msgid "Popular Lists"
3200msgstr ""
3201
3202msgctxt "#90492"
3203msgid "Search Lists"
3204msgstr ""
3205
3206msgctxt "#90493"
3207msgid "Recent Activity"
3208msgstr ""
3209
3210msgctxt "#90494"
3211msgid "Downloads List"
3212msgstr ""
3213
3214msgctxt "#90495"
3215msgid "Download New"
3216msgstr ""
3217
3218msgctxt "#90496"
3219msgid "Cartoons Popular"
3220msgstr "Cartoons Popular"
3221
3222msgctxt "#90497"
3223msgid "Animation Popular"
3224msgstr "Animation Popular"
3225
3226msgctxt "#90500"
3227msgid "Translating subtitle..."
3228msgstr "Translating subtitle..."
3229
3230msgctxt "#90501"
3231msgid "Translation done."
3232msgstr "Translation done."
3233
3234msgctxt "#90502"
3235msgid "Searching subtitles..."
3236msgstr ""
3237
3238msgctxt "#90503"
3239msgid "No subtitles found"
3240msgstr ""
3241
3242msgctxt "#90504"
3243msgid "Failed to authenticate with DeepL. Please check your API key."
3244msgstr ""
3245
3246msgctxt "#90505"
3247msgid "DeepL API key is missing or invalid."
3248msgstr ""
3249
3250msgctxt "#90510"
3251msgid "No meta available"
3252msgstr "No meta available"
3253
3254msgctxt "#90511"
3255msgid "No content available for"
3256msgstr "No content available for"
3257
3258msgctxt "#90512"
3259msgid "Season"
3260msgstr "Season"
3261
3262msgctxt "#90513"
3263msgid "No episodes available"
3264msgstr "No episodes available"
3265
3266msgctxt "#90514"
3267msgid "No videos available"
3268msgstr "No videos available"
3269
3270msgctxt "#90515"
3271msgid "Next Page"
3272msgstr "Next Page"
3273
3274msgctxt "#90516"
3275msgid "No metas available"
3276msgstr "No metas available"
3277
3278msgctxt "#90517"
3279msgid "No seasons available"
3280msgstr "No seasons available"
3281
3282msgctxt "#90518"
3283msgid "Invalid query"
3284msgstr "Invalid query"
3285
3286msgctxt "#90519"
3287msgid "No lists found"
3288msgstr "No lists found"
3289
3290msgctxt "#90520"
3291msgid "No user lists found"
3292msgstr "No user lists found"
3293
3294msgctxt "#90521"
3295msgid "No top lists found"
3296msgstr "No top lists found"
3297
3298msgctxt "#90522"
3299msgid "Custom Addon"
3300msgstr "Custom Addon"
3301
3302msgctxt "#90523"
3303msgid "Enter the custom Stremio addon URL"
3304msgstr "Enter the custom Stremio addon URL"
3305
3306msgctxt "#90524"
3307msgid "No URL provided."
3308msgstr "No URL provided."
3309
3310msgctxt "#90525"
3311msgid "Failed to fetch manifest: %s"
3312msgstr "Failed to fetch manifest: %s"
3313
3314msgctxt "#90526"
3315msgid "Manifest missing 'id' or 'name'."
3316msgstr "Manifest missing 'id' or 'name'."
3317
3318msgctxt "#90527"
3319msgid "Custom Stremio addon added successfully!"
3320msgstr "Custom Stremio addon added successfully!"
3321
3322msgctxt "#90528"
3323msgid "Addon does not provide 'stream' or 'catalog' resources."
3324msgstr "Addon does not provide 'stream' or 'catalog' resources."
3325
3326msgctxt "#90529"
3327msgid "This addon is already added to your list."
3328msgstr "This addon is already added to your list."
3329
3330msgctxt "#90530"
3331msgid "Failed to add custom addon: %s"
3332msgstr "Failed to add custom addon: %s"
3333
3334msgctxt "#90531"
3335msgid "Remove Addon"
3336msgstr "Remove Addon"
3337
3338msgctxt "#90532"
3339msgid "No custom Stremio addons to remove."
3340msgstr "No custom Stremio addons to remove."
3341
3342msgctxt "#90533"
3343msgid "Remove Stremio Addons"
3344msgstr "Remove Stremio Addons"
3345
3346msgctxt "#90534"
3347msgid "Selected addon(s) removed."
3348msgstr "Selected addon(s) removed."
3349
3350msgctxt "#90535"
3351msgid "Untitled List"
3352msgstr "Untitled List"
3353
3354msgctxt "#90536"
3355msgid "Removed from Trakt list"
3356msgstr "Removed from Trakt list"
3357
3358msgctxt "#90537"
3359msgid "Failed to remove from Trakt list"
3360msgstr "Failed to remove from Trakt list"
3361
3362msgctxt "#90538"
3363msgid "Resume from %.1f%%"
3364msgstr "Resume from %.1f%%"
3365
3366msgctxt "#90539"
3367msgid "Start from Beginning"
3368msgstr "Start from Beginning"
3369
3370msgctxt "#90540"
3371msgid "Authorize Debrid Services"
3372msgstr "Authorize Debrid Services"
3373
3374msgctxt "#90541"
3375msgid "Navigate to: [B]%s[/B]"
3376msgstr "Navigate to: [B]%s[/B]"
3377
3378msgctxt "#90542"
3379msgid "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]"
3380msgstr "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]"
3381
3382msgctxt "#90543"
3383msgid "Premiumize Auth"
3384msgstr "Premiumize Auth"
3385
3386msgctxt "#90544"
3387msgid "Success"
3388msgstr "Success"
3389
3390msgctxt "#90545"
3391msgid "Authentication completed."
3392msgstr "Authentication completed."
3393
3394msgctxt "#90546"
3395msgid "Error:"
3396msgstr "Error:"
3397
3398msgctxt "#90547"
3399msgid "Error: %s"
3400msgstr "Error: %s"
3401
3402msgctxt "#90548"
3403msgid "Auth Error"
3404msgstr "Auth Error"
3405
3406msgctxt "#90549"
3407msgid "Torbox authentication successful."
3408msgstr "Torbox authentication successful."
3409
3410msgctxt "#90554"
3411msgid "Please wait..."
3412msgstr "Please wait..."
3413
3414msgctxt "#90555"
3415msgid "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3416msgstr "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3417
3418msgctxt "#90556"
3419msgid "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3420msgstr "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3421
3422msgctxt "#90557"
3423msgid "Pastebin Link:\n[COLOR cyan]%s[/COLOR]"
3424msgstr "Pastebin Link:\n[COLOR cyan]%s[/COLOR]"
3425
3426msgctxt "#90670"
3427msgid "Local URL:\n[COLOR cyan]%s[/COLOR]"
3428msgstr "Local URL:\n[COLOR cyan]%s[/COLOR]"
3429
3430msgctxt "#90558"
3431msgid "QR Code Authentication"
3432msgstr "QR Code Authentication"
3433
3434msgctxt "#90559"
3435msgid "Kodi 20 or above required. Please update Kodi to use this addon."
3436msgstr "Kodi 20 or above required. Please update Kodi to use this addon."
3437
3438msgctxt "#90560"
3439msgid "Failed to start web server. Port may be in use."
3440msgstr "Failed to start web server. Port may be in use."
3441
3442msgctxt "#90561"
3443msgid "Authentification Removed."
3444msgstr "Authentification Removed."
3445
3446msgctxt "#90562"
3447msgid "WebDAV Error"
3448msgstr "WebDAV Error"
3449
3450msgctxt "#90563"
3451msgid "Trakt Authorization"
3452msgstr "Trakt Authorization"
3453
3454msgctxt "#90564"
3455msgid "Do you want to continue transfer in background?"
3456msgstr "Do you want to continue transfer in background?"
3457
3458msgctxt "#90565"
3459msgid "Cloud Transfer"
3460msgstr "Cloud Transfer"
3461
3462msgctxt "#90566"
3463msgid "Kodi Logs"
3464msgstr "Kodi Logs"
3465
3466msgctxt "#90567"
3467msgid "Show Logs"
3468msgstr "Show Logs"
3469
3470msgctxt "#90568"
3471msgid "Export to paste.kodi.tv"
3472msgstr "Export to paste.kodi.tv"
3473
3474msgctxt "#90569"
3475msgid "Kodi Logs Exported"
3476msgstr "Kodi Logs Exported"
3477
3478msgctxt "#90570"
3479msgid "Providers Selection Dialog"
3480msgstr "Providers Selection Dialog"
3481
3482msgctxt "#90571"
3483msgid "Your current Providers are: \n%s\n\nDo you want to change?"
3484msgstr "Your current Providers are: \n%s\n\nDo you want to change?"
3485
3486msgctxt "#90572"
3487msgid "Select Providers"
3488msgstr "Select Providers"
3489
3490msgctxt "#90573"
3491msgid "Selection Dialog"
3492msgstr "Selection Dialog"
3493
3494msgctxt "#90574"
3495msgid "Successfully selected: %s"
3496msgstr "Successfully selected: %s"
3497
3498msgctxt "#90575"
3499msgid "Selection"
3500msgstr "Selection"
3501
3502msgctxt "#90576"
3503msgid "No providers selected"
3504msgstr "No providers selected"
3505
3506msgctxt "#90577"
3507msgid "Changelog"
3508msgstr "Changelog"
3509
3510msgctxt "#90578"
3511msgid "[B]Unable to check for updates[/B]"
3512msgstr "[B]Unable to check for updates[/B]"
3513
3514msgctxt "#90579"
3515msgid "[B]No update available[/B]"
3516msgstr "[B]No update available[/B]"
3517
3518msgctxt "#90580"
3519msgid "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]"
3520msgstr "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]"
3521
3522msgctxt "#90581"
3523msgid "[B]Update available. Do you want to update?[/B]"
3524msgstr "[B]Update available. Do you want to update?[/B]"
3525
3526msgctxt "#90582"
3527msgid "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]"
3528msgstr "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]"
3529
3530msgctxt "#90583"
3531msgid "Error fetching versions: %s"
3532msgstr "Error fetching versions: %s"
3533
3534msgctxt "#90584"
3535msgid "No older/different versions found."
3536msgstr "No older/different versions found."
3537
3538msgctxt "#90585"
3539msgid "Select Version to Downgrade"
3540msgstr "Select Version to Downgrade"
3541
3542msgctxt "#90586"
3543msgid "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?"
3544msgstr "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?"
3545
3546msgctxt "#90587"
3547msgid "[B]Downloading update...[/B]"
3548msgstr "[B]Downloading update...[/B]"
3549
3550msgctxt "#90588"
3551msgid "Error: Unable to download update."
3552msgstr "Error: Unable to download update."
3553
3554msgctxt "#90589"
3555msgid "Error saving update file: %s"
3556msgstr "Error saving update file: %s"
3557
3558msgctxt "#90590"
3559msgid "Error extracting update. Please install manually."
3560msgstr "Error extracting update. Please install manually."
3561
3562msgctxt "#90591"
3563msgid "Do you want to view the changelog for the new version?"
3564msgstr "Do you want to view the changelog for the new version?"
3565
3566msgctxt "#90592"
3567msgid "New Release v%s - Changelog"
3568msgstr "New Release v%s - Changelog"
3569
3570msgctxt "#90593"
3571msgid "[B]Update complete.[/B]"
3572msgstr "[B]Update complete.[/B]"
3573
3574msgctxt "#90594"
3575msgid "Real Debrid Auth"
3576msgstr "Real Debrid Auth"
3577
3578msgctxt "#90595"
3579msgid "Torbox Auth"
3580msgstr "Torbox Auth"
3581
3582msgctxt "#90596"
3583msgid "Debrider Auth"
3584msgstr "Debrider Auth"
3585
3586msgctxt "#90597"
3587msgid "Download Error"
3588msgstr "Download Error"
3589
3590msgctxt "#90598"
3591msgid "Failed to download text: %s"
3592msgstr "Failed to download text: %s"
3593
3594msgctxt "#90599"
3595msgid "General Error"
3596msgstr "General Error"
3597
3598msgctxt "#90600"
3599msgid "Could not display text file."
3600msgstr "Could not display text file."
3601
3602msgctxt "#90601"
3603msgid "Failed to export logs."
3604msgstr "Failed to export logs."
3605
3606msgctxt "#90602"
3607msgid "Error reading log: %s"
3608msgstr "Error reading log: %s"
3609
3610msgctxt "#90603"
3611msgid "Kodi log file not found."
3612msgstr "Kodi log file not found."
3613
3614msgctxt "#90604"
3615msgid "%s Auth"
3616msgstr "%s Auth"
3617
3618msgctxt "#90605"
3619msgid "Ok"
3620msgstr "Ok"
3621
3622msgctxt "#90606"
3623msgid "No"
3624msgstr "No"
3625
3626msgctxt "#90607"
3627msgid "Pinging addons for availability..."
3628msgstr "Pinging addons for availability..."
3629
3630msgctxt "#90608"
3631msgid "Pinging addons... %d/%d"
3632msgstr "Pinging addons... %d/%d"
3633
3634msgctxt "#90609"
3635msgid "Ping Results"
3636msgstr "Ping Results"
3637
3638msgctxt "#90610"
3639msgid "Reachable: %d\nUnreachable: %d"
3640msgstr "Reachable: %d\nUnreachable: %d"
3641
3642msgctxt "#90611"
3643msgid "Select Category to Filter"
3644msgstr "Select Category to Filter"
3645
3646msgctxt "#90612"
3647msgid "Stremio Add-ons Import"
3648msgstr "Stremio Add-ons Import"
3649
3650msgctxt "#90613"
3651msgid "To import your add-ons, please log in with your Stremio email and password."
3652msgstr "To import your add-ons, please log in with your Stremio email and password."
3653
3654msgctxt "#90614"
3655msgid "Enter your Email"
3656msgstr "Enter your Email"
3657
3658msgctxt "#90615"
3659msgid "Enter your Password"
3660msgstr "Enter your Password"
3661
3662msgctxt "#90616"
3663msgid "Login Failed"
3664msgstr "Login Failed"
3665
3666msgctxt "#90617"
3667msgid "Failed to login: %s"
3668msgstr "Failed to login: %s"
3669
3670msgctxt "#90618"
3671msgid "Add-ons Import Failed"
3672msgstr "Add-ons Import Failed"
3673
3674msgctxt "#90619"
3675msgid "Please try again later and report the issue if the problem persists. For more details, check the log file."
3676msgstr "Please try again later and report the issue if the problem persists. For more details, check the log file."
3677
3678msgctxt "#90620"
3679msgid "Addons Imported"
3680msgstr "Addons Imported"
3681
3682msgctxt "#90621"
3683msgid "Successfully imported addons from your account."
3684msgstr "Successfully imported addons from your account."
3685
3686msgctxt "#90622"
3687msgid "Update Stremio Addons"
3688msgstr "Update Stremio Addons"
3689
3690msgctxt "#90623"
3691msgid "Do you want to update the Addons from your account?"
3692msgstr "Do you want to update the Addons from your account?"
3693
3694msgctxt "#90624"
3695msgid "Log Out from Stremio"
3696msgstr "Log Out from Stremio"
3697
3698msgctxt "#90625"
3699msgid "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."
3700msgstr "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."
3701
3702msgctxt "#90626"
3703msgid "Log Out"
3704msgstr "Log Out"
3705
3706msgctxt "#90627"
3707msgid "Use Existing"
3708msgstr "Use Existing"
3709
3710msgctxt "#90628"
3711msgid "Download New"
3712msgstr "Download New"
3713
3714msgctxt "#90629"
3715msgid "Playback Failed"
3716msgstr "Playback Failed"
3717
3718msgctxt "#90630"
3719msgid "The video could not be played.\nDo you want to retry?"
3720msgstr "The video could not be played.\nDo you want to retry?"
3721
3722msgctxt "#90631"
3723msgid "Retry"
3724msgstr "Retry"
3725
3726msgctxt "#90632"
3727msgid "Information"
3728msgstr "Information"
3729
3730msgctxt "#90633"
3731msgid "Review"
3732msgstr "Review"
3733
3734msgctxt "#90634"
3735msgid "Comment"
3736msgstr "Comment"
3737
3738msgctxt "#90635"
3739msgid "Trivia"
3740msgstr "Trivia"
3741
3742msgctxt "#90636"
3743msgid "Goof"
3744msgstr "Goof"
3745
3746msgctxt "#90637"
3747msgid "Parental Guide"
3748msgstr "Parental Guide"
3749
3750msgctxt "#90638"
3751msgid "Converting Magnet...\n\n%s"
3752msgstr "Converting Magnet...\n\n%s"
3753
3754msgctxt "#90639"
3755msgid "Real Debrid Error."
3756msgstr "Real Debrid Error."
3757
3758msgctxt "#90640"
3759msgid "File already cached"
3760msgstr "File already cached"
3761
3762msgctxt "#90641"
3763msgid "Saving file to the Real Debrid Cloud"
3764msgstr "Saving file to the Real Debrid Cloud"
3765
3766msgctxt "#90642"
3767msgid "File cached"
3768msgstr "File cached"
3769
3770msgctxt "#90643"
3771msgid "Saving File to the Real Debrid Cloud...\n%s\n\n"
3772msgstr "Saving File to the Real Debrid Cloud...\n%s\n\n"
3773
3774msgctxt "#90644"
3775msgid "Downloading %s GB @ %s mbps from %s peers, %s %% completed"
3776msgstr "Downloading %s GB @ %s mbps from %s peers, %s %% completed"
3777
3778msgctxt "#90645"
3779msgid "Trakt"
3780msgstr "Trakt"
3781
3782msgctxt "#90651"
3783msgid "Easynews Account Info"
3784msgstr "Easynews Account Info"
3785
3786msgctxt "#90652"
3787msgid "DeepL API Error"
3788msgstr "DeepL API Error"
3789
3790msgctxt "#90653"
3791msgid "Download"
3792msgstr "Download"
3793
3794msgctxt "#90654"
3795msgid "Failed to start download: %s"
3796msgstr "Failed to start download: %s"
3797
3798msgctxt "#90655"
3799msgid "Modify Search"
3800msgstr "Modify Search"
3801
3802msgctxt "#90656"
3803msgid "Torbox"
3804msgstr "Torbox"
3805
3806msgctxt "#90657"
3807msgid "Easy-Debrid"
3808msgstr "Easy-Debrid"
3809
3810msgctxt "#90658"
3811msgid "All-Debrid"
3812msgstr "All-Debrid"
3813
3814msgctxt "#90659"
3815msgid "Real-Debrid"
3816msgstr "Real-Debrid"
3817
3818msgctxt "#90660"
3819msgid "Getting results from providers..."
3820msgstr "Getting results from providers..."
3821
3822msgctxt "#90661"
3823msgid "Jacktook [COLOR FFFF6B00]%s[/COLOR]"
3824msgstr "Jacktook [COLOR FFFF6B00]%s[/COLOR]"
3825
3826msgctxt "#90662"
3827msgid "Downloads Viewer"
3828msgstr "Downloads Viewer"
3829
3830msgctxt "#90663"
3831msgid "WebDAV Text Viewer"
3832msgstr "WebDAV Text Viewer"
3833
3834msgctxt "#90664"
3835msgid "Selected Item"
3836msgstr "Selected Item"
3837
3838msgctxt "#90665"
3839msgid "Subtitle No. %d"
3840msgstr "Subtitle No. %d"
3841
3842msgctxt "#90666"
3843msgid "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?"
3844msgstr "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?"
3845
3846msgctxt "#90667"
3847msgid "Free DeepL Usage"
3848msgstr "Free DeepL Usage"
3849
3850msgctxt "#90668"
3851msgid "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?"
3852msgstr "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?"
3853
3854msgctxt "#90669"
3855msgid "Translation Cost"
3856msgstr "Translation Cost"
3857
3858msgctxt "#90672"
3859msgid "Play Trailer"
3860msgstr "Play Trailer"
3861
3862msgctxt "#90673"
3863msgid "Trailer unavailable"
3864msgstr "Trailer unavailable"
3865
3866msgctxt "#90690"
3867msgid "Clear All Items"
3868msgstr "Clear All Items"
3869
3870msgctxt "#90691"
3871msgid "Remove all items from this library?"
3872msgstr "Remove all items from this library?"
3873
3874msgctxt "#90692"
3875msgid "Library cleared"
3876msgstr "Library cleared"
3877
3878msgctxt "#90693"
3879msgid "No items to remove"
3880msgstr "No items to remove"
3881
3882msgctxt "#90694"
3883msgid "Added to %s cloud but still downloading. Please wait before playing."
3884msgstr "Added to %s cloud but still downloading. Please wait before playing."
3885
3886msgctxt "#90695"
3887msgid "Torrent Not Ready"
3888msgstr "Torrent Not Ready"
3889
3890msgctxt "#90696"
3891msgid "This torrent is still downloading in %s and is not ready yet."
3892msgstr "This torrent is still downloading in %s and is not ready yet."
3893
3894msgctxt "#90697"
3895msgid "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?"
3896msgstr "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?"
3897
3898msgctxt "#90698"
3899msgid "Play with Torrent"
3900msgstr "Play with Torrent"
3901
3902msgctxt "#90699"
3903msgid "Cancel & Wait"
3904msgstr "Cancel & Wait"
3905
3906msgctxt "#90712"
3907msgid "Views"
3908msgstr "Views"
3909
3910msgctxt "#90713"
3911msgid "Set Main View"
3912msgstr "Set Main View"
3913
3914msgctxt "#90714"
3915msgid "Set Movies View"
3916msgstr "Set Movies View"
3917
3918msgctxt "#90715"
3919msgid "Set TV Shows View"
3920msgstr "Set TV Shows View"
3921
3922msgctxt "#90716"
3923msgid "Set Seasons View"
3924msgstr "Set Seasons View"
3925
3926msgctxt "#90717"
3927msgid "Set Episodes View"
3928msgstr "Set Episodes View"
3929
3930msgctxt "#90718"
3931msgid "Set Library View"
3932msgstr "Set Library View"
3933
3934msgctxt "#90719"
3935msgid "Set History View"
3936msgstr "Set History View"
3937
3938msgctxt "#90720"
3939msgid "Set Downloads View"
3940msgstr "Set Downloads View"
3941
3942msgctxt "#90721"
3943msgid "Save Current View"
3944msgstr "Save Current View"
3945
3946msgctxt "#90722"
3947msgid "View saved"
3948msgstr "View saved"
3949
3950msgctxt "#90723"
3951msgid "Could not detect the current view"
3952msgstr "Could not detect the current view"
3953
3954msgctxt "#90724"
3955msgid "Saved views reset"
3956msgstr "Saved views reset"
3957
3958msgctxt "#90725"
3959msgid "Reset Saved Views"
3960msgstr "Reset Saved Views"
3961
3962msgctxt "#90726"
3963msgid "Open this screen, change the Kodi view manually, then select Save Current View."
3964msgstr "Open this screen, change the Kodi view manually, then select Save Current View."
3965
3966msgctxt "#90742"
3967msgid "Addon Settings"
3968msgstr "Addon Settings"
3969
3970msgctxt "#90743"
3971msgid "Set Views"
3972msgstr "Set Views"
3973
3974msgctxt "#90730"
3975msgid "Search with title + year"
3976msgstr "Search with title + year"
3977
3978msgctxt "#90731"
3979msgid "Search with original title"
3980msgstr "Search with original title"
3981
3982msgctxt "#90732"
3983msgid "Search with original title + year"
3984msgstr "Search with original title + year"
3985
3986msgctxt "#90733"
3987msgid "Search modes"
3988msgstr "Search modes"
3989
3990msgctxt "#90734"
3991msgid "Enter year"
3992msgstr "Enter year"
3993
3994msgctxt "#90735"
3995msgid "Invalid year"
3996msgstr "Invalid year"
3997
3998msgctxt "#90736"
3999msgid "Edit title"
4000msgstr "Edit title"
4001
4002msgctxt "#90512"
4003msgid "Season"
4004msgstr "Season"
4005
4006msgctxt "#90737"
4007msgid "Invalid season"
4008msgstr "Invalid season"
4009
4010msgctxt "#90738"
4011msgid "Episode"
4012msgstr "Episode"
4013
4014msgctxt "#90739"
4015msgid "Invalid episode"
4016msgstr "Invalid episode"
4017
4018msgctxt "#90740"
4019msgid "Edit current title"
4020msgstr "Edit current title"
4021
4022msgctxt "#90741"
4023msgid "Use original title"
4024msgstr "Use original title"
4025
4026msgctxt "#90744"
4027msgid "Upload Subtitle"
4028msgstr "Upload Subtitle"
4029
4030msgctxt "#90745"
4031msgid "Select Subtitle File"
4032msgstr "Select Subtitle File"
4033
4034msgctxt "#90746"
4035msgid "Local File"
4036msgstr "Local File"
4037
4038msgctxt "#90747"
4039msgid "Upload from Browser"
4040msgstr "Upload from Browser"
4041
4042msgctxt "#90748"
4043msgid "Upload subtitle from your browser"
4044msgstr "Upload subtitle from your browser"
4045
4046msgctxt "#90749"
4047msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file"
4048msgstr "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file"
4049
4050msgctxt "#90750"
4051msgid "Search Title Language"
4052msgstr "Search Title Language"
4053
4054msgctxt "#90751"
4055msgid "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches."
4056msgstr "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches."
4057
4058msgctxt "#90752"
4059msgid "English first"
4060msgstr "English first"
4061
4062msgctxt "#90753"
4063msgid "English only"
4064msgstr "English only"
4065
4066msgctxt "#90754"
4067msgid "Localized first"
4068msgstr "Localized first"
plugin.video.jacktook/resources/language/Portuguese (Brazil)/strings.po +3672 -0
……@@ -0,0 +1,3672 @@
1# Kodi Media Center language file
2# Addon Name: Jacktook
3# Addon id: plugin.video.jacktook
4# Addon Provider: Sam-Max
5#
6msgid ""
7msgstr ""
8"Report-Msgid-Bugs-To: \n"
9"POT-Creation-Date: 2023-09-19 19:53+0200\n"
10"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
11"Language: pt_BR\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
18msgctxt "#30000"
19msgid "Players"
20msgstr "Players"
21
22msgctxt "#30001"
23msgid "Clients"
24msgstr "Clientes:"
25
26msgctxt "#30002"
27msgid "Torrent Client"
28msgstr "Cliente"
29
30msgctxt "#30020"
31msgid "Indexers"
32msgstr "Indexadores"
33
34msgctxt "#30021"
35msgid "Indexer"
36msgstr "Indexador"
37
38msgctxt "#30022"
39msgid "Jackett Configuration:"
40msgstr "Configuração do Jackett:"
41
42msgctxt "#30023"
43msgid "Client Key"
44msgstr "Chave da API do Jackett"
45
46msgctxt "#30025"
47msgid "Host"
48msgstr "Host do Jackett"
49
50msgctxt "#30026"
51msgid "Description length"
52msgstr "Tamanho da descrição"
53
54msgctxt "#30027"
55msgid "Insecured connection"
56msgstr "Conexão insegura"
57
58msgctxt "#30028"
59msgid "Sort by"
60msgstr "Ordenar por"
61
62msgctxt "#30029"
63msgid "Total Results"
64msgstr "Total de resultados"
65
66msgctxt "#30030"
67msgid "Timeout"
68msgstr "Tempo limite"
69
70msgctxt "#30031"
71msgid "Timeout"
72msgstr "Tempo limite"
73
74msgctxt "#30050"
75msgid "Prowlarr Configuration:"
76msgstr "Configuração do Prowlarr:"
77
78msgctxt "#30051"
79msgid "Client Key"
80msgstr "Chave da API do Prowlarr"
81
82msgctxt "#30052"
83msgid "Host"
84msgstr "Host do Prowlarr"
85
86msgctxt "#30053"
87msgid "Indexers-Ids"
88msgstr "IDs dos Indexadores "
89
90msgctxt "#30054"
91msgid "Anime IndexersIds"
92msgstr "IDs dos Indexadores para Anime"
93
94msgctxt "#30100"
95msgid "TMDB"
96msgstr "TMDB"
97
98msgctxt "#30102"
99msgid "TMDB Configuration"
100msgstr "Configuração do TMDB:"
101
102msgctxt "#30103"
103msgid "Client Key"
104msgstr "Chave da API do TMDB"
105
106msgctxt "#30104"
107msgid "Clear tmdb cache"
108msgstr "Limpar cache do TMDB"
109
110msgctxt "#30105"
111msgid "Cache expiration hours"
112msgstr "Expiração do cache (Horas)"
113
114msgctxt "#30106"
115msgid "Clear Cache"
116msgstr "Limpar o cache"
117
118msgctxt "#30107"
119msgid "Enable Cache"
120msgstr "Habilitar cache"
121
122msgctxt "#30140"
123msgid "AniList"
124msgstr "AniList"
125
126msgctxt "#30142"
127msgid "AniList Configuration:"
128msgstr "Configuração do AniList:"
129
130msgctxt "#30143"
131msgid "Client ID"
132msgstr "ID do cliente"
133
134msgctxt "#30144"
135msgid "Client Secret"
136msgstr "Segredo do cliente"
137
138msgctxt "#30150"
139msgid "Services"
140msgstr "Services"
141
142msgctxt "#30152"
143msgid "Real Debrid Configuration"
144msgstr "Configuração do Real Debrid"
145
146msgctxt "#30154"
147msgid "Token"
148msgstr "Token:"
149
150msgctxt "#30200"
151msgid "Fanart.TV"
152msgstr "Fanart.TV"
153
154msgctxt "#30202"
155msgid "Fanart.TV Configuration:"
156msgstr "Configuração do Fanart.TV:"
157
158msgctxt "#30203"
159msgid "Client ID"
160msgstr "ID do cliente"
161
162msgctxt "#30221"
163msgid "No Client Key provided for"
164msgstr "Nenhuma chave de API fornecida para"
165
166msgctxt "#30223"
167msgid "You need to configure"
168msgstr "Você precisa configurar"
169
170msgctxt "#30228"
171msgid "The request to Torrentio failed."
172msgstr "A solicitação para o Torrentio falhou."
173
174msgctxt "#30229"
175msgid "The request to Jackett failed."
176msgstr "A solicitação para o Jackett falhou."
177
178msgctxt "#30230"
179msgid "The request to Prowlarr failed."
180msgstr "A solicitação para o Prowlarr falhou."
181
182msgctxt "#30231"
183msgid "The request to Elhosted failed."
184msgstr "A solicitação para o Elhosted falhou."
185
186msgctxt "#30232"
187msgid "The request to Jackgram failed."
188msgstr "A solicitação para o Jackgram falhou."
189
190msgctxt "#30233"
191msgid "The request to Mediafusion failed."
192msgstr "A solicitação para o Mediafusion falhou."
193
194msgctxt "#30234"
195msgid "The request to Peerflix failed."
196msgstr "A solicitação para o Peerflix falhou."
197
198msgctxt "#30235"
199msgid "Stremio"
200msgstr "Stremio"
201
202msgctxt "#30236"
203msgid "Stremio add-ons"
204msgstr "Add-ons do Stremio"
205
206msgctxt "#30237"
207msgid "Import your config"
208msgstr "Importar suas configurações"
209
210msgctxt "#30238"
211msgid "Manage add-ons"
212msgstr "Gerenciar add-ons"
213
214msgctxt "#30240"
215msgid "TMDB cache cleared"
216msgstr "Cache do TMDB limpo"
217
218msgctxt "#30241"
219msgid "Search on TMDB:"
220msgstr "Pesquisar no TMDB:"
221
222msgctxt "#30242"
223msgid "Search on AniList:"
224msgstr "Pesquisar no AniList:"
225
226msgctxt "#30243"
227msgid "Search for a keyword:"
228msgstr "Pesquisar por uma palavra-chave:"
229
230msgctxt "#30244"
231msgid "Cache cleared"
232msgstr "Cache limpo"
233
234msgctxt "#30249"
235msgid "Addon Jacktook Burst not found - (script.jacktook.burst)"
236msgstr "Addon Jacktook Burst not found - (script.jacktook.burst)"
237
238msgctxt "#30250"
239msgid "Addon Torrest not found - (plugin.video.torrest)"
240msgstr "Addon Torrest não encontrado - (plugin.video.torrest)"
241
242msgctxt "#30251"
243msgid "No sources found to play"
244msgstr "Nenhuma fonte encontrada para reproduzir"
245
246msgctxt "#30252"
247msgid "Addon Elementum not found - (plugin.video.elementum)"
248msgstr "Addon Elementum não encontrado - (plugin.video.elementum)"
249
250msgctxt "#30253"
251msgid "Addon Jacktorr not found - (plugin.video.jacktorr)"
252msgstr "Addon Jacktorr não encontrado - (plugin.video.jacktorr)"
253
254msgctxt "#30254"
255msgid "Do you want to install Elementum?"
256msgstr "Deseja instalar Elementum?"
257
258msgctxt "#30255"
259msgid "Do you want to install Jacktorr?"
260msgstr "Deseja instalar Jacktorr?"
261
262msgctxt "#30256"
263msgid "Do you want to install Torrest?"
264msgstr "Deseja instalar Torrest?"
265
266msgctxt "#30257"
267msgid "Do you want to install Jacktook Burst?"
268msgstr "Deseja instalar Jacktook Burst?"
269
270msgctxt "#30650"
271msgid "Queued"
272msgstr "Adicionado à fila"
273
274msgctxt "#30651"
275msgid "Checking"
276msgstr "Verificando"
277
278msgctxt "#30652"
279msgid "Finding"
280msgstr "Encontrando"
281
282msgctxt "#30653"
283msgid "Downloading"
284msgstr "Baixando"
285
286msgctxt "#30654"
287msgid "Finished"
288msgstr "Finalizado"
289
290msgctxt "#30655"
291msgid "Seeding"
292msgstr "Semeando"
293
294msgctxt "#30656"
295msgid "Allocating"
296msgstr "Alocando"
297
298msgctxt "#30657"
299msgid "Checking resume data"
300msgstr "Verificando dados para reproduzir"
301
302msgctxt "#30658"
303msgid "Paused"
304msgstr "Pausado"
305
306msgctxt "#30659"
307msgid "Buffering"
308msgstr "Carregando"
309
310msgctxt "#30660"
311msgid "Unknown"
312msgstr "Desconhecido"
313
314msgctxt "#30700"
315msgid "Buffer and play"
316msgstr "Carregar e reproduzir"
317
318msgctxt "#30701"
319msgid "Stop downloading"
320msgstr "Parar o download"
321
322msgctxt "#30702"
323msgid "Downloads"
324msgstr "Downloads"
325
326msgctxt "#30703"
327msgid "Resume"
328msgstr "Retomar"
329
330msgctxt "#30704"
331msgid "Pause"
332msgstr "Pausar"
333
334msgctxt "#30705"
335msgid "Remove torrent"
336msgstr "Remover torrent"
337
338msgctxt "#30706"
339msgid "Remove torrent and files"
340msgstr "Remover torrent e arquivos"
341
342msgctxt "#30707"
343msgid "Torrent status"
344msgstr "Status do Torrent"
345
346msgctxt "#30708"
347msgid "Stop"
348msgstr "Parar"
349
350msgctxt "#30709"
351msgid "Drop torrent"
352msgstr "Drop torrent"
353
354msgctxt "#30710"
355msgid "Include Season Packs"
356msgstr "Incluir pacotes de temporadas"
357
358msgctxt "#30711"
359msgid "Torrent Settings"
360msgstr "Configurações de Torrent"
361
362msgctxt "#30712"
363msgid "General"
364msgstr "Geral"
365
366msgctxt "#30713"
367msgid "Enable"
368msgstr "Habilitar"
369
370msgctxt "#30714"
371msgid "Jacktorr"
372msgstr "Jacktorr"
373
374msgctxt "#30715"
375msgid "Torrest"
376msgstr "Torrest"
377
378msgctxt "#30716"
379msgid "Elementum"
380msgstr "Elementum"
381
382msgctxt "#30717"
383msgid "General"
384msgstr "Geral"
385
386msgctxt "#30718"
387msgid "Auto-Play"
388msgstr "Reprodução automática"
389
390msgctxt "#30719"
391msgid "Enable Next Episode Dialog"
392msgstr "Ativar diálogo de próximo episódio"
393
394msgctxt "#30720"
395msgid "Seconds left before show Next Episode Dialog"
396msgstr "Segundos restantes antes de mostrar diálogo de próximo episódio"
397
398msgctxt "#30721"
399msgid "Cache Enabled"
400msgstr "Cache ativado"
401
402msgctxt "#30722"
403msgid "Cache Expiration (hours)"
404msgstr "Expiração do cache (horas)"
405
406msgctxt "#30723"
407msgid "Cache"
408msgstr "Cache"
409
410msgctxt "#30724"
411msgid "Update"
412msgstr "Atualizar"
413
414msgctxt "#30725"
415msgid "Indexers Configuration"
416msgstr "Indexers Configuração"
417
418msgctxt "#30726"
419msgid "Sort"
420msgstr "Ordenar"
421
422msgctxt "#30727"
423msgid "Quality"
424msgstr "Qualidade"
425
426msgctxt "#30938"
427msgid "Silent Resume"
428msgstr "Retomar Silenciosamente"
429
430msgctxt "#30939"
431msgid "Auto-resume from last position without asking"
432msgstr "Retomar automaticamente da última posição sem perguntar"
433
434msgctxt "#30728"
435msgid "Seeds"
436msgstr "Sementes"
437
438msgctxt "#30729"
439msgid "Size"
440msgstr "Tamanho"
441
442msgctxt "#30730"
443msgid "Cached"
444msgstr "Em cache"
445
446msgctxt "#30731"
447msgid "Date"
448msgstr "Data"
449
450msgctxt "#30732"
451msgid "None"
452msgstr "Nenhum"
453
454msgctxt "#30733"
455msgid "Torrentio Configuration"
456msgstr "Torrentio Configuração"
457
458msgctxt "#30734"
459msgid "Enable"
460msgstr "Habilitar"
461
462msgctxt "#30736"
463msgid "Subtitle Language"
464msgstr "Idioma das legendas"
465
466msgctxt "#30737"
467msgid "GB"
468msgstr "GB"
469
470msgctxt "#30738"
471msgid "JP"
472msgstr "JP"
473
474msgctxt "#30739"
475msgid "RU"
476msgstr "RU"
477
478msgctxt "#30740"
479msgid "IT"
480msgstr "IT"
481
482msgctxt "#30741"
483msgid "PT"
484msgstr "PT"
485
486msgctxt "#30742"
487msgid "ES"
488msgstr "ES"
489
490msgctxt "#30743"
491msgid "MX"
492msgstr "MX"
493
494msgctxt "#30744"
495msgid "KR"
496msgstr "KR"
497
498msgctxt "#30745"
499msgid "CN"
500msgstr "CN"
501
502msgctxt "#30746"
503msgid "TW"
504msgstr "TW"
505
506msgctxt "#30747"
507msgid "FR"
508msgstr "FR"
509
510msgctxt "#30748"
511msgid "DE"
512msgstr "DE"
513
514msgctxt "#30749"
515msgid "NL"
516msgstr "NL"
517
518msgctxt "#30750"
519msgid "IN"
520msgstr "IN"
521
522msgctxt "#30751"
523msgid "PL"
524msgstr "PL"
525
526msgctxt "#30752"
527msgid "LT"
528msgstr "LT"
529
530msgctxt "#30753"
531msgid "CZ"
532msgstr "CZ"
533
534msgctxt "#30754"
535msgid "SK"
536msgstr "SK"
537
538msgctxt "#30755"
539msgid "SI"
540msgstr "SI"
541
542msgctxt "#30756"
543msgid "HU"
544msgstr "HU"
545
546msgctxt "#30757"
547msgid "RO"
548msgstr "RO"
549
550msgctxt "#30758"
551msgid "HR"
552msgstr "HR"
553
554msgctxt "#30759"
555msgid "UA"
556msgstr "UA"
557
558msgctxt "#30760"
559msgid "GR"
560msgstr "GR"
561
562msgctxt "#30761"
563msgid "DK"
564msgstr "DK"
565
566msgctxt "#30762"
567msgid "FI"
568msgstr "FI"
569
570msgctxt "#30763"
571msgid "SE"
572msgstr "SE"
573
574msgctxt "#30764"
575msgid "NO"
576msgstr "NO"
577
578msgctxt "#30765"
579msgid "TR"
580msgstr "TR"
581
582msgctxt "#30766"
583msgid "Torrentio Providers"
584msgstr "Provedores Torrentio"
585
586msgctxt "#30767"
587msgid "Jackgram Configuration"
588msgstr "Jackgram Configuração"
589
590msgctxt "#30768"
591msgid "Host"
592msgstr "Host"
593
594msgctxt "#30769"
595msgid "Elfhosted/KnightCrawler Configuration"
596msgstr "Elfhosted/KnightCrawler Configuração"
597
598msgctxt "#30770"
599msgid "Language"
600msgstr "Idioma"
601
602msgctxt "#30771"
603msgid "Zilean Configuration"
604msgstr "Zilean Configuração"
605
606msgctxt "#30772"
607msgid "Zilean Host"
608msgstr "Host Zilean"
609
610msgctxt "#30774"
611msgid "Zilean Timeout"
612msgstr "Zilean Tempo limite"
613
614msgctxt "#30775"
615msgid "Jackett Configuration"
616msgstr "Jackett Configuração"
617
618msgctxt "#30776"
619msgid "Prowlarr Configuration"
620msgstr "Prowlarr Configuração"
621
622msgctxt "#30777"
623msgid "MediaFusion configuration"
624msgstr "Configuração MediaFusion"
625
626msgctxt "#30779"
627msgid "Manifest Url"
628msgstr "URL do Manifesto"
629
630msgctxt "#30780"
631msgid "Jacktook Burst Configuration"
632msgstr "Jacktook Burst Configuração"
633
634msgctxt "#30781"
635msgid "Burst Configuration"
636msgstr "Burst Configuração"
637
638msgctxt "#30782"
639msgid "User"
640msgstr "Usuário"
641
642msgctxt "#30783"
643msgid "Authorize"
644msgstr "Autorizar"
645
646msgctxt "#30784"
647msgid "Remove Authorization"
648msgstr "Remover Autorização"
649
650msgctxt "#30785"
651msgid "Premiumize Configuration"
652msgstr "Premiumize Configuração"
653
654msgctxt "#30786"
655msgid "Enabled"
656msgstr "Habilitado"
657
658msgctxt "#30787"
659msgid "Token"
660msgstr "Token"
661
662msgctxt "#30788"
663msgid "TorBox Configuration"
664msgstr "TorBox Configuração"
665
666msgctxt "#30789"
667msgid "Debrider Configuration"
668msgstr "Debrider Configuração"
669
670msgctxt "#30790"
671msgid "Uncached"
672msgstr "Não em cache"
673
674msgctxt "#30791"
675msgid "Show uncached torrents"
676msgstr "Mostrar torrents não em cache"
677
678msgctxt "#30792"
679msgid "Use Kodi Language"
680msgstr "Usar idioma do Kodi"
681
682msgctxt "#30793"
683msgid "Trakt"
684msgstr "Trakt"
685
686msgctxt "#30794"
687msgid "Trakt Configuration"
688msgstr "Trakt Configuração"
689
690msgctxt "#30795"
691msgid "Expires"
692msgstr "Expira"
693
694msgctxt "#30796"
695msgid "Refresh"
696msgstr "Atualizar"
697
698msgctxt "#30797"
699msgid "Token"
700msgstr "Token"
701
702msgctxt "#30798"
703msgid "Trakt Client"
704msgstr "Cliente Trakt"
705
706msgctxt "#30800"
707msgid "Trakt Secret"
708msgstr "Segredo Trakt"
709
710msgctxt "#30802"
711msgid "Log out"
712msgstr "Sair"
713
714msgctxt "#30804"
715msgid "Menus"
716msgstr "Menus"
717
718msgctxt "#30805"
719msgid "Show Telegram Menu"
720msgstr "Mostrar menu Telegram"
721
722msgctxt "#30806"
723msgid "Clear cache when update"
724msgstr "Limpar cache na atualização"
725
726msgctxt "#30807"
727msgid "Peerfix Configuration"
728msgstr "Peerfix Configuração"
729
730msgctxt "#30894"
731msgid "Show Changelog"
732msgstr "Mostrar log de alterações"
733
734msgctxt "#30895"
735msgid "View the latest changes and updates to the addon."
736msgstr "Ver as últimas alterações e atualizações do addon."
737
738msgctxt "#30896"
739msgid "Calendar"
740msgstr "Calendário"
741
742msgctxt "#30897"
743msgid "Use broadcast day adjustment"
744msgstr "Usar ajuste de dia de transmissão"
745
746msgctxt "#30898"
747msgid "Adjust broadcast day based on manual offset"
748msgstr "Ajustar o dia da transmissão com base no desvio manual"
749
750msgctxt "#30899"
751msgid "Broadcast Offset (Hours)"
752msgstr "Broadcast Offset (Hours)"
753
754msgctxt "#30900"
755msgid "Manual hour adjustment for broadcast day simulation"
756msgstr "Ajuste manual da hora para simulação do dia de transmissão"
757
758msgctxt "#30905"
759msgid "Trakt Calendar Previous Days"
760msgstr "Dias passados do calendário Trakt"
761
762msgctxt "#30906"
763msgid "Number of days in the past to show in the Trakt calendar."
764msgstr "Número de dias passados a mostrar no calendário Trakt."
765
766msgctxt "#30907"
767msgid "Trakt Calendar Future Days"
768msgstr "Dias futuros do calendário Trakt"
769
770msgctxt "#30908"
771msgid "Number of days in the future to show in the Trakt calendar."
772msgstr "Número de dias futuros a mostrar no calendário Trakt."
773
774msgctxt "#30909"
775msgid "Show All Shows in Calendar"
776msgstr "Exibir todas as séries no calendário"
777
778msgctxt "#30918"
779msgid "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead."
780msgstr "{provider} não pode reproduzir diretamente releases compactados (.rar/.zip). Escolha uma fonte com arquivos de vídeo."
781
782msgctxt "#30910"
783msgid "Show all airing shows instead of just your watchlist/collection."
784msgstr "Exibe todas as séries em exibição em vez de apenas as da sua lista/coleção."
785
786msgctxt "#30911"
787msgid "Your Torbox account username/email."
788msgstr "Nome de usuário/e-mail da sua conta Torbox."
789
790msgctxt "#30912"
791msgid "Authorize add-on to access Torbox account."
792msgstr "Autorizar o add-on a acessar a conta Torbox."
793
794msgctxt "#30913"
795msgid "Revoke Torbox account access."
796msgstr "Revogar o acesso à conta Torbox."
797
798msgctxt "#30914"
799msgid "Trakt Sync Interval (minutes)"
800msgstr "Intervalo de sincronizacao do Trakt (minutos)"
801
802msgctxt "#30915"
803msgid "How often Jacktook checks Trakt for remote activity changes while Kodi is running."
804msgstr "Com que frequencia o Jacktook verifica mudancas remotas de atividade no Trakt enquanto o Kodi estiver em execucao."
805
806msgctxt "#30916"
807msgid "Account Info"
808msgstr "Informacoes da conta Trakt"
809
810msgctxt "#30917"
811msgid "View your Trakt account profile and library stats."
812msgstr "Veja o perfil da sua conta Trakt e as estatisticas da biblioteca."
813
814msgctxt "#30924"
815msgid "Trakt List Name"
816msgstr "Nome da lista Trakt"
817
818msgctxt "#30925"
819msgid "Trakt List Description"
820msgstr "Descricao da lista Trakt"
821
822msgctxt "#30926"
823msgid "Create Trakt List"
824msgstr "Criar lista Trakt"
825
826msgctxt "#30927"
827msgid "Choose Trakt List"
828msgstr "Escolher lista Trakt"
829
830msgctxt "#30936"
831msgid "Enable or disable Trakt scrobbling and resume playback from the last tracked position."
832msgstr "Ativa ou desativa o scrobbling do Trakt e retoma a reproducao da ultima posicao registrada."
833
834msgctxt "#30937"
835msgid "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view."
836msgstr "Limpa os dados em cache do Trakt e obriga o Jacktook a buscar resultados novos do Trakt na proxima vez que abrir uma vista do Trakt."
837
838msgctxt "#40000"
839msgid "Arabic (United Arab Emirates)"
840msgstr "Árabe (Emirados Árabes Unidos)"
841
842msgctxt "#40001"
843msgid "Arabic (Saudi Arabia)"
844msgstr "Árabe (Arábia Saudita)"
845
846msgctxt "#40002"
847msgid "Belarusian (Belarus)"
848msgstr "Belarusian (Belarus)"
849
850msgctxt "#40003"
851msgid "Bulgarian (Bulgaria)"
852msgstr "Búlgaro (Bulgaria)"
853
854msgctxt "#40004"
855msgid "Bengali (Bangladesh)"
856msgstr "Bengali (Bangladesh)"
857
858msgctxt "#40005"
859msgid "Catalan (Spain)"
860msgstr "Catalão (Espanha)"
861
862msgctxt "#40006"
863msgid "Chamorro (Guam)"
864msgstr "Chamorro (Guam)"
865
866msgctxt "#40007"
867msgid "Czech (Czech Republic)"
868msgstr "Tcheco (República Tcheca)"
869
870msgctxt "#40008"
871msgid "Danish (Denmark)"
872msgstr "Dinamarquês (Dinamarca)"
873
874msgctxt "#40009"
875msgid "German (Austria)"
876msgstr "Alemão (Austria)"
877
878msgctxt "#40010"
879msgid "German (Switzerland)"
880msgstr "Alemão (Suíça)"
881
882msgctxt "#40011"
883msgid "German (Germany)"
884msgstr "Alemão (Alemanha)"
885
886msgctxt "#40012"
887msgid "Greek (Greece)"
888msgstr "Grego (Greece)"
889
890msgctxt "#40013"
891msgid "English (Australia)"
892msgstr "Inglês (Austrália)"
893
894msgctxt "#40014"
895msgid "English (Canada)"
896msgstr "Inglês (Canadá)"
897
898msgctxt "#40015"
899msgid "English (United Kingdom)"
900msgstr "Inglês (Reino Unido)"
901
902msgctxt "#40016"
903msgid "English (Ireland)"
904msgstr "Inglês (Ireland)"
905
906msgctxt "#40017"
907msgid "English (New Zealand)"
908msgstr "Inglês (New Zealand)"
909
910msgctxt "#40018"
911msgid "English (United States of America)"
912msgstr "Inglês (United States of America)"
913
914msgctxt "#40019"
915msgid "Esperanto (None)"
916msgstr "Esperanto (None)"
917
918msgctxt "#40020"
919msgid "Spanish (Spain)"
920msgstr "Espanhol (Espanha)"
921
922msgctxt "#40021"
923msgid "Spanish (Mexico)"
924msgstr "Espanhol (México)"
925
926msgctxt "#40022"
927msgid "Estonian (Estonia)"
928msgstr "Estonian (Estonia)"
929
930msgctxt "#40023"
931msgid "Basque (Spain)"
932msgstr "Basque (Espanha)"
933
934msgctxt "#40024"
935msgid "Persian (Iran)"
936msgstr "Persian (Iran)"
937
938msgctxt "#40025"
939msgid "Finnish (Finland)"
940msgstr "Finlandês (Finland)"
941
942msgctxt "#40026"
943msgid "French (Canada)"
944msgstr "Francês (Canadá)"
945
946msgctxt "#40027"
947msgid "French (France)"
948msgstr "Francês (França)"
949
950msgctxt "#40028"
951msgid "Galician (Spain)"
952msgstr "Galician (Espanha)"
953
954msgctxt "#40029"
955msgid "Hebrew (Israel)"
956msgstr "Hebrew (Israel)"
957
958msgctxt "#40030"
959msgid "Hindi (India)"
960msgstr "Hindi (Índia)"
961
962msgctxt "#40031"
963msgid "Hungarian (Hungary)"
964msgstr "Húngaro (Hungary)"
965
966msgctxt "#40032"
967msgid "Indonesian (Indonesia)"
968msgstr "Indonésio (Indonesia)"
969
970msgctxt "#40033"
971msgid "Italian (Italy)"
972msgstr "Italiano (Itália)"
973
974msgctxt "#40034"
975msgid "Japanese (Japan)"
976msgstr "Japonês (Japão)"
977
978msgctxt "#40035"
979msgid "Georgian (Georgia)"
980msgstr "Georgian (Georgia)"
981
982msgctxt "#40036"
983msgid "Kazakh (Kazakhstan)"
984msgstr "Kazakh (Kazakhstan)"
985
986msgctxt "#40037"
987msgid "Kannada (India)"
988msgstr "Kannada (Índia)"
989
990msgctxt "#40038"
991msgid "Korean (South Korea)"
992msgstr "Coreano (Coreia do Sul)"
993
994msgctxt "#40039"
995msgid "Lithuanian (Lithuania)"
996msgstr "Lithuanian (Lithuania)"
997
998msgctxt "#40040"
999msgid "Latvian (Latvia)"
1000msgstr "Latvian (Latvia)"
1001
1002msgctxt "#40041"
1003msgid "Malayalam (India)"
1004msgstr "Malayalam (Índia)"
1005
1006msgctxt "#40042"
1007msgid "Malay (Malaysia)"
1008msgstr "Malay (Malaysia)"
1009
1010msgctxt "#40043"
1011msgid "Malay (Singapore)"
1012msgstr "Malay (Singapore)"
1013
1014msgctxt "#40044"
1015msgid "Norwegian Bokmål (Norway)"
1016msgstr "Norwegian Bokmål (Noruega)"
1017
1018msgctxt "#40045"
1019msgid "Dutch (Netherlands)"
1020msgstr "Holandês (Países Baixos)"
1021
1022msgctxt "#40046"
1023msgid "Norwegian (Norway)"
1024msgstr "Norueguês (Noruega)"
1025
1026msgctxt "#40047"
1027msgid "Polish (Poland)"
1028msgstr "Polonês (Polônia)"
1029
1030msgctxt "#40048"
1031msgid "Portuguese (Brazil)"
1032msgstr "Português (Brasil)"
1033
1034msgctxt "#40049"
1035msgid "Portuguese (Portugal)"
1036msgstr "Português (Portugal)"
1037
1038msgctxt "#40050"
1039msgid "Romanian (Romania)"
1040msgstr "Romeno (Romania)"
1041
1042msgctxt "#40051"
1043msgid "Russian (Russia)"
1044msgstr "Russo (Rússia)"
1045
1046msgctxt "#40052"
1047msgid "Sinhalese (Sri Lanka)"
1048msgstr "Sinhalese (Sri Lanka)"
1049
1050msgctxt "#40053"
1051msgid "Slovak (Slovakia)"
1052msgstr "Slovak (Slovakia)"
1053
1054msgctxt "#40054"
1055msgid "Slovenian (Slovenia)"
1056msgstr "Slovenian (Slovenia)"
1057
1058msgctxt "#40055"
1059msgid "Serbian (Serbia)"
1060msgstr "Serbian (Serbia)"
1061
1062msgctxt "#40056"
1063msgid "Swedish (Sweden)"
1064msgstr "Sueco (Suécia)"
1065
1066msgctxt "#40057"
1067msgid "Tamil (India)"
1068msgstr "Tamil (Índia)"
1069
1070msgctxt "#40058"
1071msgid "Telugu (India)"
1072msgstr "Telugu (Índia)"
1073
1074msgctxt "#40059"
1075msgid "Thai (Thailand)"
1076msgstr "Tailandês (Tailândia)"
1077
1078msgctxt "#40060"
1079msgid "Tagalog (Philippines)"
1080msgstr "Tagalog (Philippines)"
1081
1082msgctxt "#40061"
1083msgid "Turkish (Turkey)"
1084msgstr "Turco (Turkey)"
1085
1086msgctxt "#40062"
1087msgid "Ukrainian (Ukraine)"
1088msgstr "Ucraniano (Ucrânia)"
1089
1090msgctxt "#40063"
1091msgid "Vietnamese (Vietnam)"
1092msgstr "Vietnamita (Vietnã)"
1093
1094msgctxt "#40064"
1095msgid "Mandarin (China)"
1096msgstr "Mandarin (China)"
1097
1098msgctxt "#40065"
1099msgid "Mandarin (Hong Kong)"
1100msgstr "Mandarin (Hong Kong)"
1101
1102msgctxt "#40066"
1103msgid "Mandarin (Taiwan)"
1104msgstr "Mandarin (Taiwan)"
1105
1106msgctxt "#40067"
1107msgid "Zulu (South Africa)"
1108msgstr "Zulu (South Africa)"
1109
1110msgctxt "#42418"
1111msgid "Language and country"
1112msgstr "Idioma e país"
1113
1114msgctxt "#42419"
1115msgid "The language and country settings used when looking up data from TMDb"
1116msgstr "As configurações de idioma e país utilizadas ao procurar dados no TMDb"
1117
1118msgctxt "#42420"
1119msgid "EN"
1120msgstr "EN"
1121
1122msgctxt "#30801"
1123msgid "Zilean server address (include port if required)."
1124msgstr "Endereço do servidor Zilean (inclua a porta se necessário)."
1125
1126msgctxt "#30803"
1127msgid "Display torrents not yet cached on your debrid service."
1128msgstr "Exibir torrents ainda não armazenados em cache no seu serviço debrid."
1129
1130msgctxt "#30808"
1131msgid "Fanart.tv Client Key (for artwork)."
1132msgstr "ID do cliente da API Fanart.tv (para imagens)."
1133
1134msgctxt "#30809"
1135msgid "Improve performance by caching metadata."
1136msgstr "Melhore o desempenho armazenando metadados em cache."
1137
1138msgctxt "#30810"
1139msgid "Seconds before Jackett requests time out (e.g., 30)."
1140msgstr "Segundos antes do tempo limite das requisições do Jackett (ex.: 30)."
1141
1142msgctxt "#30811"
1143msgid "Trakt client secret from OAuth application settings."
1144msgstr "Segredo do cliente Trakt das configurações do aplicativo OAuth."
1145
1146msgctxt "#30812"
1147msgid "Limit TV show results to specific seasons/episodes."
1148msgstr "Limitar resultados de séries a temporadas/episódios específicos."
1149
1150msgctxt "#30813"
1151msgid "Hours before cached data is automatically refreshed (e.g., 24)."
1152msgstr "Horas antes dos dados em cache serem atualizados automaticamente (ex.: 24)."
1153
1154msgctxt "#30814"
1155msgid "Premiumize Client Token (get from account page)."
1156msgstr "Token da API Premiumize (obtenha na página da conta)."
1157
1158msgctxt "#30815"
1159msgid "Jackett server URL (e.g., http://localhost)."
1160msgstr "URL do servidor Jackett (ex.: http://localhost:9117)."
1161
1162msgctxt "#30816"
1163msgid "Display Telegram integration options in UI."
1164msgstr "Exibir opções de integração do Telegram na interface."
1165
1166msgctxt "#30817"
1167msgid "Your Real-Debrid account username/email."
1168msgstr "Nome de usuário/e-mail da sua conta Real-Debrid."
1169
1170msgctxt "#30818"
1171msgid "Trakt client ID from https://trakt.tv/oauth/apps."
1172msgstr "ID do cliente Trakt de https://trakt.tv/oauth/apps."
1173
1174msgctxt "#30819"
1175msgid "Connect Trakt.tv account to sync watch progress."
1176msgstr "Conecte sua conta Trakt.tv para sincronizar o progresso de visualização."
1177
1178msgctxt "#30820"
1179msgid "Seconds before Zilean requests time out (e.g., 30)."
1180msgstr "Segundos antes do tempo limite das requisições do Zilean (ex.: 30)."
1181
1182msgctxt "#30821"
1183msgid "Your Jackett Client Key (found in dashboard)."
1184msgstr "Sua chave da API Jackett (encontrada no painel)."
1185
1186msgctxt "#30822"
1187msgid "Sort results by criteria (e.g., 'date', 'title', 'size')."
1188msgstr "Classificar resultados por critérios (ex.: 'data', 'título', 'tamanho')."
1189
1190msgctxt "#30823"
1191msgid "Configure Burst mode settings and providers."
1192msgstr "Configurar ajustes e provedores do modo Burst."
1193
1194msgctxt "#30824"
1195msgid "Enable Premiumize.me cloud service."
1196msgstr "Habilitar o serviço de nuvem Premiumize.me."
1197
1198msgctxt "#30825"
1199msgid "Truncate descriptions after [X] characters (0 for full text)."
1200msgstr "Truncar descrições após [X] caracteres (0 para texto completo)."
1201
1202msgctxt "#30826"
1203msgid "Enable Prowlarr indexer management."
1204msgstr "Habilitar gerenciamento de indexadores do Prowlarr."
1205
1206msgctxt "#30827"
1207msgid "Jackgram server URL (e.g., http://localhost:9117)."
1208msgstr "URL do servidor Jackgram (ex.: http://localhost:9117)."
1209
1210msgctxt "#30828"
1211msgid "Debrider Client Key for authentication."
1212msgstr "Chave da API Debrider para autenticação."
1213
1214msgctxt "#30829"
1215msgid "Delay (in seconds) before next episode dialog appears (e.g., 10)."
1216msgstr "Atraso (em segundos) antes do diálogo do próximo episódio aparecer (ex.: 10)."
1217
1218msgctxt "#30830"
1219msgid "Specify the torrent client."
1220msgstr "Especifique o executável do cliente de torrent."
1221
1222msgctxt "#30831"
1223msgid "Enable Debrider cached torrent support."
1224msgstr "Habilitar suporte a torrents em cache do EasyDebrid."
1225
1226msgctxt "#30832"
1227msgid "Torbox Client Token (found in account settings)."
1228msgstr "Token da API Torbox (encontrado nas configurações da conta)."
1229
1230msgctxt "#30833"
1231msgid "Trakt refresh token (auto-managed)."
1232msgstr "Token de atualização do Trakt (gerenciado automaticamente)."
1233
1234msgctxt "#30834"
1235msgid "Show confirmation dialog before playing next episode."
1236msgstr "Mostrar diálogo de confirmação antes de reproduzir o próximo episódio."
1237
1238msgctxt "#30835"
1239msgid "Authorize add-on to access Real-Debrid account."
1240msgstr "Autorizar a extensão a acessar a conta Real-Debrid."
1241
1242msgctxt "#30836"
1243msgid "Enable torrent-based media content"
1244msgstr "Habilitar conteúdo de mídia via torrent."
1245
1246msgctxt "#30837"
1247msgid "Indicates if Real-Debrid account is linked."
1248msgstr "Indica se a conta Real-Debrid está vinculada."
1249
1250msgctxt "#30838"
1251msgid "Automatically play next media item when current playback ends."
1252msgstr "Reproduzir automaticamente o próximo item ao terminar a reprodução atual."
1253
1254msgctxt "#30839"
1255msgid "Check for and install the latest add-on updates."
1256msgstr "Verificar e instalar as atualizações mais recentes da extensão."
1257
1258msgctxt "#30840"
1259msgid "Comma-separated Prowlarr indexer IDs to use (e.g., 1,5,7)."
1260msgstr "IDs dos indexadores do Prowlarr a serem usados, separados por vírgula (ex.: 1,5,7)."
1261
1262msgctxt "#30841"
1263msgid "Timeout (seconds) for Prowlarr requests (e.g., 20)."
1264msgstr "Tempo limite (segundos) para requisições do Prowlarr (ex.: 20)."
1265
1266msgctxt "#30842"
1267msgid "Trakt authorization expiration date (read-only)."
1268msgstr "Data de expiração da autorização do Trakt (somente leitura)."
1269
1270msgctxt "#30843"
1271msgid "Enable Torbox.de cloud torrent integration."
1272msgstr "Habilitar integração com torrents na nuvem do Torbox.de."
1273
1274msgctxt "#30844"
1275msgid "Trakt Access Token (auto-filled after auth)."
1276msgstr "Token de acesso da API Trakt (preenchido automaticamente após autenticação)."
1277
1278msgctxt "#30845"
1279msgid "Link Premiumize.me account to add-on."
1280msgstr "Vincular conta Premiumize.me à extensão."
1281
1282msgctxt "#30846"
1283msgid "Clear cached data automatically after updating the add-on."
1284msgstr "Limpar dados em cache automaticamente após atualizar a extensão."
1285
1286msgctxt "#30847"
1287msgid "Click to delete all cached data immediately."
1288msgstr "Clique para excluir todos os dados em cache imediatamente."
1289
1290msgctxt "#30848"
1291msgid "Real-Debrid Client Token (auto-filled after auth)."
1292msgstr "Token da API Real-Debrid (preenchido automaticamente após autenticação)."
1293
1294msgctxt "#30849"
1295msgid "Enable Jackett torrent proxy support."
1296msgstr "Habilitar suporte ao proxy de torrents do Jackett."
1297
1298msgctxt "#30850"
1299msgid "Enable Jackgram integration for enhanced search."
1300msgstr "Habilitar integração do Jackgram para buscas aprimoradas."
1301
1302msgctxt "#30851"
1303msgid "Prowlarr instance URL (e.g., http://localhost)."
1304msgstr "URL da instância Prowlarr (ex.: http://localhost:9696)."
1305
1306msgctxt "#30852"
1307msgid "Maximum number of search results to display (e.g., 50)."
1308msgstr "Número máximo de resultados de busca a exibir (ex.: 50)."
1309
1310msgctxt "#30853"
1311msgid "Revoke Real-Debrid account access."
1312msgstr "Revogar acesso à conta Real-Debrid."
1313
1314msgctxt "#30854"
1315msgid "Prowlarr Client Key (found in Settings > General)."
1316msgstr "Chave da API Prowlarr (encontrada em Configurações > Geral)."
1317
1318msgctxt "#30855"
1319msgid "Trakt.tv username (auto-populated after auth)."
1320msgstr "Nome de usuário Trakt.tv (preenchido automaticamente após autenticação)."
1321
1322msgctxt "#30856"
1323msgid "Disconnect Trakt.tv account from add-on."
1324msgstr "Desconectar conta Trakt.tv da extensão."
1325
1326msgctxt "#30857"
1327msgid "Enable Zilean integration for metadata fetching."
1328msgstr "Habilitar integração do Zilean para obtenção de metadados."
1329
1330msgctxt "#30858"
1331msgid "Your Client Key (required for metadata)"
1332msgstr "Sua chave da API TMDB (necessária para metadados)."
1333
1334msgctxt "#30859"
1335msgid "Enable Burst mode for accelerated searches."
1336msgstr "Habilitar modo Burst para buscas aceleradas."
1337
1338msgctxt "#30860"
1339msgid "Others"
1340msgstr "Outros"
1341
1342msgctxt "#30861"
1343msgid "Filters"
1344msgstr "Filters"
1345
1346msgctxt "#30862"
1347msgid "Torrent (P2P) Configuration"
1348msgstr "Torrent (P2P) Configuração"
1349
1350msgctxt "#30863"
1351msgid "Specify the torrent client"
1352msgstr "Especificar o cliente de torrent"
1353
1354msgctxt "#30864"
1355msgid "Sources"
1356msgstr "Pesquisar"
1357
1358msgctxt "#30865"
1359msgid "General Filters"
1360msgstr "Filtro e ordem dos resultados"
1361
1362msgctxt "#30866"
1363msgid "Sign in to your Stremio account to access add-ons and personalized features."
1364msgstr "Faça login na sua conta do Stremio para acessar complementos e recursos personalizados."
1365
1366msgctxt "#30867"
1367msgid "Sign out of your Stremio account. This will disconnect your account but won't delete your data."
1368msgstr "Faça logout da sua conta do Stremio. Isso desconectará sua conta, mas não excluirá seus dados."
1369
1370msgctxt "#30868"
1371msgid "Log Out of Stremio"
1372msgstr "Sair do Stremio"
1373
1374msgctxt "#30869"
1375msgid "Enable Stremio add-ons integration."
1376msgstr "Habilitar a integração de complementos do Stremio."
1377
1378msgctxt "#30870"
1379msgid "Select Stream Addons"
1380msgstr "Alternar complementos"
1381
1382msgctxt "#30871"
1383msgid "Log In to Stremio"
1384msgstr "Fazer login no Stremio"
1385
1386msgctxt "#30872"
1387msgid "Enable or disable Stremio add-ons."
1388msgstr "Habilitar ou desabilitar complementos do Stremio."
1389
1390msgctxt "#30873"
1391msgid "Select language for player subtitle selection"
1392msgstr "Priorizar resultados no idioma selecionado"
1393
1394msgctxt "#30874"
1395msgid "Update Addons"
1396msgstr "Atualizar Addons"
1397
1398msgctxt "#30875"
1399msgid "Select Catalogs Addons"
1400msgstr "Selecionar Catalógos e Addons"
1401
1402msgctxt "#30876"
1403msgid "Download directory"
1404msgstr "Download directory"
1405
1406msgctxt "#30877"
1407msgid "Enable Trakt.tv Scrobbling"
1408msgstr "Ativar Scrobbling do Trakt.tv"
1409
1410msgctxt "#30878"
1411msgid "Auto select subtitle when playing"
1412msgstr "Selecionar legenda automaticamente"
1413
1414msgctxt "#30879"
1415msgid "Auto-play quality"
1416msgstr "Qualidade de reprodução automática"
1417
1418msgctxt "#30880"
1419msgid "1080p"
1420msgstr "1080p"
1421
1422msgctxt "#30881"
1423msgid "720p"
1424msgstr "720p"
1425
1426msgctxt "#30882"
1427msgid "4k"
1428msgstr "4k"
1429
1430msgctxt "#30883"
1431msgid "480p"
1432msgstr "480p"
1433
1434msgctxt "#30884"
1435msgid "Subtitles Configuration"
1436msgstr "Configuração de legendas"
1437
1438msgctxt "#30885"
1439msgid "Addon Host"
1440msgstr "Host do addon"
1441
1442msgctxt "#30886"
1443msgid "Subtitles"
1444msgstr "Legendas"
1445
1446msgctxt "#30887"
1447msgid "Primary Language"
1448msgstr "Idioma principal"
1449
1450msgctxt "#30888"
1451msgid "Secondary Language"
1452msgstr "Idioma secundário"
1453
1454msgctxt "#30889"
1455msgid "Deepl Enabled"
1456msgstr "Deepl ativado"
1457
1458msgctxt "#30890"
1459msgid "Deepl Client Key"
1460msgstr "Chave API do Deepl"
1461
1462msgctxt "#30891"
1463msgid "Translator Configuration"
1464msgstr "Configuração do tradutor"
1465
1466msgctxt "#30892"
1467msgid "Deepl Target Language"
1468msgstr "Idioma de destino do Deepl"
1469
1470msgctxt "#30893"
1471msgid "Stremio Subtitle Configuration"
1472msgstr "Configuração de legendas do Stremio"
1473
1474msgctxt "#90001"
1475msgid "Add Custom Addon"
1476msgstr "Adicionar Addon Personalizado"
1477
1478msgctxt "#90002"
1479msgid "Add a custom Stremio addon by entering its URL"
1480msgstr "Adicionar um addon Stremio personalizado inserindo o seu URL"
1481
1482msgctxt "#90003"
1483msgid "Auto-select audio when playing"
1484msgstr "Selecionar áudio automaticamente ao reproduzir"
1485
1486msgctxt "#90004"
1487msgid "Audio Language"
1488msgstr "Idioma do Áudio"
1489
1490msgctxt "#90005"
1491msgid "Select audio language for playing the video"
1492msgstr "Selecionar o idioma do áudio para reproduzir o vídeo"
1493
1494msgctxt "#90006"
1495msgid "Search"
1496msgstr "Pesquisar"
1497
1498msgctxt "#90007"
1499msgid "TV Shows"
1500msgstr "Séries"
1501
1502msgctxt "#90008"
1503msgid "Movies"
1504msgstr "Filmes"
1505
1506msgctxt "#90009"
1507msgid "Anime"
1508msgstr "Anime"
1509
1510msgctxt "#90010"
1511msgid "Live TV"
1512msgstr "TV ao vivo"
1513
1514msgctxt "#90011"
1515msgid "Direct Search"
1516msgstr "Pesquisa Direta"
1517
1518msgctxt "#90012"
1519msgid "Torrents"
1520msgstr "Torrents"
1521
1522msgctxt "#90013"
1523msgid "Telegram"
1524msgstr "Telegram"
1525
1526msgctxt "#90014"
1527msgid "Cloud"
1528msgstr "Nuvem"
1529
1530msgctxt "#90015"
1531msgid "Downloads"
1532msgstr "Downloads"
1533
1534msgctxt "#90016"
1535msgid "Settings"
1536msgstr "Configurações"
1537
1538msgctxt "#90017"
1539msgid "History"
1540msgstr "Histórico"
1541
1542msgctxt "#90018"
1543msgid "Donate"
1544msgstr "Doar"
1545
1546msgctxt "#90019"
1547msgid "Recently Watched Files"
1548msgstr "Arquivos assistidos recentemente"
1549
1550msgctxt "#90020"
1551msgid "Recently Watched Titles"
1552msgstr "Títulos assistidos recentemente"
1553
1554msgctxt "#90021"
1555msgid "TV Calendar"
1556msgstr "Calendário de TV"
1557
1558msgctxt "#90022"
1559msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:"
1560msgstr "Se você gosta de usar o Jacktook e quer apoiar seu desenvolvimento, você pode me pagar um café! Seu apoio ajuda a manter o projeto ativo e permite futuras melhorias:"
1561
1562msgctxt "#90023"
1563msgid "Support Jacktook"
1564msgstr "Apoiar o Jacktook"
1565
1566msgctxt "#90024"
1567msgid "Trending Recent"
1568msgstr "Tendências recentes"
1569
1570msgctxt "#90025"
1571msgid "Genres"
1572msgstr "Gêneros"
1573
1574msgctxt "#90026"
1575msgid "Calendar"
1576msgstr "Calendário"
1577
1578msgctxt "#90027"
1579msgid "Years"
1580msgstr "Anos"
1581
1582msgctxt "#90028"
1583msgid "Trending"
1584msgstr "Tendências"
1585
1586msgctxt "#90029"
1587msgid "Most Watched"
1588msgstr "Mais assistidos"
1589
1590msgctxt "#90030"
1591msgid "Most Favorited"
1592msgstr "Mais favoritados"
1593
1594msgctxt "#90031"
1595msgid "Popular Lists"
1596msgstr "Listas populares"
1597
1598msgctxt "#90032"
1599msgid "Trending Lists"
1600msgstr "Listas de tendências"
1601
1602msgctxt "#90033"
1603msgid "Recomendations"
1604msgstr "Recomendações"
1605
1606msgctxt "#90034"
1607msgid "Watched History"
1608msgstr "Histórico de visualização"
1609
1610msgctxt "#90035"
1611msgid "Watchlist"
1612msgstr "Watchlist"
1613
1614msgctxt "#90036"
1615msgid "Top 10 BoxOffice"
1616msgstr "Top 10 Bilheteria"
1617
1618msgctxt "#90037"
1619msgid "Popular"
1620msgstr "Popular"
1621
1622msgctxt "#90038"
1623msgid "Popular Recent"
1624msgstr "Populares recentes"
1625
1626msgctxt "#90039"
1627msgid "Airing"
1628msgstr "No ar"
1629
1630msgctxt "#90040"
1631msgid "Years"
1632msgstr "Anos"
1633
1634msgctxt "#90041"
1635msgid "Genres"
1636msgstr "Gêneros"
1637
1638msgctxt "#90042"
1639msgid "Top Rated"
1640msgstr "Melhores avaliados"
1641
1642msgctxt "#90043"
1643msgid "Most Watched"
1644msgstr "Mais assistidos"
1645
1646msgctxt "#90044"
1647msgid "Torrentio Configuration"
1648msgstr "Torrentio Configuração"
1649
1650msgctxt "#90045"
1651msgid "Enable Torrentio Settings"
1652msgstr "Ativar definições Torrentio"
1653
1654msgctxt "#90046"
1655msgid "Enable or disable the Torrentio Configuration"
1656msgstr "Habilitar ou desabilitar the Torrentio Configuration"
1657
1658msgctxt "#90047"
1659msgid "Select Torrentio Providers"
1660msgstr "Selecionar provedores Torrentio"
1661
1662msgctxt "#90048"
1663msgid "Choose which providers to include in the stream request."
1664msgstr "Escolha quais os provedores a incluir no pedido de stream."
1665
1666msgctxt "#90049"
1667msgid "Scrape again"
1668msgstr "Pesquisar novamente"
1669
1670msgctxt "#90050"
1671msgid "Recommendations"
1672msgstr "Recomendações"
1673
1674msgctxt "#90051"
1675msgid "Similar"
1676msgstr "Semelhante"
1677
1678msgctxt "#90052"
1679msgid "Thread Number"
1680msgstr "Número de threads"
1681
1682msgctxt "#90053"
1683msgid "Select the number of threads (1-30)"
1684msgstr "Select the number of threads (1-30)"
1685
1686msgctxt "#90054"
1687msgid "Kodi logs"
1688msgstr "Logs do Kodi"
1689
1690msgctxt "#90055"
1691msgid "Kodi old logs"
1692msgstr "Logs antigos do Kodi"
1693
1694msgctxt "#90056"
1695msgid "Displays Kodi logs to help diagnose issues."
1696msgstr "Exibe os logs do Kodi para ajudar a diagnosticar problemas."
1697
1698msgctxt "#90057"
1699msgid "Logs"
1700msgstr "Logs"
1701
1702msgctxt "#90058"
1703msgid "Filter by Size"
1704msgstr "Filtrar por Tamanho"
1705
1706msgctxt "#90059"
1707msgid "Enable Size Filter"
1708msgstr "Ativar filtro de tamanho"
1709
1710msgctxt "#90060"
1711msgid "Minimum Size (MB)"
1712msgstr "Minimum Size (MB)"
1713
1714msgctxt "#90061"
1715msgid "Maximum Size (MB)"
1716msgstr "Maximum Size (MB)"
1717
1718msgctxt "#90062"
1719msgid "Enable Cached Search"
1720msgstr "Ativar pesquisa em cache"
1721
1722msgctxt "#90063"
1723msgid "Only show cached streams from external services"
1724msgstr "Mostrar apenas streams em cache de serviços externos"
1725
1726msgctxt "#90064"
1727msgid "Popular"
1728msgstr "Popular"
1729
1730msgctxt "#90065"
1731msgid "Languages"
1732msgstr "Idiomas"
1733
1734msgctxt "#90066"
1735msgid "Networks"
1736msgstr "Redes"
1737
1738msgctxt "#90067"
1739msgid "Collections"
1740msgstr "Coleções"
1741
1742msgctxt "#90068"
1743msgid "Search for collections:"
1744msgstr "Pesquisar coleções:"
1745
1746msgctxt "#90069"
1747msgid "Main Menu"
1748msgstr "Menu Principal"
1749
1750msgctxt "#90070"
1751msgid "Last Titles"
1752msgstr "Últimos Títulos"
1753
1754msgctxt "#90071"
1755msgid "Last Files"
1756msgstr "Últimos Arquivos"
1757
1758msgctxt "#90072"
1759msgid "Trakt Lists"
1760msgstr "Listas Trakt"
1761
1762msgctxt "#90073"
1763msgid "Metadata"
1764msgstr "Metadados"
1765
1766msgctxt "#90074"
1767msgid "Top Lists"
1768msgstr "Listas Top"
1769
1770msgctxt "#90075"
1771msgid "User Lists"
1772msgstr "Listas do Usuário"
1773
1774msgctxt "#90076"
1775msgid "Search Lists"
1776msgstr "Pesquisar Listas"
1777
1778msgctxt "#90077"
1779msgid "MDbList Configuration"
1780msgstr "MDbList Configuração"
1781
1782msgctxt "#90078"
1783msgid "People"
1784msgstr "Pessoas"
1785
1786msgctxt "#90079"
1787msgid "Popular People"
1788msgstr "Pessoas Populares"
1789
1790msgctxt "#90080"
1791msgid "Latest People"
1792msgstr "Pessoas Recentes"
1793
1794msgctxt "#90081"
1795msgid "Search People"
1796msgstr "Pesquisar Pessoas"
1797
1798msgctxt "#90082"
1799msgid "Download subtitles"
1800msgstr "Baixar legendas"
1801
1802msgctxt "#90083"
1803msgid "Download video"
1804msgstr "Baixar vídeo"
1805
1806msgctxt "#90084"
1807msgid "Browse into"
1808msgstr "Explorar"
1809
1810msgctxt "#90085"
1811msgid "Include TV show Specials"
1812msgstr "Incluir Especiais de Séries de TV"
1813
1814msgctxt "#90086"
1815msgid "Airing Today"
1816msgstr "Hoje no Ar"
1817
1818msgctxt "#90099"
1819msgid "Filter by Source Type"
1820msgstr "Filtrar por Tipo de Fonte"
1821
1822msgctxt "#90100"
1823msgid "Include BluRay/UHD sources"
1824msgstr "Incluir fontes BluRay/UHD"
1825
1826msgctxt "#90105"
1827msgid "Includes releases such as: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX"
1828msgstr "Inclui lançamentos como: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX"
1829
1830msgctxt "#90101"
1831msgid "Include WEB/HD sources"
1832msgstr "Incluir fontes WEB/HD"
1833
1834msgctxt "#90106"
1835msgid "Includes releases such as: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux"
1836msgstr "Inclui lançamentos como: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux"
1837
1838msgctxt "#90102"
1839msgid "Include DVD/TV/SAT sources"
1840msgstr "Incluir fontes DVD/TV/SAT"
1841
1842msgctxt "#90107"
1843msgid "Includes releases such as: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV"
1844msgstr "Inclui lançamentos como: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV"
1845
1846msgctxt "#90103"
1847msgid "Include CAM/Screener sources"
1848msgstr "Incluir fontes CAM/Screener"
1849
1850msgctxt "#90108"
1851msgid "Includes releases such as: CAM, TeleSync, TeleCine, SCR"
1852msgstr "Inclui lançamentos como: CAM, TeleSync, TeleCine, SCR"
1853
1854msgctxt "#90104"
1855msgid "Include Unknown sources"
1856msgstr "Incluir fontes desconhecidas"
1857
1858msgctxt "#90109"
1859msgid "Includes uncategorized or unidentified sources"
1860msgstr "Inclui fontes não categorizadas ou não identificadas"
1861
1862msgctxt "#90110"
1863msgid "Do you want to clear your watched history?."
1864msgstr "Deseja limpar seu histórico de watched history.?"
1865
1866msgctxt "#90111"
1867msgid "Do you want to clear your history?."
1868msgstr "Deseja limpar seu histórico de history.?"
1869
1870msgctxt "#90112"
1871msgid "Clear History"
1872msgstr "Limpar Histórico"
1873
1874msgctxt "#90113"
1875msgid "Click to delete all watched history immediately."
1876msgstr "Clique para apagar todo o histórico de visualização imediatamente."
1877
1878msgctxt "#90114"
1879msgid "History cleared"
1880msgstr "Histórico apagado"
1881
1882msgctxt "#90115"
1883msgid "Source Select"
1884msgstr "Seleção de Fonte"
1885
1886msgctxt "#90116"
1887msgid "Open Settings"
1888msgstr "Abrir Configurações"
1889
1890msgctxt "#90117"
1891msgid "Select Torrentio Providers"
1892msgstr "Selecionar provedores Torrentio"
1893
1894msgctxt "#90118"
1895msgid "Port"
1896msgstr "Porta"
1897
1898msgctxt "#90119"
1899msgid "Jackett port (default:9117)"
1900msgstr "Jackett port (default:9117)"
1901
1902msgctxt "#90120"
1903msgid "Prowlarr port (default:9696)"
1904msgstr "Prowlarr port (default:9696)"
1905
1906msgctxt "#90121"
1907msgid "All Debrid Configuration"
1908msgstr "All Debrid Configuração"
1909
1910msgctxt "#90122"
1911msgid "Auto download subtitles"
1912msgstr "Baixar legendas automaticamente"
1913
1914msgctxt "#90123"
1915msgid "Stremio Timeout"
1916msgstr "Stremio Tempo limite"
1917
1918msgctxt "#90124"
1919msgid "Webdav Client Configuration"
1920msgstr "Webdav Client Configuração"
1921
1922msgctxt "#90126"
1923msgid "Host"
1924msgstr "Host"
1925
1926msgctxt "#90127"
1927msgid "Username"
1928msgstr "Nome de usuário"
1929
1930msgctxt "#90128"
1931msgid "Password"
1932msgstr "Senha"
1933
1934msgctxt "#90129"
1935msgid "Test Connection"
1936msgstr ""
1937
1938msgctxt "#90131"
1939msgid "Webdav"
1940msgstr ""
1941
1942msgctxt "#90132"
1943msgid "Remote Path"
1944msgstr ""
1945
1946msgctxt "#90133"
1947msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)."
1948msgstr "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)"
1949
1950msgctxt "#90134"
1951msgid "The username required for authenticating with your WebDAV server"
1952msgstr ""
1953
1954msgctxt "#90135"
1955msgid "The password for the specified username to authenticate with your WebDAV server"
1956msgstr ""
1957
1958msgctxt "#90136"
1959msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)."
1960msgstr "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)"
1961
1962msgctxt "#90137"
1963msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials."
1964msgstr ""
1965
1966msgctxt "#90138"
1967msgid "Remove Custom Addon"
1968msgstr ""
1969
1970msgctxt "#90139"
1971msgid "Playback"
1972msgstr "Reprodução"
1973
1974msgctxt "#90140"
1975msgid "Super quick play"
1976msgstr ""
1977
1978msgctxt "#90141"
1979msgid "Re-ask to play last played video"
1980msgstr ""
1981
1982msgctxt "#90142"
1983msgid "Replay Dialog"
1984msgstr ""
1985
1986msgctxt "#90143"
1987msgid "Do you want to play last watched?"
1988msgstr ""
1989
1990msgctxt "#90144"
1991msgid "Could not resolve playback URL"
1992msgstr ""
1993
1994msgctxt "#90145"
1995msgid "Precaching Enabled"
1996msgstr "Pré-carregamento Ativado"
1997
1998msgctxt "#90146"
1999msgid "Enable or disable precaching of next episodes."
2000msgstr "Ativar ou desativar o pré-carregamento dos próximos episódios."
2001
2002msgctxt "#90147"
2003msgid "Precaching Episode Count"
2004msgstr "Contagem de Episódios para Pré-carregar"
2005
2006msgctxt "#90148"
2007msgid "Number of next episodes to precache."
2008msgstr "Número de próximos episódios para pré-carregar."
2009
2010msgctxt "#30901"
2011msgid "Show Only Stremio Catalogs"
2012msgstr ""
2013
2014msgctxt "#30902"
2015msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus."
2016msgstr "Oculta itens incorporados (TMDb, Trakt, etc.) e mostra apenas catálogos do Stremio nos menus."
2017
2018msgctxt "#30903"
2019msgid "Select Live TV Addons"
2020msgstr "Selecionar add-ons de TV ao vivo"
2021
2022msgctxt "#30904"
2023msgid "Choose specific Stremio addons for Live TV content"
2024msgstr "Escolher add-ons específicos do Stremio para conteúdo de TV ao vivo"
2025
2026msgctxt "#30920"
2027msgid "Clear Trakt Cache"
2028msgstr "Limpar cache do Trakt"
2029
2030msgctxt "#30921"
2031msgid "Clear TMDB Cache"
2032msgstr "Limpar cache do TMDB"
2033
2034msgctxt "#30922"
2035msgid "Clear Providers Cache"
2036msgstr "Limpar cache de Provedores"
2037
2038msgctxt "#30923"
2039msgid "Clear Settings Cache"
2040msgstr "Limpar cache de Configurações"
2041
2042msgctxt "#90149"
2043msgid "Maintenance"
2044msgstr "Manutenção"
2045
2046msgctxt "#90150"
2047msgid "Stremio: General"
2048msgstr "Stremio: Geral"
2049
2050msgctxt "#90151"
2051msgid "Stremio: Account"
2052msgstr "Stremio: Conta"
2053
2054msgctxt "#90152"
2055msgid "Stremio: Addons & Catalogs"
2056msgstr "Stremio: Addons e catálogos"
2057
2058msgctxt "#90153"
2059msgid "Stremio: Torrentio"
2060msgstr "Stremio: Torrentio"
2061
2062msgctxt "#90154"
2063msgid "Trending Recent"
2064msgstr "Tendências recentes"
2065
2066msgctxt "#90155"
2067msgid "Stremio: Filters"
2068msgstr "Stremio: Filters"
2069
2070msgctxt "#32300"
2071msgid "Filter by Quality"
2072msgstr "Filtrar por Qualidade"
2073
2074msgctxt "#32301"
2075msgid "Filter by Provider"
2076msgstr ""
2077
2078msgctxt "#32302"
2079msgid "Filter by Source"
2080msgstr ""
2081
2082msgctxt "#32303"
2083msgid "Filter by Language"
2084msgstr ""
2085
2086msgctxt "#32304"
2087msgid "Filter by Torrents"
2088msgstr ""
2089
2090msgctxt "#32305"
2091msgid "Filter by Debrid"
2092msgstr "Filtrar por Debrid"
2093
2094msgctxt "#32306"
2095msgid "Reset Filter"
2096msgstr ""
2097
2098msgctxt "#30930"
2099msgid "Ping Addons"
2100msgstr "Testar conexão dos add-ons"
2101
2102msgctxt "#30931"
2103msgid "Check availability of addons and show only reachable ones"
2104msgstr "Verificar a disponibilidade dos add-ons e mostrar apenas os alcançáveis."
2105
2106msgctxt "#90164"
2107msgid "Skip Intro / Recap"
2108msgstr "Pular Intro / Recapitulação"
2109
2110msgctxt "#90165"
2111msgid "Enable Skip Intro"
2112msgstr "Habilitar Pular Intro"
2113
2114msgctxt "#90166"
2115msgid "Detect intros and recaps using IntroDB and offer to skip them during playback"
2116msgstr "Detectar intros e recapitulações usando IntroDB e oferecer para pular durante a reprodução"
2117
2118msgctxt "#90167"
2119msgid "Auto-skip"
2120msgstr "Pular automaticamente"
2121
2122msgctxt "#90168"
2123msgid "Automatically skip intros and recaps without showing the button"
2124msgstr "Pular automaticamente intros e recapitulações sem mostrar o botão"
2125
2126msgctxt "#90160"
2127msgid "Skip Intro"
2128msgstr "Pular Intro"
2129
2130msgctxt "#90161"
2131msgid "Skip Recap"
2132msgstr "Pular Recapitulação"
2133
2134msgctxt "#90162"
2135msgid "Manage from Phone"
2136msgstr "Gerenciar pelo celular"
2137
2138msgctxt "#90163"
2139msgid "Start a local web server and show a QR code to manage Stremio sources from your phone"
2140msgstr "Iniciar um servidor web local e mostrar um código QR para gerenciar as fontes do Stremio pelo seu celular"
2141
2142msgctxt "#90200"
2143msgid "Continue Watching"
2144msgstr "Continuar Assistindo"
2145
2146msgctxt "#90201"
2147msgid "Library"
2148msgstr "Biblioteca"
2149
2150msgctxt "#90202"
2151msgid "My Shows"
2152msgstr "Minhas Séries"
2153
2154msgctxt "#90203"
2155msgid "My Movies"
2156msgstr "Meus Filmes"
2157
2158msgctxt "#90204"
2159msgid "Remove from Library"
2160msgstr "Remover da Biblioteca"
2161
2162msgctxt "#90205"
2163msgid "Add to Library"
2164msgstr "Adicionar à Biblioteca"
2165
2166msgctxt "#90206"
2167msgid "Remove"
2168msgstr "Remover"
2169
2170msgctxt "#90207"
2171msgid "Search Movies & TV"
2172msgstr "Pesquisar Filmes e Séries"
2173
2174msgctxt "#90208"
2175msgid "Recent Searches"
2176msgstr "Pesquisas Recentes"
2177
2178msgctxt "#90209"
2179msgid "Recent Direct Searches"
2180msgstr "Pesquisas Diretas Recentes"
2181
2182msgctxt "#90210"
2183msgid "Clear All Search History"
2184msgstr "Limpar todo o histórico de pesquisa"
2185
2186msgctxt "#90211"
2187msgid "Bypass Stremio Addons Native Sorting"
2188msgstr "Ignorar a ordenação nativa dos add-ons do Stremio"
2189
2190msgctxt "#90212"
2191msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds."
2192msgstr ""
2193
2194msgctxt "#90213"
2195msgid "Select Bypassed Addons"
2196msgstr "Selecionar add-ons a ignorar"
2197
2198msgctxt "#90214"
2199msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering."
2200msgstr ""
2201
2202msgctxt "#90215"
2203msgid "Downgrade"
2204msgstr "Reverter"
2205
2206msgctxt "#90671"
2207msgid "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails."
2208msgstr "Instala uma versao publicada anterior do Jacktook. A versao atual e salva e restaurada automaticamente se o downgrade falhar."
2209
2210msgctxt "#30932"
2211msgid "Update Action"
2212msgstr ""
2213
2214msgctxt "#30933"
2215msgid "Choose how to handle background update checks."
2216msgstr ""
2217
2218msgctxt "#30934"
2219msgid "Ask"
2220msgstr ""
2221
2222msgctxt "#30935"
2223msgid "Notify"
2224msgstr ""
2225
2226msgctxt "#90216"
2227msgid "Volume Check Enabled"
2228msgstr ""
2229
2230msgctxt "#90217"
2231msgid "Automatically limits volume if it is above the threshold before playback."
2232msgstr ""
2233
2234msgctxt "#90218"
2235msgid "Volume Threshold"
2236msgstr "Limite de volume"
2237
2238msgctxt "#90219"
2239msgid "The volume percentage above which the warning triggers."
2240msgstr ""
2241
2242msgctxt "#90220"
2243msgid "High Volume Warning"
2244msgstr ""
2245
2246msgctxt "#90221"
2247msgid "High volume detected. Adjusting to %s%% for safety."
2248msgstr ""
2249
2250msgctxt "#90222"
2251msgid "Fanart.tv Configuration"
2252msgstr "Fanart.tv Configuração"
2253
2254msgctxt "#90223"
2255msgid "Client Key"
2256msgstr ""
2257
2258msgctxt "#90224"
2259msgid "Your personal Fanart.tv client key for higher quality assets."
2260msgstr ""
2261
2262msgctxt "#90225"
2263msgid "Debrid Notifications"
2264msgstr ""
2265
2266msgctxt "#90226"
2267msgid "Debrid Expiration Notification"
2268msgstr ""
2269
2270msgctxt "#90227"
2271msgid "Enable or disable notifications when your Debrid subscription is about to expire."
2272msgstr "Habilitar ou desabilitar notifications when your Debrid subscription is about to expire."
2273
2274msgctxt "#90228"
2275msgid "Expiration Threshold (Days)"
2276msgstr "Expiration Threshold (Days)"
2277
2278msgctxt "#90229"
2279msgid "How many days before expiration to start notifying."
2280msgstr ""
2281
2282msgctxt "#90230"
2283msgid "%s expires in %d days."
2284msgstr ""
2285
2286msgctxt "#90231"
2287msgid "%s expires today!"
2288msgstr ""
2289
2290msgctxt "#90232"
2291msgid "Easynews"
2292msgstr "Easynews"
2293
2294msgctxt "#90233"
2295msgid "Enable EasyNews as a direct stream provider."
2296msgstr ""
2297
2298msgctxt "#90234"
2299msgid "Your EasyNews username."
2300msgstr ""
2301
2302msgctxt "#90235"
2303msgid "Your EasyNews password."
2304msgstr ""
2305
2306msgctxt "#90236"
2307msgid "Request timeout in seconds."
2308msgstr ""
2309
2310msgctxt "#90237"
2311msgid "Account Information"
2312msgstr "Informações da conta"
2313
2314msgctxt "#90238"
2315msgid "Search Dialog Style"
2316msgstr ""
2317
2318msgctxt "#90239"
2319msgid "Background"
2320msgstr ""
2321
2322msgctxt "#90240"
2323msgid "Detailed Status"
2324msgstr ""
2325
2326msgctxt "#90241"
2327msgid "Search Status"
2328msgstr "Status da pesquisa"
2329
2330msgctxt "#90242"
2331msgid "Cancel"
2332msgstr "Cancelar"
2333
2334msgctxt "#90243"
2335msgid "Results found: "
2336msgstr "Resultados encontrados: "
2337
2338msgctxt "#90244"
2339msgid "Pending"
2340msgstr "Pendente"
2341
2342msgctxt "#90245"
2343msgid "In Progress"
2344msgstr "Em progresso"
2345
2346msgctxt "#90246"
2347msgid "Completed"
2348msgstr "Concluído"
2349
2350msgctxt "#90247"
2351msgid "Failed"
2352msgstr "Falhou"
2353
2354msgctxt "#90248"
2355msgid "Cancelled"
2356msgstr "Cancelado"
2357
2358msgctxt "#90249"
2359msgid "Your personal Jackgram API token for authentication."
2360msgstr ""
2361
2362msgctxt "#90250"
2363msgid "Subtitles Found"
2364msgstr "Legendas Encontradas"
2365
2366msgctxt "#90251"
2367msgid "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?"
2368msgstr "Ja existem legendas baixadas para este video.\nDeseja usar as legendas existentes?"
2369
2370msgctxt "#90252"
2371msgid "No subtitles found"
2372msgstr "Nenhuma legenda encontrada"
2373
2374msgctxt "#90253"
2375msgid "No subtitles selected"
2376msgstr "Nenhuma legenda selecionada"
2377
2378msgctxt "#90254"
2379msgid "Translate Subtitles"
2380msgstr "Traduzir Legendas"
2381
2382msgctxt "#90255"
2383msgid "Do you want to also translate the selected subtitles?"
2384msgstr "Deseja tambem traduzir as legendas selecionadas?"
2385
2386msgctxt "#90256"
2387msgid "Select Subtitle to Download"
2388msgstr "Selecionar Legenda para Baixar"
2389
2390msgctxt "#90257"
2391msgid "Subtitles Loaded"
2392msgstr "Legendas Carregadas"
2393
2394msgctxt "#90258"
2395msgid "Backup"
2396msgstr "Backup das Configuracoes"
2397
2398msgctxt "#90259"
2399msgid "Export Backup"
2400msgstr "Exportar Backup"
2401
2402msgctxt "#90260"
2403msgid "Export addon settings and custom Stremio addons to a JSON backup file."
2404msgstr "Exporta as configuracoes do addon e os addons personalizados do Stremio para um arquivo JSON de backup."
2405
2406msgctxt "#90261"
2407msgid "Restore Backup"
2408msgstr "Restaurar Backup das Configuracoes"
2409
2410msgctxt "#90262"
2411msgid "Restore addon settings and replace custom Stremio addons from a JSON backup file."
2412msgstr "Restaura as configuracoes do addon e substitui os addons personalizados do Stremio a partir de um arquivo JSON de backup."
2413
2414msgctxt "#90263"
2415msgid "Remove Sensitive Data"
2416msgstr "Remover Dados Sensiveis"
2417
2418msgctxt "#90264"
2419msgid "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups."
2420msgstr "Remove chaves de API, senhas, nomes de usuario, emails, estado de autenticacao e URLs de addons personalizados do Stremio dos backups exportados."
2421
2422msgctxt "#90265"
2423msgid "Settings backup exported"
2424msgstr "Backup exportado"
2425
2426msgctxt "#90266"
2427msgid "Settings backup restored"
2428msgstr "Backup restaurado"
2429
2430msgctxt "#90267"
2431msgid "Settings Backup Failed"
2432msgstr "Falha no Backup das Configuracoes"
2433
2434msgctxt "#90268"
2435msgid "Select export folder"
2436msgstr "Selecionar pasta de exportacao"
2437
2438msgctxt "#90269"
2439msgid "Select backup file"
2440msgstr "Selecionar arquivo de backup"
2441
2442msgctxt "#90270"
2443msgid "Backup file is invalid."
2444msgstr "O arquivo de backup e invalido."
2445
2446msgctxt "#90271"
2447msgid "No export folder selected."
2448msgstr "Nenhuma pasta de exportacao selecionada."
2449
2450msgctxt "#90272"
2451msgid "No backup file selected."
2452msgstr "Nenhum arquivo de backup selecionado."
2453
2454msgctxt "#90273"
2455msgid "Unable to export settings backup."
2456msgstr "Nao foi possivel exportar o backup."
2457
2458msgctxt "#90274"
2459msgid "Unable to restore settings backup."
2460msgstr "Nao foi possivel restaurar o backup."
2461
2462msgctxt "#90275"
2463msgid "Restore Backup"
2464msgstr "Restaurar Backup das Configuracoes"
2465
2466msgctxt "#90276"
2467msgid "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?"
2468msgstr "Isso vai sobrescrever as configuracoes atuais do addon e substituir seus addons personalizados do Stremio pelos do backup. Deseja continuar?"
2469
2470msgctxt "#90277"
2471msgid "Restore cancelled"
2472msgstr "Restauracao cancelada"
2473
2474msgctxt "#90278"
2475msgid "Reset All Settings"
2476msgstr "Redefinir Todas as Configuracoes"
2477
2478msgctxt "#90279"
2479msgid "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?"
2480msgstr "Isso vai redefinir todas as configuracoes do addon para os valores padrao e remover seus addons personalizados do Stremio. Deseja continuar?"
2481
2482msgctxt "#90280"
2483msgid "Reset cancelled"
2484msgstr "Redefinicao cancelada"
2485
2486msgctxt "#90281"
2487msgid "All addon settings reset"
2488msgstr "Todas as configuracoes do addon foram redefinidas"
2489
2490msgctxt "#90282"
2491msgid "Reset Failed"
2492msgstr "Falha ao Redefinir"
2493
2494msgctxt "#90283"
2495msgid "Unable to reset addon settings."
2496msgstr "Nao foi possivel redefinir as configuracoes do addon."
2497
2498msgctxt "#90284"
2499msgid "Reset every addon setting back to its default value and clear custom Stremio addons."
2500msgstr "Redefine cada configuracao do addon para o valor padrao e limpa os addons personalizados do Stremio."
2501
2502msgctxt "#90285"
2503msgid "Factory Reset"
2504msgstr "Redefinicao de Fabrica"
2505
2506msgctxt "#90286"
2507msgid "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?"
2508msgstr "Isso vai redefinir as configuracoes, limpar caches, remover addons personalizados do Stremio e apagar o historico e os dados locais do addon. Deseja continuar?"
2509
2510msgctxt "#90287"
2511msgid "Factory reset cancelled"
2512msgstr "Redefinicao de fabrica cancelada"
2513
2514msgctxt "#90288"
2515msgid "Factory reset completed"
2516msgstr "Redefinicao de fabrica concluida"
2517
2518msgctxt "#90289"
2519msgid "Factory Reset Failed"
2520msgstr "Falha na Redefinicao de Fabrica"
2521
2522msgctxt "#90290"
2523msgid "Unable to complete factory reset."
2524msgstr "Nao foi possivel concluir a redefinicao de fabrica."
2525
2526msgctxt "#90291"
2527msgid "Reset settings to defaults, clear caches, and wipe local addon history/database data."
2528msgstr "Redefine as configuracoes para os valores padrao, limpa caches e apaga o historico e os dados locais do addon."
2529
2530msgctxt "#90292"
2531msgid "Trakt Library"
2532msgstr "Biblioteca do Trakt"
2533
2534msgctxt "#90293"
2535msgid "Trakt Discovery"
2536msgstr "Descoberta do Trakt"
2537
2538msgctxt "#90294"
2539msgid "Collection"
2540msgstr "Colecao"
2541
2542msgctxt "#90295"
2543msgid "Favorites"
2544msgstr "Favoritos"
2545
2546msgctxt "#90296"
2547msgid "Up Next"
2548msgstr "A Seguir"
2549
2550msgctxt "#90297"
2551msgid "My Lists"
2552msgstr "Minhas Listas"
2553
2554msgctxt "#90298"
2555msgid "Liked Lists"
2556msgstr "Listas Curtidas"
2557
2558msgctxt "#90331"
2559msgid "Stremio Addons"
2560msgstr "Addons do Stremio"
2561
2562msgctxt "#90332"
2563msgid "No addons available yet. Use Log In to Stremio or Add Custom Addon first."
2564msgstr "Ainda nao ha addons disponiveis. Use Iniciar sessao no Stremio ou Adicionar Addon Personalizado primeiro."
2565
2566msgctxt "#90333"
2567msgid "Stremio Stream Addons"
2568msgstr "Addons de streams do Stremio"
2569
2570msgctxt "#90334"
2571msgid "Stremio Catalog Addons"
2572msgstr "Addons de catalogo do Stremio"
2573
2574msgctxt "#90335"
2575msgid "Stremio Live TV Addons"
2576msgstr "Addons de TV ao vivo do Stremio"
2577
2578msgctxt "#90336"
2579msgid "Refresh addons imported from your Stremio account. Custom addons remain unchanged."
2580msgstr "Atualiza os addons importados da sua conta Stremio. Os addons personalizados permanecem inalterados."
2581
2582msgctxt "#90337"
2583msgid "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first."
2584msgstr "Escolha quais addons de streams do Stremio, importados ou personalizados, deseja usar. Se ainda nao houver addons disponiveis, inicie sessao ou adicione primeiro um addon personalizado."
2585
2586msgctxt "#90338"
2587msgid "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first."
2588msgstr "Escolha quais addons de catalogo do Stremio, importados ou personalizados, deseja usar. Se ainda nao houver addons disponiveis, inicie sessao ou adicione primeiro um addon personalizado."
2589
2590msgctxt "#90339"
2591msgid "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first."
2592msgstr "Escolha quais addons de TV ao vivo do Stremio, importados ou personalizados, deseja usar. Se ainda nao houver addons disponiveis, inicie sessao ou adicione primeiro um addon personalizado."
2593
2594msgctxt "#90340"
2595msgid "Remove custom Stremio addons you previously added manually."
2596msgstr "Remova os addons personalizados do Stremio que voce adicionou manualmente."
2597
2598msgctxt "#90341"
2599msgid "Choose Torrent Client"
2600msgstr "Escolher cliente de torrent"
2601
2602msgctxt "#90342"
2603msgid "Clear Database Cache"
2604msgstr "Limpar cache do banco de dados"
2605
2606msgctxt "#90343"
2607msgid "Clear Stremio Cache"
2608msgstr "Limpar cache do Stremio"
2609
2610msgctxt "#90344"
2611msgid "Clear Debrid Cache"
2612msgstr "Limpar cache de Debrid"
2613
2614msgctxt "#90345"
2615msgid "Clear MDBList Cache"
2616msgstr "Limpar cache do MDBList"
2617
2618msgctxt "#90346"
2619msgid "Restore backup from"
2620msgstr "Restaurar backup de"
2621
2622msgctxt "#90347"
2623msgid "File"
2624msgstr "Arquivo"
2625
2626msgctxt "#90348"
2627msgid "URL"
2628msgstr "URL"
2629
2630msgctxt "#90349"
2631msgid "Enter backup URL"
2632msgstr "Informe a URL do backup"
2633
2634msgctxt "#90350"
2635msgid "No backup URL provided."
2636msgstr "Nenhuma URL de backup foi informada."
2637
2638msgctxt "#90351"
2639msgid "Unable to download settings backup."
2640msgstr "Nao foi possivel baixar o backup de configuracoes."
2641
2642msgctxt "#90352"
2643msgid "Source Select Style"
2644msgstr ""
2645
2646msgctxt "#90353"
2647msgid "Modern"
2648msgstr ""
2649
2650msgctxt "#90354"
2651msgid "OLED"
2652msgstr ""
2653
2654msgctxt "#90355"
2655msgid "Cyberpunk"
2656msgstr ""
2657
2658msgctxt "#90356"
2659msgid "Forest"
2660msgstr ""
2661
2662msgctxt "#90357"
2663msgid "Gold"
2664msgstr ""
2665
2666msgctxt "#90358"
2667msgid "No playable results found"
2668msgstr "Nenhum resultado reproduzivel foi encontrado"
2669
2670msgctxt "#90359"
2671msgid "Add to TorrServer"
2672msgstr "Adicionar ao TorrServer"
2673
2674msgctxt "#90360"
2675msgid "Added to TorrServer"
2676msgstr "Adicionado ao TorrServer"
2677
2678msgctxt "#90361"
2679msgid "Could not resolve torrent details"
2680msgstr "Nao foi possivel resolver os detalhes do torrent"
2681
2682msgctxt "#90362"
2683msgid "Added to %s cloud"
2684msgstr "Adicionado a nuvem do %s"
2685
2686msgctxt "#90363"
2687msgid "Choose Debrid Service"
2688msgstr "Escolher servico Debrid"
2689
2690msgctxt "#90364"
2691msgid "No supported debrid service is enabled"
2692msgstr "Nenhum servico Debrid compativel esta habilitado"
2693
2694msgctxt "#90365"
2695msgid "Download to Debrid"
2696msgstr "Transferir para Debrid"
2697
2698msgctxt "#90366"
2699msgid "Include Unknown quality"
2700msgstr "Incluir qualidade desconhecida"
2701
2702msgctxt "#90367"
2703msgid "Includes releases where video quality could not be identified"
2704msgstr "Inclui releases onde a qualidade do video nao pode ser identificada"
2705
2706msgctxt "#90368"
2707msgid "Search by Keyword"
2708msgstr "Pesquisar por palavra-chave"
2709
2710msgctxt "#90369"
2711msgid "Latest Movies"
2712msgstr "Últimos filmes"
2713
2714msgctxt "#90370"
2715msgid "Latest Series"
2716msgstr "Últimas séries"
2717
2718msgctxt "#90371"
2719msgid "Raw Files"
2720msgstr "Arquivos brutos"
2721
2722msgctxt "#90372"
2723msgid "MDblist"
2724msgstr "MDblist"
2725
2726msgctxt "#90373"
2727msgid "Active Downloads"
2728msgstr "Downloads ativos"
2729
2730msgctxt "#90374"
2731msgid "Cancel Download"
2732msgstr "Cancelar download"
2733
2734msgctxt "#90375"
2735msgid "Delete File"
2736msgstr "Excluir arquivo"
2737
2738msgctxt "#90376"
2739msgid "Search Collections"
2740msgstr "Pesquisar coleções"
2741
2742msgctxt "#90377"
2743msgid "Popular Collections"
2744msgstr "Coleções populares"
2745
2746msgctxt "#90378"
2747msgid "Top Rated Collections"
2748msgstr "Coleções mais bem avaliadas"
2749
2750msgctxt "#90379"
2751msgid "Recommendations"
2752msgstr "Recomendações"
2753
2754msgctxt "#90380"
2755msgid "Similar"
2756msgstr "Semelhantes"
2757
2758msgctxt "#90381"
2759msgid "Keyword: %s"
2760msgstr "Palavra-chave: %s"
2761
2762msgctxt "#90410"
2763msgid "Error modifying Trakt Watchlist"
2764msgstr "Erro ao modificar a Watchlist do Trakt"
2765
2766msgctxt "#90411"
2767msgid "Marked as Watched on Trakt"
2768msgstr "Marcado como Visto no Trakt"
2769
2770msgctxt "#90412"
2771msgid "Error modifying Trakt Watched status"
2772msgstr "Erro ao modificar o estado de Visto no Trakt"
2773
2774msgctxt "#90413"
2775msgid "Play"
2776msgstr "Reproduzir"
2777
2778msgctxt "#90414"
2779msgid "Trailer"
2780msgstr "Trailer"
2781
2782msgctxt "#90415"
2783msgid "Watched"
2784msgstr "Visto"
2785
2786msgctxt "#90416"
2787msgid "Refresh"
2788msgstr "Atualizar"
2789
2790msgctxt "#90418"
2791msgid "No credits found for this ID"
2792msgstr "Nenhum crédito encontrado para este ID"
2793
2794msgctxt "#90419"
2795msgid "Credited People"
2796msgstr "Pessoas Creditadas"
2797
2798msgctxt "#90420"
2799msgid "Person Details"
2800msgstr "Detalhes da Pessoa"
2801
2802msgctxt "#90421"
2803msgid "Person not found"
2804msgstr "Pessoa não encontrada"
2805
2806msgctxt "#90422"
2807msgid "Jackgram not activated"
2808msgstr "Jackgram não ativado."
2809
2810msgctxt "#90423"
2811msgid "Not yet implemented"
2812msgstr "Ainda não implementado"
2813
2814msgctxt "#90424"
2815msgid "Unsupported debrid type"
2816msgstr "Tipo de debrid não suportado"
2817
2818msgctxt "#90425"
2819msgid "Easynews credentials required"
2820msgstr "Credenciais do Easynews obrigatórias"
2821
2822msgctxt "#90426"
2823msgid "No Corrupt or Missing Databases"
2824msgstr "Nenhum banco de dados corrompido ou ausente"
2825
2826msgctxt "#90427"
2827msgid "Otaku (script.otaku.mappings) not found"
2828msgstr "Otaku (script.otaku.mappings) não encontrado"
2829
2830msgctxt "#90431"
2831msgid "No items found in this list"
2832msgstr "Nenhum item encontrado nesta lista"
2833
2834msgctxt "#90432"
2835msgid "Databases Rebuilt"
2836msgstr "Bancos de dados reconstruídos"
2837
2838msgctxt "#90433"
2839msgid "[B]Following Databases Rebuilt:[/B][CR][CR]%s"
2840msgstr "[B]Os seguintes bancos de dados foram reconstruídos:[/B][CR][CR]%s"
2841
2842msgctxt "#90434"
2843msgid "Added to Trakt watchlist"
2844msgstr "Adicionado à watchlist do Trakt"
2845
2846msgctxt "#90435"
2847msgid "Failed to add to Trakt watchlist"
2848msgstr "Falha ao adicionar à watchlist do Trakt"
2849
2850msgctxt "#90436"
2851msgid "Removed from Trakt watchlist"
2852msgstr "Removido da watchlist do Trakt"
2853
2854msgctxt "#90437"
2855msgid "Failed to remove from Trakt watchlist"
2856msgstr "Falha ao remover da watchlist do Trakt"
2857
2858msgctxt "#90438"
2859msgid "Failed to mark as watched on Trakt"
2860msgstr "Falha ao marcar como assistido no Trakt"
2861
2862msgctxt "#90439"
2863msgid "Marked as unwatched on Trakt"
2864msgstr "Marcado como não assistido no Trakt"
2865
2866msgctxt "#90440"
2867msgid "Failed to mark as unwatched on Trakt"
2868msgstr "Falha ao marcar como não assistido no Trakt"
2869
2870msgctxt "#90441"
2871msgid "Added to Trakt collection"
2872msgstr "Adicionado à coleção do Trakt"
2873
2874msgctxt "#90442"
2875msgid "Removed from Trakt collection"
2876msgstr "Removido da coleção do Trakt"
2877
2878msgctxt "#90443"
2879msgid "Failed to add to Trakt collection"
2880msgstr "Falha ao adicionar à coleção do Trakt"
2881
2882msgctxt "#90444"
2883msgid "Failed to remove from Trakt collection"
2884msgstr "Falha ao remover da coleção do Trakt"
2885
2886msgctxt "#90445"
2887msgid "Added to Trakt favorites"
2888msgstr "Adicionado aos favoritos do Trakt"
2889
2890msgctxt "#90446"
2891msgid "Removed from Trakt favorites"
2892msgstr "Removido dos favoritos do Trakt"
2893
2894msgctxt "#90447"
2895msgid "Failed to add to Trakt favorites"
2896msgstr "Falha ao adicionar aos favoritos do Trakt"
2897
2898msgctxt "#90448"
2899msgid "Failed to remove from Trakt favorites"
2900msgstr "Falha ao remover dos favoritos do Trakt"
2901
2902msgctxt "#90449"
2903msgid "Created Trakt list"
2904msgstr "Lista do Trakt criada"
2905
2906msgctxt "#90450"
2907msgid "Failed to create Trakt list"
2908msgstr "Falha ao criar lista do Trakt"
2909
2910msgctxt "#90451"
2911msgid "Deleted Trakt list"
2912msgstr "Lista do Trakt excluída"
2913
2914msgctxt "#90452"
2915msgid "Failed to delete Trakt list"
2916msgstr "Falha ao excluir lista do Trakt"
2917
2918msgctxt "#90453"
2919msgid "Liked Trakt list"
2920msgstr "Curtiu a lista do Trakt"
2921
2922msgctxt "#90454"
2923msgid "Failed to like Trakt list"
2924msgstr "Falha ao curtir a lista do Trakt"
2925
2926msgctxt "#90455"
2927msgid "Unliked Trakt list"
2928msgstr "Curtir removido da lista do Trakt"
2929
2930msgctxt "#90456"
2931msgid "Failed to unlike Trakt list"
2932msgstr "Falha ao remover curtida da lista do Trakt"
2933
2934msgctxt "#90457"
2935msgid "Added to Trakt list"
2936msgstr "Adicionado à lista do Trakt"
2937
2938msgctxt "#90459"
2939msgid "Failed to add to Trakt list"
2940msgstr "Falha ao adicionar à lista do Trakt"
2941
2942msgctxt "#90461"
2943msgid "No Trakt lists found"
2944msgstr "Nenhuma lista do Trakt encontrada"
2945
2946msgctxt "#90462"
2947msgid "Item not found in Trakt collection"
2948msgstr "Item não encontrado na coleção do Trakt"
2949
2950msgctxt "#90463"
2951msgid "Trakt could not match this item for collection"
2952msgstr "O Trakt não conseguiu associar este item à coleção"
2953
2954msgctxt "#90464"
2955msgid "No Trakt collection changes were applied"
2956msgstr "Nenhuma alteração na coleção do Trakt foi aplicada"
2957
2958msgctxt "#90465"
2959msgid "Could not build Trakt collection payload for show"
2960msgstr "Não foi possível montar a payload da coleção do Trakt para a série"
2961
2962msgctxt "#90466"
2963msgid "Delete this Trakt list?"
2964msgstr "Excluir esta lista do Trakt?"
2965
2966msgctxt "#90510"
2967msgid "No meta available"
2968msgstr "Sem metadados disponíveis"
2969
2970msgctxt "#90511"
2971msgid "No content available for"
2972msgstr "Sem conteúdo disponível para"
2973
2974msgctxt "#90512"
2975msgid "Season"
2976msgstr "Temporada"
2977
2978msgctxt "#90513"
2979msgid "No episodes available"
2980msgstr "Sem episódios disponíveis"
2981
2982msgctxt "#90514"
2983msgid "No videos available"
2984msgstr "Sem vídeos disponíveis"
2985
2986msgctxt "#90515"
2987msgid "Next Page"
2988msgstr "Próxima página"
2989
2990msgctxt "#90516"
2991msgid "No metas available"
2992msgstr "Sem metadados disponíveis"
2993
2994msgctxt "#90517"
2995msgid "No seasons available"
2996msgstr "Sem temporadas disponíveis"
2997
2998msgctxt "#90518"
2999msgid "Invalid query"
3000msgstr "Consulta inválida"
3001
3002msgctxt "#90519"
3003msgid "No lists found"
3004msgstr "Nenhuma lista encontrada"
3005
3006msgctxt "#90520"
3007msgid "No user lists found"
3008msgstr "Nenhuma lista de usuário encontrada"
3009
3010msgctxt "#90521"
3011msgid "No top lists found"
3012msgstr "Nenhuma lista principal encontrada"
3013
3014msgctxt "#90522"
3015msgid "Custom Addon"
3016msgstr "Addon personalizado"
3017
3018msgctxt "#90523"
3019msgid "Enter the custom Stremio addon URL"
3020msgstr "Digite a URL do addon personalizado do Stremio"
3021
3022msgctxt "#90524"
3023msgid "No URL provided."
3024msgstr "Nenhuma URL fornecida."
3025
3026msgctxt "#90525"
3027msgid "Failed to fetch manifest: %s"
3028msgstr "Falha ao buscar o manifesto: %s"
3029
3030msgctxt "#90526"
3031msgid "Manifest missing 'id' or 'name'."
3032msgstr "O manifesto nao possui 'id' ou 'name'."
3033
3034msgctxt "#90527"
3035msgid "Custom Stremio addon added successfully!"
3036msgstr "Addon personalizado do Stremio adicionado com sucesso!"
3037
3038msgctxt "#90528"
3039msgid "Addon does not provide 'stream' or 'catalog' resources."
3040msgstr "O addon nao fornece recursos 'stream' ou 'catalog'."
3041
3042msgctxt "#90529"
3043msgid "This addon is already added to your list."
3044msgstr "Este addon ja foi adicionado a sua lista."
3045
3046msgctxt "#90530"
3047msgid "Failed to add custom addon: %s"
3048msgstr "Falha ao adicionar addon personalizado: %s"
3049
3050msgctxt "#90531"
3051msgid "Remove Addon"
3052msgstr "Remover addon"
3053
3054msgctxt "#90532"
3055msgid "No custom Stremio addons to remove."
3056msgstr "Nao ha addons personalizados do Stremio para remover."
3057
3058msgctxt "#90533"
3059msgid "Remove Stremio Addons"
3060msgstr "Remover addons do Stremio"
3061
3062msgctxt "#90534"
3063msgid "Selected addon(s) removed."
3064msgstr "Addon(s) selecionado(s) removido(s)."
3065
3066msgctxt "#90535"
3067msgid "Untitled List"
3068msgstr "Lista sem titulo"
3069
3070msgctxt "#90536"
3071msgid "Removed from Trakt list"
3072msgstr "Removido da lista do Trakt"
3073
3074msgctxt "#90537"
3075msgid "Failed to remove from Trakt list"
3076msgstr "Falha ao remover da lista do Trakt"
3077
3078msgctxt "#90538"
3079msgid "Resume from %.1f%%"
3080msgstr "Retomar de %.1f%%"
3081
3082msgctxt "#90539"
3083msgid "Start from Beginning"
3084msgstr "Comecar do inicio"
3085
3086msgctxt "#90540"
3087msgid "Authorize Debrid Services"
3088msgstr "Autorizar servicos Debrid"
3089
3090msgctxt "#90541"
3091msgid "Navigate to: [B]%s[/B]"
3092msgstr "Acesse: [B]%s[/B]"
3093
3094msgctxt "#90542"
3095msgid "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]"
3096msgstr "Digite o seguinte codigo: [COLOR orangered][B]%s[/B][/COLOR]"
3097
3098msgctxt "#90543"
3099msgid "Premiumize Auth"
3100msgstr "Autenticacao Premiumize"
3101
3102msgctxt "#90544"
3103msgid "Success"
3104msgstr "Sucesso"
3105
3106msgctxt "#90545"
3107msgid "Authentication completed."
3108msgstr "Autenticacao concluida."
3109
3110msgctxt "#90546"
3111msgid "Error:"
3112msgstr "Erro:"
3113
3114msgctxt "#90547"
3115msgid "Error: %s"
3116msgstr "Erro: %s"
3117
3118msgctxt "#90548"
3119msgid "Auth Error"
3120msgstr "Erro de autenticacao"
3121
3122msgctxt "#90549"
3123msgid "Torbox authentication successful."
3124msgstr "Autenticacao do Torbox concluida com sucesso."
3125
3126msgctxt "#90554"
3127msgid "Please wait..."
3128msgstr "Por favor, aguarde..."
3129
3130msgctxt "#90555"
3131msgid "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3132msgstr "Acesse:\n[COLOR cyan]%s[/COLOR]\nDigite o codigo: [COLOR seagreen][B]%s[/B][/COLOR]"
3133
3134msgctxt "#90556"
3135msgid "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3136msgstr "Acesse:\n[COLOR cyan]%s[/COLOR]\n\nDigite o codigo: [COLOR seagreen][B]%s[/B][/COLOR]"
3137
3138msgctxt "#90557"
3139msgid "Pastebin Link:\n[COLOR cyan]%s[/COLOR]"
3140msgstr "Link do Pastebin:\n[COLOR cyan]%s[/COLOR]"
3141
3142msgctxt "#90670"
3143msgid "Local URL:\n[COLOR cyan]%s[/COLOR]"
3144msgstr "URL local:\n[COLOR cyan]%s[/COLOR]"
3145
3146msgctxt "#90558"
3147msgid "QR Code Authentication"
3148msgstr "Autenticacao por codigo QR"
3149
3150msgctxt "#90559"
3151msgid "Kodi 20 or above required. Please update Kodi to use this addon."
3152msgstr "E necessario Kodi 20 ou superior. Atualize o Kodi para usar este addon."
3153
3154msgctxt "#90560"
3155msgid "Failed to start web server. Port may be in use."
3156msgstr "Falha ao iniciar o servidor web. A porta pode estar em uso."
3157
3158msgctxt "#90561"
3159msgid "Authentification Removed."
3160msgstr "Autenticacao removida."
3161
3162msgctxt "#90562"
3163msgid "WebDAV Error"
3164msgstr "Erro de WebDAV"
3165
3166msgctxt "#90563"
3167msgid "Trakt Authorization"
3168msgstr "Autorizacao do Trakt"
3169
3170msgctxt "#90564"
3171msgid "Do you want to continue transfer in background?"
3172msgstr "Deseja continuar a transferencia em segundo plano?"
3173
3174msgctxt "#90565"
3175msgid "Cloud Transfer"
3176msgstr "Transferencia para a nuvem"
3177
3178msgctxt "#90566"
3179msgid "Kodi Logs"
3180msgstr "Logs do Kodi"
3181
3182msgctxt "#90567"
3183msgid "Show Logs"
3184msgstr "Mostrar logs"
3185
3186msgctxt "#90568"
3187msgid "Export to paste.kodi.tv"
3188msgstr "Exportar para paste.kodi.tv"
3189
3190msgctxt "#90569"
3191msgid "Kodi Logs Exported"
3192msgstr "Logs do Kodi exportados"
3193
3194msgctxt "#90570"
3195msgid "Providers Selection Dialog"
3196msgstr "Dialogo de selecao de provedores"
3197
3198msgctxt "#90571"
3199msgid "Your current Providers are: \n%s\n\nDo you want to change?"
3200msgstr "Seus provedores atuais sao: \n%s\n\nDeseja alterá-los?"
3201
3202msgctxt "#90572"
3203msgid "Select Providers"
3204msgstr "Selecionar provedores"
3205
3206msgctxt "#90573"
3207msgid "Selection Dialog"
3208msgstr "Dialogo de selecao"
3209
3210msgctxt "#90574"
3211msgid "Successfully selected: %s"
3212msgstr "Selecionado com sucesso: %s"
3213
3214msgctxt "#90575"
3215msgid "Selection"
3216msgstr "Selecao"
3217
3218msgctxt "#90576"
3219msgid "No providers selected"
3220msgstr "Nenhum provedor selecionado"
3221
3222msgctxt "#90577"
3223msgid "Changelog"
3224msgstr "Registro de alteracoes"
3225
3226msgctxt "#90578"
3227msgid "[B]Unable to check for updates[/B]"
3228msgstr "[B]Nao foi possivel verificar atualizacoes[/B]"
3229
3230msgctxt "#90579"
3231msgid "[B]No update available[/B]"
3232msgstr "[B]Nenhuma atualizacao disponivel[/B]"
3233
3234msgctxt "#90580"
3235msgid "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]"
3236msgstr "Instalado: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]"
3237
3238msgctxt "#90581"
3239msgid "[B]Update available. Do you want to update?[/B]"
3240msgstr "[B]Atualizacao disponivel. Deseja atualizar?[/B]"
3241
3242msgctxt "#90582"
3243msgid "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]"
3244msgstr "[B]Atualizacao disponivel (v%s).[/B] [I]Verifique as configuracoes para atualizar.[/I]"
3245
3246msgctxt "#90583"
3247msgid "Error fetching versions: %s"
3248msgstr "Erro ao obter versoes: %s"
3249
3250msgctxt "#90584"
3251msgid "No older/different versions found."
3252msgstr "Nenhuma versao anterior ou diferente encontrada."
3253
3254msgctxt "#90585"
3255msgid "Select Version to Downgrade"
3256msgstr "Selecione a versao para downgrade"
3257
3258msgctxt "#90586"
3259msgid "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?"
3260msgstr "Tem certeza de que deseja fazer downgrade ou reinstalar a versao [B]%s[/B]?"
3261
3262msgctxt "#90587"
3263msgid "[B]Downloading update...[/B]"
3264msgstr "[B]Baixando atualizacao...[/B]"
3265
3266msgctxt "#90588"
3267msgid "Error: Unable to download update."
3268msgstr "Erro: nao foi possivel baixar a atualizacao."
3269
3270msgctxt "#90589"
3271msgid "Error saving update file: %s"
3272msgstr "Erro ao salvar o arquivo de atualizacao: %s"
3273
3274msgctxt "#90590"
3275msgid "Error extracting update. Please install manually."
3276msgstr "Erro ao extrair a atualizacao. Instale manualmente."
3277
3278msgctxt "#90591"
3279msgid "Do you want to view the changelog for the new version?"
3280msgstr "Deseja ver o registro de alteracoes da nova versao?"
3281
3282msgctxt "#90592"
3283msgid "New Release v%s - Changelog"
3284msgstr "Nova versao v%s - Registro de alteracoes"
3285
3286msgctxt "#90593"
3287msgid "[B]Update complete.[/B]"
3288msgstr "[B]Atualizacao concluida.[/B]"
3289
3290msgctxt "#90594"
3291msgid "Real Debrid Auth"
3292msgstr "Autenticacao do Real Debrid"
3293
3294msgctxt "#90595"
3295msgid "Torbox Auth"
3296msgstr "Autenticacao do Torbox"
3297
3298msgctxt "#90596"
3299msgid "Debrider Auth"
3300msgstr "Autenticacao do Debrider"
3301
3302msgctxt "#90597"
3303msgid "Download Error"
3304msgstr "Erro de download"
3305
3306msgctxt "#90598"
3307msgid "Failed to download text: %s"
3308msgstr "Falha ao baixar o texto: %s"
3309
3310msgctxt "#90599"
3311msgid "General Error"
3312msgstr "Erro geral"
3313
3314msgctxt "#90600"
3315msgid "Could not display text file."
3316msgstr "Nao foi possivel exibir o arquivo de texto."
3317
3318msgctxt "#90601"
3319msgid "Failed to export logs."
3320msgstr "Falha ao exportar os logs."
3321
3322msgctxt "#90602"
3323msgid "Error reading log: %s"
3324msgstr "Erro ao ler o log: %s"
3325
3326msgctxt "#90603"
3327msgid "Kodi log file not found."
3328msgstr "Arquivo de log do Kodi nao encontrado."
3329
3330msgctxt "#90604"
3331msgid "%s Auth"
3332msgstr "Autenticacao de %s"
3333
3334msgctxt "#90605"
3335msgid "Ok"
3336msgstr "Ok"
3337
3338msgctxt "#90606"
3339msgid "No"
3340msgstr "Nao"
3341
3342msgctxt "#90607"
3343msgid "Pinging addons for availability..."
3344msgstr "Verificando disponibilidade dos addons..."
3345
3346msgctxt "#90608"
3347msgid "Pinging addons... %d/%d"
3348msgstr "Verificando addons... %d/%d"
3349
3350msgctxt "#90609"
3351msgid "Ping Results"
3352msgstr "Resultados do ping"
3353
3354msgctxt "#90610"
3355msgid "Reachable: %d\nUnreachable: %d"
3356msgstr "Acessiveis: %d\nInacessiveis: %d"
3357
3358msgctxt "#90611"
3359msgid "Select Category to Filter"
3360msgstr "Selecione a categoria para filtrar"
3361
3362msgctxt "#90612"
3363msgid "Stremio Add-ons Import"
3364msgstr "Importacao de addons do Stremio"
3365
3366msgctxt "#90613"
3367msgid "To import your add-ons, please log in with your Stremio email and password."
3368msgstr "Para importar seus addons, faca login com seu email e senha do Stremio."
3369
3370msgctxt "#90614"
3371msgid "Enter your Email"
3372msgstr "Digite seu email"
3373
3374msgctxt "#90615"
3375msgid "Enter your Password"
3376msgstr "Digite sua senha"
3377
3378msgctxt "#90616"
3379msgid "Login Failed"
3380msgstr "Falha no login"
3381
3382msgctxt "#90617"
3383msgid "Failed to login: %s"
3384msgstr "Falha ao fazer login: %s"
3385
3386msgctxt "#90618"
3387msgid "Add-ons Import Failed"
3388msgstr "Falha na importacao de addons"
3389
3390msgctxt "#90619"
3391msgid "Please try again later and report the issue if the problem persists. For more details, check the log file."
3392msgstr "Tente novamente mais tarde e relate o problema se ele persistir. Para mais detalhes, consulte o arquivo de log."
3393
3394msgctxt "#90620"
3395msgid "Addons Imported"
3396msgstr "Addons importados"
3397
3398msgctxt "#90621"
3399msgid "Successfully imported addons from your account."
3400msgstr "Os addons da sua conta foram importados com sucesso."
3401
3402msgctxt "#90622"
3403msgid "Update Stremio Addons"
3404msgstr "Atualizar addons do Stremio"
3405
3406msgctxt "#90623"
3407msgid "Do you want to update the Addons from your account?"
3408msgstr "Deseja atualizar os addons da sua conta?"
3409
3410msgctxt "#90624"
3411msgid "Log Out from Stremio"
3412msgstr "Sair do Stremio"
3413
3414msgctxt "#90625"
3415msgid "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."
3416msgstr "Tem certeza de que deseja sair? Voce pode continuar usando o Stremio sem fazer login, mas suas configuracoes serao redefinidas para o padrao."
3417
3418msgctxt "#90626"
3419msgid "Log Out"
3420msgstr "Sair"
3421
3422msgctxt "#90627"
3423msgid "Use Existing"
3424msgstr "Usar existentes"
3425
3426msgctxt "#90628"
3427msgid "Download New"
3428msgstr "Baixar novos"
3429
3430msgctxt "#90629"
3431msgid "Playback Failed"
3432msgstr "Falha na reproducao"
3433
3434msgctxt "#90630"
3435msgid "The video could not be played.\nDo you want to retry?"
3436msgstr "O video nao pode ser reproduzido.\nDeseja tentar novamente?"
3437
3438msgctxt "#90631"
3439msgid "Retry"
3440msgstr "Tentar novamente"
3441
3442msgctxt "#90632"
3443msgid "Information"
3444msgstr "Informacao"
3445
3446msgctxt "#90633"
3447msgid "Review"
3448msgstr "Resenha"
3449
3450msgctxt "#90634"
3451msgid "Comment"
3452msgstr "Comentario"
3453
3454msgctxt "#90635"
3455msgid "Trivia"
3456msgstr "Trivia"
3457
3458msgctxt "#90636"
3459msgid "Goof"
3460msgstr "Erro"
3461
3462msgctxt "#90637"
3463msgid "Parental Guide"
3464msgstr "Guia parental"
3465
3466msgctxt "#90638"
3467msgid "Converting Magnet...\n\n%s"
3468msgstr "Convertendo magnet...\n\n%s"
3469
3470msgctxt "#90639"
3471msgid "Real Debrid Error."
3472msgstr "Erro do Real Debrid."
3473
3474msgctxt "#90640"
3475msgid "File already cached"
3476msgstr "Arquivo ja em cache"
3477
3478msgctxt "#90641"
3479msgid "Saving file to the Real Debrid Cloud"
3480msgstr "Salvando arquivo na nuvem do Real Debrid"
3481
3482msgctxt "#90642"
3483msgid "File cached"
3484msgstr "Arquivo em cache"
3485
3486msgctxt "#90643"
3487msgid "Saving File to the Real Debrid Cloud...\n%s\n\n"
3488msgstr "Salvando arquivo na nuvem do Real Debrid...\n%s\n\n"
3489
3490msgctxt "#90644"
3491msgid "Downloading %s GB @ %s mbps from %s peers, %s %% completed"
3492msgstr "Baixando %s GB a %s mbps de %s peers, %s %% concluido"
3493
3494msgctxt "#90645"
3495msgid "Trakt"
3496msgstr "Trakt"
3497
3498msgctxt "#90651"
3499msgid "Easynews Account Info"
3500msgstr "Informacoes da conta Easynews"
3501
3502msgctxt "#90652"
3503msgid "DeepL API Error"
3504msgstr "Erro da API do DeepL"
3505
3506msgctxt "#90653"
3507msgid "Download"
3508msgstr "Download"
3509
3510msgctxt "#90654"
3511msgid "Failed to start download: %s"
3512msgstr "Falha ao iniciar o download: %s"
3513
3514msgctxt "#90655"
3515msgid "Modify Search"
3516msgstr "Modificar busca"
3517
3518msgctxt "#90656"
3519msgid "Torbox"
3520msgstr "Torbox"
3521
3522msgctxt "#90657"
3523msgid "Easy-Debrid"
3524msgstr "Easy-Debrid"
3525
3526msgctxt "#90658"
3527msgid "All-Debrid"
3528msgstr "All-Debrid"
3529
3530msgctxt "#90659"
3531msgid "Real-Debrid"
3532msgstr "Real-Debrid"
3533
3534msgctxt "#90660"
3535msgid "Getting results from providers..."
3536msgstr "Obtendo resultados dos provedores..."
3537
3538msgctxt "#90661"
3539msgid "Jacktook [COLOR FFFF6B00]%s[/COLOR]"
3540msgstr "Jacktook [COLOR FFFF6B00]%s[/COLOR]"
3541
3542msgctxt "#90662"
3543msgid "Downloads Viewer"
3544msgstr "Visualizador de downloads"
3545
3546msgctxt "#90663"
3547msgid "WebDAV Text Viewer"
3548msgstr "Visualizador de texto WebDAV"
3549
3550msgctxt "#90664"
3551msgid "Selected Item"
3552msgstr "Item selecionado"
3553
3554msgctxt "#90665"
3555msgid "Subtitle No. %d"
3556msgstr "Legenda n.o %d"
3557
3558msgctxt "#90666"
3559msgid "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?"
3560msgstr "Uso gratuito do DeepL disponivel.\nCaracteres restantes na cota gratuita: %s\nCaracteres para traduzir: %s\n\nDeseja continuar com a traducao?"
3561
3562msgctxt "#90667"
3563msgid "Free DeepL Usage"
3564msgstr "Uso gratuito do DeepL"
3565
3566msgctxt "#90668"
3567msgid "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?"
3568msgstr "Total de caracteres para traduzir: %s\nCusto estimado: $%.2f USD\n\nDeseja continuar com a traducao?"
3569
3570msgctxt "#90669"
3571msgid "Translation Cost"
3572msgstr "Custo da traducao"
3573
3574msgctxt "#90672"
3575msgid "Play Trailer"
3576msgstr "Reproduzir trailer"
3577
3578msgctxt "#90673"
3579msgid "Trailer unavailable"
3580msgstr "Trailer indisponivel"
3581
3582msgctxt "#90690"
3583msgid "Clear All Items"
3584msgstr "Clear All Items"
3585
3586msgctxt "#90691"
3587msgid "Remove all items from this library?"
3588msgstr "Remove all items from this library?"
3589
3590msgctxt "#90692"
3591msgid "Library cleared"
3592msgstr "Library cleared"
3593
3594msgctxt "#90693"
3595msgid "No items to remove"
3596msgstr "No items to remove"
3597
3598msgctxt "#90694"
3599msgid "Added to %s cloud but still downloading. Please wait before playing."
3600msgstr "Adicionado à nuvem do %s mas ainda baixando. Por favor aguarde antes de reproduzir."
3601
3602msgctxt "#90695"
3603msgid "Torrent Not Ready"
3604msgstr "Torrent Não Pronto"
3605
3606msgctxt "#90696"
3607msgid "This torrent is still downloading in %s and is not ready yet."
3608msgstr "Este torrent ainda está baixando no %s e não está pronto ainda."
3609
3610msgctxt "#90697"
3611msgid "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?"
3612msgstr "Deseja reproduzir usando o cliente torrent (pode ser mais lento) ou cancelar e esperar o debrid terminar?"
3613
3614msgctxt "#90698"
3615msgid "Play with Torrent"
3616msgstr "Reproduzir com Torrent"
3617
3618msgctxt "#90699"
3619msgid "Cancel & Wait"
3620msgstr "Cancelar e Esperar"
3621
3622msgctxt "#90742"
3623msgid "Addon Settings"
3624msgstr "Configurações do addon"
3625
3626msgctxt "#90743"
3627msgid "Set Views"
3628msgstr "Definir visualizações"
3629
3630msgctxt "#90744"
3631msgid "Upload Subtitle"
3632msgstr "Enviar legendas"
3633
3634msgctxt "#90745"
3635msgid "Select Subtitle File"
3636msgstr "Selecionar arquivo de legendas"
3637
3638msgctxt "#90746"
3639msgid "Local File"
3640msgstr "Arquivo local"
3641
3642msgctxt "#90747"
3643msgid "Upload from Browser"
3644msgstr "Enviar do navegador"
3645
3646msgctxt "#90748"
3647msgid "Upload subtitle from your browser"
3648msgstr "Enviar legenda do seu navegador"
3649
3650msgctxt "#90749"
3651msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file"
3652msgstr "Escaneie o código QR ou abra a URL (%s) no seu navegador para enviar um arquivo de legendas"
3653
3654msgctxt "#90750"
3655msgid "Search Title Language"
3656msgstr "Idioma do título de busca"
3657
3658msgctxt "#90751"
3659msgid "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches."
3660msgstr "Controla como as buscas por título de Easynews/Jackett/Prowlarr são priorizadas nas buscas de contexto."
3661
3662msgctxt "#90752"
3663msgid "English first"
3664msgstr "Inglês primeiro"
3665
3666msgctxt "#90753"
3667msgid "English only"
3668msgstr "Somente inglês"
3669
3670msgctxt "#90754"
3671msgid "Localized first"
3672msgstr "Localizado primeiro"
plugin.video.jacktook/resources/language/Portuguese/strings.po +3673 -0
……@@ -0,0 +1,3673 @@
1# Kodi Media Center language file
2# Addon Name: Jacktook
3# Addon id: plugin.video.jacktook
4# Addon Provider: Sam-Max
5#
6msgid ""
7msgstr ""
8"Report-Msgid-Bugs-To: \n"
9"POT-Creation-Date: 2023-09-19 19:53+0200\n"
10"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
11"Language: pt\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
18msgctxt "#30000"
19msgid "Players"
20msgstr "Players"
21
22msgctxt "#30001"
23msgid "Clients"
24msgstr "Clientes:"
25
26msgctxt "#30002"
27msgid "Torrent Client"
28msgstr "Cliente"
29
30msgctxt "#30020"
31msgid "Indexers"
32msgstr "Indexadores"
33
34msgctxt "#30021"
35msgid "Indexer"
36msgstr "Indexador"
37
38msgctxt "#30022"
39msgid "Jackett Configuration:"
40msgstr "Configuração do Jackett:"
41
42msgctxt "#30023"
43msgid "Client Key"
44msgstr "Chave de API do Jackett"
45
46msgctxt "#30025"
47msgid "Host"
48msgstr "Host do Jackett"
49
50msgctxt "#30026"
51msgid "Description length"
52msgstr "Tamanho da descrição"
53
54msgctxt "#30027"
55msgid "Insecured connection"
56msgstr "Conexão insegura"
57
58msgctxt "#30028"
59msgid "Sort by"
60msgstr "Ordenar por"
61
62msgctxt "#30029"
63msgid "Total Results"
64msgstr "Total de Resultados"
65
66msgctxt "#30030"
67msgid "Timeout"
68msgstr "Tempo limite"
69
70msgctxt "#30031"
71msgid "Timeout"
72msgstr "Tempo limite"
73
74msgctxt "#30050"
75msgid "Prowlarr Configuration:"
76msgstr "Configuração do Prowlarr:"
77
78msgctxt "#30051"
79msgid "Client Key"
80msgstr "Chave de API do Prowlarr"
81
82msgctxt "#30052"
83msgid "Host"
84msgstr "Host do Prowlarr"
85
86msgctxt "#30053"
87msgid "Indexers-Ids"
88msgstr "IDs dos Indexadores "
89
90msgctxt "#30054"
91msgid "Anime IndexersIds"
92msgstr "IDs dos Indexadores para Anime"
93
94msgctxt "#30100"
95msgid "TMDB"
96msgstr "TMDB"
97
98msgctxt "#30102"
99msgid "TMDB Configuration"
100msgstr "Configuração do TMDB:"
101
102msgctxt "#30103"
103msgid "Client Key"
104msgstr "Chave de API do TMDB"
105
106msgctxt "#30104"
107msgid "Clear tmdb cache"
108msgstr "Limpar cache do TMDB"
109
110msgctxt "#30105"
111msgid "Cache expiration hours"
112msgstr "Expiração do cache (Horas)"
113
114msgctxt "#30106"
115msgid "Clear Cache"
116msgstr "Limpar o cache"
117
118msgctxt "#30107"
119msgid "Enable Cache"
120msgstr "Ativar cache"
121
122msgctxt "#30140"
123msgid "AniList"
124msgstr "AniList"
125
126msgctxt "#30142"
127msgid "AniList Configuration:"
128msgstr "Configuração do AniList:"
129
130msgctxt "#30143"
131msgid "Client ID"
132msgstr "ID do cliente"
133
134msgctxt "#30144"
135msgid "Client Secret"
136msgstr "Segredo do cliente"
137
138msgctxt "#30150"
139msgid "Services"
140msgstr "Services"
141
142msgctxt "#30152"
143msgid "Real Debrid Configuration"
144msgstr "Configuração do Real Debrid"
145
146msgctxt "#30154"
147msgid "Token"
148msgstr "Token:"
149
150msgctxt "#30200"
151msgid "Fanart.TV"
152msgstr "Fanart.TV"
153
154msgctxt "#30202"
155msgid "Fanart.TV Configuration:"
156msgstr "Configuração do Fanart.TV:"
157
158msgctxt "#30203"
159msgid "Client ID"
160msgstr "ID do cliente"
161
162msgctxt "#30221"
163msgid "No Client Key provided for"
164msgstr "Nenhuma chave de API fornecida para"
165
166msgctxt "#30223"
167msgid "You need to configure"
168msgstr "Você precisa configurar"
169
170msgctxt "#30228"
171msgid "The request to Torrentio failed."
172msgstr "A requisição para o Torrentio falhou."
173
174msgctxt "#30229"
175msgid "The request to Jackett failed."
176msgstr "A requisição para o Jackett falhou."
177
178msgctxt "#30230"
179msgid "The request to Prowlarr failed."
180msgstr "A requisição para o Prowlarr falhou."
181
182msgctxt "#30231"
183msgid "The request to Elhosted failed."
184msgstr "A requisição para o Elhosted falhou."
185
186msgctxt "#30232"
187msgid "The request to Jackgram failed."
188msgstr "A requisição para o Jackgram falhou"
189
190msgctxt "#30233"
191msgid "The request to Mediafusion failed."
192msgstr "A requisição para o Mediafusion falhou"
193
194msgctxt "#30234"
195msgid "The request to Peerflix failed."
196msgstr "A requisição para o Peerflix falhou"
197
198msgctxt "#30235"
199msgid "Stremio"
200msgstr "Stremio"
201
202msgctxt "#30236"
203msgid "Stremio add-ons"
204msgstr "Add-ons do Stremio"
205
206msgctxt "#30237"
207msgid "Import your config"
208msgstr "Importar as suas definições"
209
210msgctxt "#30238"
211msgid "Manage add-ons"
212msgstr "Gerir add-ons"
213
214msgctxt "#30240"
215msgid "TMDB cache cleared"
216msgstr "Cache do TMDB limpo"
217
218msgctxt "#30241"
219msgid "Search on TMDB:"
220msgstr "Pesquisar no TMDB:"
221
222msgctxt "#30242"
223msgid "Search on AniList:"
224msgstr "Pesquisar no AniList:"
225
226msgctxt "#30243"
227msgid "Search for a keyword:"
228msgstr "Pesquisar por uma palavra-chave:"
229
230msgctxt "#30244"
231msgid "Cache cleared"
232msgstr "Cache limpo"
233
234msgctxt "#30249"
235msgid "Addon Jacktook Burst not found - (script.jacktook.burst)"
236msgstr "Addon Jacktook Burst not found - (script.jacktook.burst)"
237
238msgctxt "#30250"
239msgid "Addon Torrest not found - (plugin.video.torrest)"
240msgstr "Addon Torrest não encontrado - (plugin.video.torrest)"
241
242msgctxt "#30251"
243msgid "No sources found to play"
244msgstr "Nenhuma fonte encontrada para reproduzir"
245
246msgctxt "#30252"
247msgid "Addon Elementum not found - (plugin.video.elementum)"
248msgstr "Addon Elementum não encontrado - (plugin.video.elementum)"
249
250msgctxt "#30253"
251msgid "Addon Jacktorr not found - (plugin.video.jacktorr)"
252msgstr "Addon Jacktorr não encontrado - (plugin.video.jacktorr)"
253
254msgctxt "#30254"
255msgid "Do you want to install Elementum?"
256msgstr "Deseja instalar Elementum?"
257
258msgctxt "#30255"
259msgid "Do you want to install Jacktorr?"
260msgstr "Deseja instalar Jacktorr?"
261
262msgctxt "#30256"
263msgid "Do you want to install Torrest?"
264msgstr "Deseja instalar Torrest?"
265
266msgctxt "#30257"
267msgid "Do you want to install Jacktook Burst?"
268msgstr "Deseja instalar Jacktook Burst?"
269
270msgctxt "#30650"
271msgid "Queued"
272msgstr "Adicionado à fila"
273
274msgctxt "#30651"
275msgid "Checking"
276msgstr "Verificando"
277
278msgctxt "#30652"
279msgid "Finding"
280msgstr "Encontrando"
281
282msgctxt "#30653"
283msgid "Downloading"
284msgstr "Baixando"
285
286msgctxt "#30654"
287msgid "Finished"
288msgstr "Finalizado"
289
290msgctxt "#30655"
291msgid "Seeding"
292msgstr "Semeando"
293
294msgctxt "#30656"
295msgid "Allocating"
296msgstr "Alocando"
297
298msgctxt "#30657"
299msgid "Checking resume data"
300msgstr "Verificando dados para reproduzir"
301
302msgctxt "#30658"
303msgid "Paused"
304msgstr "Pausado"
305
306msgctxt "#30659"
307msgid "Buffering"
308msgstr "Bufferizando"
309
310msgctxt "#30660"
311msgid "Unknown"
312msgstr "Desconhecido"
313
314msgctxt "#30700"
315msgid "Buffer and play"
316msgstr "Bufferizar e reproduzir"
317
318msgctxt "#30701"
319msgid "Stop downloading"
320msgstr "Parar o download"
321
322msgctxt "#30702"
323msgid "Downloads"
324msgstr "Descarregamentos"
325
326msgctxt "#30703"
327msgid "Resume"
328msgstr "Retomar"
329
330msgctxt "#30704"
331msgid "Pause"
332msgstr "Pausar"
333
334msgctxt "#30705"
335msgid "Remove torrent"
336msgstr "Remover torrent"
337
338msgctxt "#30706"
339msgid "Remove torrent and files"
340msgstr "Remover torrent e arquivos"
341
342msgctxt "#30707"
343msgid "Torrent status"
344msgstr "Status do Torrent"
345
346msgctxt "#30708"
347msgid "Stop"
348msgstr "Parar"
349
350msgctxt "#30709"
351msgid "Drop torrent"
352msgstr "Drop torrent"
353
354msgctxt "#30710"
355msgid "Include Season Packs"
356msgstr "Incluir pacotes de temporadas"
357
358msgctxt "#30711"
359msgid "Torrent Settings"
360msgstr "Definições de Torrent"
361
362msgctxt "#30712"
363msgid "General"
364msgstr "Geral"
365
366msgctxt "#30713"
367msgid "Enable"
368msgstr "Ativar"
369
370msgctxt "#30714"
371msgid "Jacktorr"
372msgstr "Jacktorr"
373
374msgctxt "#30715"
375msgid "Torrest"
376msgstr "Torrest"
377
378msgctxt "#30716"
379msgid "Elementum"
380msgstr "Elementum"
381
382msgctxt "#30717"
383msgid "General"
384msgstr "Geral"
385
386msgctxt "#30718"
387msgid "Auto-Play"
388msgstr "Reprodução automática"
389
390msgctxt "#30719"
391msgid "Enable Next Episode Dialog"
392msgstr "Ativar diálogo de próximo episódio"
393
394msgctxt "#30720"
395msgid "Seconds left before show Next Episode Dialog"
396msgstr "Segundos restantes antes de mostrar diálogo de próximo episódio"
397
398msgctxt "#30721"
399msgid "Cache Enabled"
400msgstr "Cache ativado"
401
402msgctxt "#30722"
403msgid "Cache Expiration (hours)"
404msgstr "Expiração do cache (horas)"
405
406msgctxt "#30723"
407msgid "Cache"
408msgstr "Cache"
409
410msgctxt "#30724"
411msgid "Update"
412msgstr "Atualizar"
413
414msgctxt "#30725"
415msgid "Indexers Configuration"
416msgstr "Indexers Configuração"
417
418msgctxt "#30726"
419msgid "Sort"
420msgstr "Ordenar"
421
422msgctxt "#30727"
423msgid "Quality"
424msgstr "Qualidade"
425
426msgctxt "#30938"
427msgid "Silent Resume"
428msgstr "Retomar Silenciosamente"
429
430msgctxt "#30939"
431msgid "Auto-resume from last position without asking"
432msgstr "Retomar automaticamente da última posição sem perguntar"
433
434msgctxt "#30728"
435msgid "Seeds"
436msgstr "Sementes"
437
438msgctxt "#30729"
439msgid "Size"
440msgstr "Tamanho"
441
442msgctxt "#30730"
443msgid "Cached"
444msgstr "Em cache"
445
446msgctxt "#30731"
447msgid "Date"
448msgstr "Data"
449
450msgctxt "#30732"
451msgid "None"
452msgstr "Nenhum"
453
454msgctxt "#30733"
455msgid "Torrentio Configuration"
456msgstr "Torrentio Configuração"
457
458msgctxt "#30734"
459msgid "Enable"
460msgstr "Ativar"
461
462msgctxt "#30736"
463msgid "Subtitle Language"
464msgstr "Idioma das legendas"
465
466msgctxt "#30737"
467msgid "GB"
468msgstr "GB"
469
470msgctxt "#30738"
471msgid "JP"
472msgstr "JP"
473
474msgctxt "#30739"
475msgid "RU"
476msgstr "RU"
477
478msgctxt "#30740"
479msgid "IT"
480msgstr "IT"
481
482msgctxt "#30741"
483msgid "PT"
484msgstr "PT"
485
486msgctxt "#30742"
487msgid "ES"
488msgstr "ES"
489
490msgctxt "#30743"
491msgid "MX"
492msgstr "MX"
493
494msgctxt "#30744"
495msgid "KR"
496msgstr "KR"
497
498msgctxt "#30745"
499msgid "CN"
500msgstr "CN"
501
502msgctxt "#30746"
503msgid "TW"
504msgstr "TW"
505
506msgctxt "#30747"
507msgid "FR"
508msgstr "FR"
509
510msgctxt "#30748"
511msgid "DE"
512msgstr "DE"
513
514msgctxt "#30749"
515msgid "NL"
516msgstr "NL"
517
518msgctxt "#30750"
519msgid "IN"
520msgstr "IN"
521
522msgctxt "#30751"
523msgid "PL"
524msgstr "PL"
525
526msgctxt "#30752"
527msgid "LT"
528msgstr "LT"
529
530msgctxt "#30753"
531msgid "CZ"
532msgstr "CZ"
533
534msgctxt "#30754"
535msgid "SK"
536msgstr "SK"
537
538msgctxt "#30755"
539msgid "SI"
540msgstr "SI"
541
542msgctxt "#30756"
543msgid "HU"
544msgstr "HU"
545
546msgctxt "#30757"
547msgid "RO"
548msgstr "RO"
549
550msgctxt "#30758"
551msgid "HR"
552msgstr "HR"
553
554msgctxt "#30759"
555msgid "UA"
556msgstr "UA"
557
558msgctxt "#30760"
559msgid "GR"
560msgstr "GR"
561
562msgctxt "#30761"
563msgid "DK"
564msgstr "DK"
565
566msgctxt "#30762"
567msgid "FI"
568msgstr "FI"
569
570msgctxt "#30763"
571msgid "SE"
572msgstr "SE"
573
574msgctxt "#30764"
575msgid "NO"
576msgstr "NO"
577
578msgctxt "#30765"
579msgid "TR"
580msgstr "TR"
581
582msgctxt "#30766"
583msgid "Torrentio Providers"
584msgstr "Provedores Torrentio"
585
586msgctxt "#30767"
587msgid "Jackgram Configuration"
588msgstr "Jackgram Configuração"
589
590msgctxt "#30768"
591msgid "Host"
592msgstr "Host"
593
594msgctxt "#30769"
595msgid "Elfhosted/KnightCrawler Configuration"
596msgstr "Elfhosted/KnightCrawler Configuração"
597
598msgctxt "#30770"
599msgid "Language"
600msgstr "Idioma"
601
602msgctxt "#30771"
603msgid "Zilean Configuration"
604msgstr "Zilean Configuração"
605
606msgctxt "#30772"
607msgid "Zilean Host"
608msgstr "Host Zilean"
609
610msgctxt "#30774"
611msgid "Zilean Timeout"
612msgstr "Zilean Tempo limite"
613
614msgctxt "#30775"
615msgid "Jackett Configuration"
616msgstr "Jackett Configuração"
617
618msgctxt "#30776"
619msgid "Prowlarr Configuration"
620msgstr "Prowlarr Configuração"
621
622msgctxt "#30777"
623msgid "MediaFusion configuration"
624msgstr "Configuração MediaFusion"
625
626msgctxt "#30779"
627msgid "Manifest Url"
628msgstr "URL do Manifesto"
629
630msgctxt "#30780"
631msgid "Jacktook Burst Configuration"
632msgstr "Jacktook Burst Configuração"
633
634msgctxt "#30781"
635msgid "Burst Configuration"
636msgstr "Burst Configuração"
637
638msgctxt "#30782"
639msgid "User"
640msgstr "Utilizador"
641
642msgctxt "#30783"
643msgid "Authorize"
644msgstr "Autorizar"
645
646msgctxt "#30784"
647msgid "Remove Authorization"
648msgstr "Remover Autorização"
649
650msgctxt "#30785"
651msgid "Premiumize Configuration"
652msgstr "Premiumize Configuração"
653
654msgctxt "#30786"
655msgid "Enabled"
656msgstr "Ativado"
657
658msgctxt "#30787"
659msgid "Token"
660msgstr "Token"
661
662msgctxt "#30788"
663msgid "TorBox Configuration"
664msgstr "TorBox Configuração"
665
666msgctxt "#30789"
667msgid "Debrider Configuration"
668msgstr "Debrider Configuração"
669
670msgctxt "#30790"
671msgid "Uncached"
672msgstr "Não em cache"
673
674msgctxt "#30791"
675msgid "Show uncached torrents"
676msgstr "Mostrar torrents não em cache"
677
678msgctxt "#30792"
679msgid "Use Kodi Language"
680msgstr "Usar idioma do Kodi"
681
682msgctxt "#30793"
683msgid "Trakt"
684msgstr "Trakt"
685
686msgctxt "#30794"
687msgid "Trakt Configuration"
688msgstr "Trakt Configuração"
689
690msgctxt "#30795"
691msgid "Expires"
692msgstr "Expira"
693
694msgctxt "#30796"
695msgid "Refresh"
696msgstr "Atualizar"
697
698msgctxt "#30797"
699msgid "Token"
700msgstr "Token"
701
702msgctxt "#30798"
703msgid "Trakt Client"
704msgstr "Cliente Trakt"
705
706msgctxt "#30800"
707msgid "Trakt Secret"
708msgstr "Segredo Trakt"
709
710msgctxt "#30802"
711msgid "Log out"
712msgstr "Sair"
713
714msgctxt "#30804"
715msgid "Menus"
716msgstr "Menus"
717
718msgctxt "#30805"
719msgid "Show Telegram Menu"
720msgstr "Mostrar menu Telegram"
721
722msgctxt "#30806"
723msgid "Clear cache when update"
724msgstr "Limpar cache na atualização"
725
726msgctxt "#30807"
727msgid "Peerfix Configuration"
728msgstr "Peerfix Configuração"
729
730msgctxt "#30894"
731msgid "Show Changelog"
732msgstr "Mostrar registo de alterações"
733
734msgctxt "#30895"
735msgid "View the latest changes and updates to the addon."
736msgstr "Ver as últimas alterações e atualizações do addon."
737
738msgctxt "#30896"
739msgid "Calendar"
740msgstr "Calendário"
741
742msgctxt "#30897"
743msgid "Use broadcast day adjustment"
744msgstr "Usar ajuste de dia de transmissão"
745
746msgctxt "#30898"
747msgid "Adjust broadcast day based on manual offset"
748msgstr "Ajustar o dia da transmissão com base no desvio manual"
749
750msgctxt "#30899"
751msgid "Broadcast Offset (Hours)"
752msgstr "Broadcast Offset (Hours)"
753
754msgctxt "#30900"
755msgid "Manual hour adjustment for broadcast day simulation"
756msgstr "Ajuste manual da hora para simulação do dia de transmissão"
757
758msgctxt "#30905"
759msgid "Trakt Calendar Previous Days"
760msgstr "Dias passados do calendário Trakt"
761
762msgctxt "#30906"
763msgid "Number of days in the past to show in the Trakt calendar."
764msgstr "Número de dias passados a mostrar no calendário Trakt."
765
766msgctxt "#30907"
767msgid "Trakt Calendar Future Days"
768msgstr "Dias futuros do calendario Trakt"
769
770msgctxt "#30908"
771msgid "Number of days in the future to show in the Trakt calendar."
772msgstr "Número de dias futuros a mostrar no calendário Trakt."
773
774msgctxt "#30909"
775msgid "Show All Shows in Calendar"
776msgstr "Mostrar todas as séries no calendário"
777
778msgctxt "#30918"
779msgid "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead."
780msgstr "{provider} não pode reproduzir diretamente releases compactados (.rar/.zip). Escolha uma fonte com arquivos de vídeo."
781
782msgctxt "#30910"
783msgid "Show all airing shows instead of just your watchlist/collection."
784msgstr "Mostra todas as séries em exibição em vez de apenas as da sua lista/coleção."
785
786msgctxt "#30911"
787msgid "Your Torbox account username/email."
788msgstr "Nome de utilizador/e-mail da sua conta Torbox."
789
790msgctxt "#30912"
791msgid "Authorize add-on to access Torbox account."
792msgstr "Autorizar o add-on a aceder à conta Torbox."
793
794msgctxt "#30913"
795msgid "Revoke Torbox account access."
796msgstr "Revogar o acesso à conta Torbox."
797
798msgctxt "#30914"
799msgid "Trakt Sync Interval (minutes)"
800msgstr "Intervalo de sincronizacao do Trakt (minutos)"
801
802msgctxt "#30915"
803msgid "How often Jacktook checks Trakt for remote activity changes while Kodi is running."
804msgstr "Com que frequencia o Jacktook verifica alteracoes remotas de atividade no Trakt enquanto o Kodi estiver em execucao."
805
806msgctxt "#30916"
807msgid "Account Info"
808msgstr "Informacoes da conta Trakt"
809
810msgctxt "#30917"
811msgid "View your Trakt account profile and library stats."
812msgstr "Veja o perfil da sua conta Trakt e as estatisticas da biblioteca."
813
814msgctxt "#30924"
815msgid "Trakt List Name"
816msgstr "Nome da lista Trakt"
817
818msgctxt "#30925"
819msgid "Trakt List Description"
820msgstr "Descricao da lista Trakt"
821
822msgctxt "#30926"
823msgid "Create Trakt List"
824msgstr "Criar lista Trakt"
825
826msgctxt "#30927"
827msgid "Choose Trakt List"
828msgstr "Escolher lista Trakt"
829
830msgctxt "#30936"
831msgid "Enable or disable Trakt scrobbling and resume playback from the last tracked position."
832msgstr "Ativa ou desativa o scrobbling do Trakt e retoma a reproducao a partir da ultima posicao registada."
833
834msgctxt "#30937"
835msgid "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view."
836msgstr "Limpa os dados em cache do Trakt e obriga o Jacktook a obter resultados novos do Trakt na proxima vez que abrir uma vista do Trakt."
837
838msgctxt "#40000"
839msgid "Arabic (United Arab Emirates)"
840msgstr "Árabe (Emirados Árabes Unidos)"
841
842msgctxt "#40001"
843msgid "Arabic (Saudi Arabia)"
844msgstr "Árabe (Arábia Saudita)"
845
846msgctxt "#40002"
847msgid "Belarusian (Belarus)"
848msgstr "Belarusian (Belarus)"
849
850msgctxt "#40003"
851msgid "Bulgarian (Bulgaria)"
852msgstr "Búlgaro (Bulgaria)"
853
854msgctxt "#40004"
855msgid "Bengali (Bangladesh)"
856msgstr "Bengali (Bangladesh)"
857
858msgctxt "#40005"
859msgid "Catalan (Spain)"
860msgstr "Catalão (Espanha)"
861
862msgctxt "#40006"
863msgid "Chamorro (Guam)"
864msgstr "Chamorro (Guam)"
865
866msgctxt "#40007"
867msgid "Czech (Czech Republic)"
868msgstr "Checo (República Checa)"
869
870msgctxt "#40008"
871msgid "Danish (Denmark)"
872msgstr "Dinamarquês (Dinamarca)"
873
874msgctxt "#40009"
875msgid "German (Austria)"
876msgstr "Alemão (Austria)"
877
878msgctxt "#40010"
879msgid "German (Switzerland)"
880msgstr "Alemão (Suíça)"
881
882msgctxt "#40011"
883msgid "German (Germany)"
884msgstr "Alemão (Alemanha)"
885
886msgctxt "#40012"
887msgid "Greek (Greece)"
888msgstr "Grego (Greece)"
889
890msgctxt "#40013"
891msgid "English (Australia)"
892msgstr "Inglês (Austrália)"
893
894msgctxt "#40014"
895msgid "English (Canada)"
896msgstr "Inglês (Canadá)"
897
898msgctxt "#40015"
899msgid "English (United Kingdom)"
900msgstr "Inglês (Reino Unido)"
901
902msgctxt "#40016"
903msgid "English (Ireland)"
904msgstr "Inglês (Ireland)"
905
906msgctxt "#40017"
907msgid "English (New Zealand)"
908msgstr "Inglês (New Zealand)"
909
910msgctxt "#40018"
911msgid "English (United States of America)"
912msgstr "Inglês (United States of America)"
913
914msgctxt "#40019"
915msgid "Esperanto (None)"
916msgstr "Esperanto (None)"
917
918msgctxt "#40020"
919msgid "Spanish (Spain)"
920msgstr "Espanhol (Espanha)"
921
922msgctxt "#40021"
923msgid "Spanish (Mexico)"
924msgstr "Espanhol (México)"
925
926msgctxt "#40022"
927msgid "Estonian (Estonia)"
928msgstr "Estonian (Estonia)"
929
930msgctxt "#40023"
931msgid "Basque (Spain)"
932msgstr "Basque (Espanha)"
933
934msgctxt "#40024"
935msgid "Persian (Iran)"
936msgstr "Persian (Iran)"
937
938msgctxt "#40025"
939msgid "Finnish (Finland)"
940msgstr "Finlandês (Finland)"
941
942msgctxt "#40026"
943msgid "French (Canada)"
944msgstr "Francês (Canadá)"
945
946msgctxt "#40027"
947msgid "French (France)"
948msgstr "Francês (França)"
949
950msgctxt "#40028"
951msgid "Galician (Spain)"
952msgstr "Galician (Espanha)"
953
954msgctxt "#40029"
955msgid "Hebrew (Israel)"
956msgstr "Hebrew (Israel)"
957
958msgctxt "#40030"
959msgid "Hindi (India)"
960msgstr "Hindi (Índia)"
961
962msgctxt "#40031"
963msgid "Hungarian (Hungary)"
964msgstr "Húngaro (Hungary)"
965
966msgctxt "#40032"
967msgid "Indonesian (Indonesia)"
968msgstr "Indonésio (Indonesia)"
969
970msgctxt "#40033"
971msgid "Italian (Italy)"
972msgstr "Italiano (Itália)"
973
974msgctxt "#40034"
975msgid "Japanese (Japan)"
976msgstr "Japonês (Japão)"
977
978msgctxt "#40035"
979msgid "Georgian (Georgia)"
980msgstr "Georgian (Georgia)"
981
982msgctxt "#40036"
983msgid "Kazakh (Kazakhstan)"
984msgstr "Kazakh (Kazakhstan)"
985
986msgctxt "#40037"
987msgid "Kannada (India)"
988msgstr "Kannada (Índia)"
989
990msgctxt "#40038"
991msgid "Korean (South Korea)"
992msgstr "Coreano (Coreia do Sul)"
993
994msgctxt "#40039"
995msgid "Lithuanian (Lithuania)"
996msgstr "Lithuanian (Lithuania)"
997
998msgctxt "#40040"
999msgid "Latvian (Latvia)"
1000msgstr "Latvian (Latvia)"
1001
1002msgctxt "#40041"
1003msgid "Malayalam (India)"
1004msgstr "Malayalam (Índia)"
1005
1006msgctxt "#40042"
1007msgid "Malay (Malaysia)"
1008msgstr "Malay (Malaysia)"
1009
1010msgctxt "#40043"
1011msgid "Malay (Singapore)"
1012msgstr "Malay (Singapore)"
1013
1014msgctxt "#40044"
1015msgid "Norwegian Bokmål (Norway)"
1016msgstr "Norwegian Bokmål (Noruega)"
1017
1018msgctxt "#40045"
1019msgid "Dutch (Netherlands)"
1020msgstr "Holandês (Países Baixos)"
1021
1022msgctxt "#40046"
1023msgid "Norwegian (Norway)"
1024msgstr "Norueguês (Noruega)"
1025
1026msgctxt "#40047"
1027msgid "Polish (Poland)"
1028msgstr "Polaco (Polónia)"
1029
1030msgctxt "#40048"
1031msgid "Portuguese (Brazil)"
1032msgstr "Português (Brasil)"
1033
1034msgctxt "#40049"
1035msgid "Portuguese (Portugal)"
1036msgstr "Português (Portugal)"
1037
1038msgctxt "#40050"
1039msgid "Romanian (Romania)"
1040msgstr "Romeno (Romania)"
1041
1042msgctxt "#40051"
1043msgid "Russian (Russia)"
1044msgstr "Russo (Rússia)"
1045
1046msgctxt "#40052"
1047msgid "Sinhalese (Sri Lanka)"
1048msgstr "Sinhalese (Sri Lanka)"
1049
1050msgctxt "#40053"
1051msgid "Slovak (Slovakia)"
1052msgstr "Slovak (Slovakia)"
1053
1054msgctxt "#40054"
1055msgid "Slovenian (Slovenia)"
1056msgstr "Slovenian (Slovenia)"
1057
1058msgctxt "#40055"
1059msgid "Serbian (Serbia)"
1060msgstr "Serbian (Serbia)"
1061
1062msgctxt "#40056"
1063msgid "Swedish (Sweden)"
1064msgstr "Sueco (Suécia)"
1065
1066msgctxt "#40057"
1067msgid "Tamil (India)"
1068msgstr "Tamil (Índia)"
1069
1070msgctxt "#40058"
1071msgid "Telugu (India)"
1072msgstr "Telugu (Índia)"
1073
1074msgctxt "#40059"
1075msgid "Thai (Thailand)"
1076msgstr "Tailandês (Tailândia)"
1077
1078msgctxt "#40060"
1079msgid "Tagalog (Philippines)"
1080msgstr "Tagalog (Philippines)"
1081
1082msgctxt "#40061"
1083msgid "Turkish (Turkey)"
1084msgstr "Turco (Turkey)"
1085
1086msgctxt "#40062"
1087msgid "Ukrainian (Ukraine)"
1088msgstr "Ucraniano (Ucrânia)"
1089
1090msgctxt "#40063"
1091msgid "Vietnamese (Vietnam)"
1092msgstr "Vietnamita (Vietname)"
1093
1094msgctxt "#40064"
1095msgid "Mandarin (China)"
1096msgstr "Mandarin (China)"
1097
1098msgctxt "#40065"
1099msgid "Mandarin (Hong Kong)"
1100msgstr "Mandarin (Hong Kong)"
1101
1102msgctxt "#40066"
1103msgid "Mandarin (Taiwan)"
1104msgstr "Mandarin (Taiwan)"
1105
1106msgctxt "#40067"
1107msgid "Zulu (South Africa)"
1108msgstr "Zulu (South Africa)"
1109
1110msgctxt "#42418"
1111msgid "Language and country"
1112msgstr "Idioma e país"
1113
1114msgctxt "#42419"
1115msgid "The language and country settings used when looking up data from TMDb"
1116msgstr "As definições de idioma e país utilizadas ao procurar dados no TMDb"
1117
1118msgctxt "#42420"
1119msgid "EN"
1120msgstr "EN"
1121
1122msgctxt "#30801"
1123msgid "Zilean server address (include port if required)."
1124msgstr "Endereço do servidor Zilean (inclua a porta se necessário)."
1125
1126msgctxt "#30803"
1127msgid "Display torrents not yet cached on your debrid service."
1128msgstr "Exibir torrents ainda não armazenados em cache no seu serviço debrid."
1129
1130msgctxt "#30808"
1131msgid "Fanart.tv Client Key (for artwork)."
1132msgstr "ID do cliente da API Fanart.tv (para imagens)."
1133
1134msgctxt "#30809"
1135msgid "Improve performance by caching metadata."
1136msgstr "Melhore o desempenho armazenando metadados em cache."
1137
1138msgctxt "#30810"
1139msgid "Seconds before Jackett requests time out (e.g., 30)."
1140msgstr "Segundos antes do tempo limite das requisições do Jackett (ex.: 30)."
1141
1142msgctxt "#30811"
1143msgid "Trakt client secret from OAuth application settings."
1144msgstr "Segredo do cliente Trakt das configurações do aplicativo OAuth."
1145
1146msgctxt "#30812"
1147msgid "Limit TV show results to specific seasons/episodes."
1148msgstr "Limitar resultados de séries a temporadas/episódios específicos."
1149
1150msgctxt "#30813"
1151msgid "Hours before cached data is automatically refreshed (e.g., 24)."
1152msgstr "Horas antes dos dados em cache serem atualizados automaticamente (ex.: 24)."
1153
1154msgctxt "#30814"
1155msgid "Premiumize Client Token (get from account page)."
1156msgstr "Token da API Premiumize (obtenha na página da conta)."
1157
1158msgctxt "#30815"
1159msgid "Jackett server URL (e.g., http://localhost)."
1160msgstr "URL do servidor Jackett (ex.: http://localhost:9117)."
1161
1162msgctxt "#30816"
1163msgid "Display Telegram integration options in UI."
1164msgstr "Exibir opções de integração do Telegram na interface."
1165
1166msgctxt "#30817"
1167msgid "Your Real-Debrid account username/email."
1168msgstr "Nome de usuário/e-mail da sua conta Real-Debrid."
1169
1170msgctxt "#30818"
1171msgid "Trakt client ID from https://trakt.tv/oauth/apps."
1172msgstr "ID do cliente Trakt de https://trakt.tv/oauth/apps."
1173
1174msgctxt "#30819"
1175msgid "Connect Trakt.tv account to sync watch progress."
1176msgstr "Conecte sua conta Trakt.tv para sincronizar o progresso de visualização."
1177
1178msgctxt "#30820"
1179msgid "Seconds before Zilean requests time out (e.g., 30)."
1180msgstr "Segundos antes do tempo limite das requisições do Zilean (ex.: 30)."
1181
1182msgctxt "#30821"
1183msgid "Your Jackett Client Key (found in dashboard)."
1184msgstr "Sua chave da API Jackett (encontrada no painel)."
1185
1186msgctxt "#30822"
1187msgid "Sort results by criteria (e.g., 'date', 'title', 'size')."
1188msgstr "Classificar resultados por critérios (ex.: 'data', 'título', 'tamanho')."
1189
1190msgctxt "#30823"
1191msgid "Configure Burst mode settings and providers."
1192msgstr "Configurar ajustes e provedores do modo Burst."
1193
1194msgctxt "#30824"
1195msgid "Enable Premiumize.me cloud service."
1196msgstr "Habilitar o serviço de nuvem Premiumize.me."
1197
1198msgctxt "#30825"
1199msgid "Truncate descriptions after [X] characters (0 for full text)."
1200msgstr "Truncar descrições após [X] caracteres (0 para texto completo)."
1201
1202msgctxt "#30826"
1203msgid "Enable Prowlarr indexer management."
1204msgstr "Habilitar gerenciamento de indexadores do Prowlarr."
1205
1206msgctxt "#30827"
1207msgid "Jackgram server URL (e.g., http://localhost:9117)."
1208msgstr "URL do servidor Jackgram (ex.: http://localhost:9117)."
1209
1210msgctxt "#30828"
1211msgid "Debrider Client Key for authentication."
1212msgstr "Chave da API Debrider para autenticação."
1213
1214msgctxt "#30829"
1215msgid "Delay (in seconds) before next episode dialog appears (e.g., 10)."
1216msgstr "Atraso (em segundos) antes do diálogo do próximo episódio aparecer (ex.: 10)."
1217
1218msgctxt "#30830"
1219msgid "Specify the torrent client."
1220msgstr "Especifique o executável do cliente de torrent."
1221
1222msgctxt "#30831"
1223msgid "Enable Debrider cached torrent support."
1224msgstr "Habilitar suporte a torrents em cache do EasyDebrid."
1225
1226msgctxt "#30832"
1227msgid "Torbox Client Token (found in account settings)."
1228msgstr "Token da API Torbox (encontrado nas configurações da conta)."
1229
1230msgctxt "#30833"
1231msgid "Trakt refresh token (auto-managed)."
1232msgstr "Token de atualização do Trakt (gerenciado automaticamente)."
1233
1234msgctxt "#30834"
1235msgid "Show confirmation dialog before playing next episode."
1236msgstr "Mostrar diálogo de confirmação antes de reproduzir o próximo episódio."
1237
1238msgctxt "#30835"
1239msgid "Authorize add-on to access Real-Debrid account."
1240msgstr "Autorizar a extensão a acessar a conta Real-Debrid."
1241
1242msgctxt "#30836"
1243msgid "Enable torrent-based media content"
1244msgstr "Habilitar conteúdo de mídia via torrent."
1245
1246msgctxt "#30837"
1247msgid "Indicates if Real-Debrid account is linked."
1248msgstr "Indica se a conta Real-Debrid está vinculada."
1249
1250msgctxt "#30838"
1251msgid "Automatically play next media item when current playback ends."
1252msgstr "Reproduzir automaticamente o próximo item ao terminar a reprodução atual."
1253
1254msgctxt "#30839"
1255msgid "Check for and install the latest add-on updates."
1256msgstr "Verificar e instalar as atualizações mais recentes da extensão."
1257
1258msgctxt "#30840"
1259msgid "Comma-separated Prowlarr indexer IDs to use (e.g., 1,5,7)."
1260msgstr "IDs dos indexadores do Prowlarr a serem usados, separados por vírgula (ex.: 1,5,7)."
1261
1262msgctxt "#30841"
1263msgid "Timeout (seconds) for Prowlarr requests (e.g., 20)."
1264msgstr "Tempo limite (segundos) para requisições do Prowlarr (ex.: 20)."
1265
1266msgctxt "#30842"
1267msgid "Trakt authorization expiration date (read-only)."
1268msgstr "Data de expiração da autorização do Trakt (somente leitura)."
1269
1270msgctxt "#30843"
1271msgid "Enable Torbox.de cloud torrent integration."
1272msgstr "Habilitar integração com torrents na nuvem do Torbox.de."
1273
1274msgctxt "#30844"
1275msgid "Trakt Access Token (auto-filled after auth)."
1276msgstr "Token de acesso da API Trakt (preenchido automaticamente após autenticação)."
1277
1278msgctxt "#30845"
1279msgid "Link Premiumize.me account to add-on."
1280msgstr "Vincular conta Premiumize.me à extensão."
1281
1282msgctxt "#30846"
1283msgid "Clear cached data automatically after updating the add-on."
1284msgstr "Limpar dados em cache automaticamente após atualizar a extensão."
1285
1286msgctxt "#30847"
1287msgid "Click to delete all cached data immediately."
1288msgstr "Clique para excluir todos os dados em cache imediatamente."
1289
1290msgctxt "#30848"
1291msgid "Real-Debrid Client Token (auto-filled after auth)."
1292msgstr "Token da API Real-Debrid (preenchido automaticamente após autenticação)."
1293
1294msgctxt "#30849"
1295msgid "Enable Jackett torrent proxy support."
1296msgstr "Habilitar suporte ao proxy de torrents do Jackett."
1297
1298msgctxt "#30850"
1299msgid "Enable Jackgram integration for enhanced search."
1300msgstr "Habilitar integração do Jackgram para buscas aprimoradas."
1301
1302msgctxt "#30851"
1303msgid "Prowlarr instance URL (e.g., http://localhost)."
1304msgstr "URL da instância Prowlarr (ex.: http://localhost:9696)."
1305
1306msgctxt "#30852"
1307msgid "Maximum number of search results to display (e.g., 50)."
1308msgstr "Número máximo de resultados de busca a exibir (ex.: 50)."
1309
1310msgctxt "#30853"
1311msgid "Revoke Real-Debrid account access."
1312msgstr "Revogar acesso à conta Real-Debrid."
1313
1314msgctxt "#30854"
1315msgid "Prowlarr Client Key (found in Settings > General)."
1316msgstr "Chave da API Prowlarr (encontrada em Configurações > Geral)."
1317
1318msgctxt "#30855"
1319msgid "Trakt.tv username (auto-populated after auth)."
1320msgstr "Nome de usuário Trakt.tv (preenchido automaticamente após autenticação)."
1321
1322msgctxt "#30856"
1323msgid "Disconnect Trakt.tv account from add-on."
1324msgstr "Desconectar conta Trakt.tv da extensão."
1325
1326msgctxt "#30857"
1327msgid "Enable Zilean integration for metadata fetching."
1328msgstr "Habilitar integração do Zilean para obtenção de metadados."
1329
1330msgctxt "#30858"
1331msgid "Your Client Key (required for metadata)"
1332msgstr "Sua chave da API TMDB (necessária para metadados)."
1333
1334msgctxt "#30859"
1335msgid "Enable Burst mode for accelerated searches."
1336msgstr "Habilitar modo Burst para buscas aceleradas."
1337
1338msgctxt "#30860"
1339msgid "Others"
1340msgstr "Outros"
1341
1342msgctxt "#30861"
1343msgid "Filters"
1344msgstr "Filters"
1345
1346msgctxt "#30862"
1347msgid "Torrent (P2P) Configuration"
1348msgstr "Torrent (P2P) Configuração"
1349
1350msgctxt "#30863"
1351msgid "Specify the torrent client"
1352msgstr "Especificar o cliente de torrent"
1353
1354msgctxt "#30864"
1355msgid "Sources"
1356msgstr "Pesquisar"
1357
1358msgctxt "#30865"
1359msgid "General Filters"
1360msgstr "Filtro e ordem dos resultados"
1361
1362msgctxt "#30866"
1363msgid "Sign in to your Stremio account to access add-ons and personalized features."
1364msgstr "Faça login na sua conta do Stremio para acessar complementos e recursos personalizados."
1365
1366msgctxt "#30867"
1367msgid "Sign out of your Stremio account. This will disconnect your account but won't delete your data."
1368msgstr "Faça logout da sua conta do Stremio. Isso desconectará sua conta, mas não excluirá seus dados."
1369
1370msgctxt "#30868"
1371msgid "Log Out of Stremio"
1372msgstr "Sair do Stremio"
1373
1374msgctxt "#30869"
1375msgid "Enable Stremio add-ons integration."
1376msgstr "Habilitar a integração de complementos do Stremio."
1377
1378msgctxt "#30870"
1379msgid "Select Stream Addons"
1380msgstr "Alternar complementos"
1381
1382msgctxt "#30871"
1383msgid "Log In to Stremio"
1384msgstr "Fazer login no Stremio"
1385
1386msgctxt "#30872"
1387msgid "Enable or disable Stremio add-ons."
1388msgstr "Habilitar ou desabilitar complementos do Stremio."
1389
1390msgctxt "#30873"
1391msgid "Select language for player subtitle selection"
1392msgstr "Priorizar resultados no idioma selecionado"
1393
1394msgctxt "#30874"
1395msgid "Update Addons"
1396msgstr "Atualizar Addons"
1397
1398msgctxt "#30875"
1399msgid "Select Catalogs Addons"
1400msgstr "Selecionar Catalógos e Addons"
1401
1402msgctxt "#30876"
1403msgid "Download directory"
1404msgstr "Download directory"
1405
1406msgctxt "#30877"
1407msgid "Enable Trakt.tv Scrobbling"
1408msgstr "Ativar Scrobbling do Trakt.tv"
1409
1410msgctxt "#30878"
1411msgid "Auto select subtitle when playing"
1412msgstr "Selecionar legenda automaticamente"
1413
1414msgctxt "#30879"
1415msgid "Auto-play quality"
1416msgstr "Qualidade de reprodução automática"
1417
1418msgctxt "#30880"
1419msgid "1080p"
1420msgstr "1080p"
1421
1422msgctxt "#30881"
1423msgid "720p"
1424msgstr "720p"
1425
1426msgctxt "#30882"
1427msgid "4k"
1428msgstr "4k"
1429
1430msgctxt "#30883"
1431msgid "480p"
1432msgstr "480p"
1433
1434msgctxt "#30884"
1435msgid "Subtitles Configuration"
1436msgstr "Configuração de legendas"
1437
1438msgctxt "#30885"
1439msgid "Addon Host"
1440msgstr "Host do addon"
1441
1442msgctxt "#30886"
1443msgid "Subtitles"
1444msgstr "Legendas"
1445
1446msgctxt "#30887"
1447msgid "Primary Language"
1448msgstr "Idioma principal"
1449
1450msgctxt "#30888"
1451msgid "Secondary Language"
1452msgstr "Idioma secundário"
1453
1454msgctxt "#30889"
1455msgid "Deepl Enabled"
1456msgstr "Deepl ativado"
1457
1458msgctxt "#30890"
1459msgid "Deepl Client Key"
1460msgstr "Chave API do Deepl"
1461
1462msgctxt "#30891"
1463msgid "Translator Configuration"
1464msgstr "Configuração do tradutor"
1465
1466msgctxt "#30892"
1467msgid "Deepl Target Language"
1468msgstr "Idioma de destino do Deepl"
1469
1470msgctxt "#30893"
1471msgid "Stremio Subtitle Configuration"
1472msgstr "Configuração de legendas do Stremio"
1473
1474msgctxt "#90001"
1475msgid "Add Custom Addon"
1476msgstr "Adicionar Addon Personalizado"
1477
1478msgctxt "#90002"
1479msgid "Add a custom Stremio addon by entering its URL"
1480msgstr "Adicionar um addon Stremio personalizado inserindo o seu URL"
1481
1482msgctxt "#90003"
1483msgid "Auto-select audio when playing"
1484msgstr "Selecionar áudio automaticamente ao reproduzir"
1485
1486msgctxt "#90004"
1487msgid "Audio Language"
1488msgstr "Idioma do Áudio"
1489
1490msgctxt "#90005"
1491msgid "Select audio language for playing the video"
1492msgstr "Selecionar o idioma do áudio para reproduzir o vídeo"
1493
1494msgctxt "#90006"
1495msgid "Search"
1496msgstr "Pesquisar"
1497
1498msgctxt "#90007"
1499msgid "TV Shows"
1500msgstr "Séries"
1501
1502msgctxt "#90008"
1503msgid "Movies"
1504msgstr "Filmes"
1505
1506msgctxt "#90009"
1507msgid "Anime"
1508msgstr "Anime"
1509
1510msgctxt "#90010"
1511msgid "Live TV"
1512msgstr "TV Direta"
1513
1514msgctxt "#90011"
1515msgid "Direct Search"
1516msgstr "Pesquisa Direta"
1517
1518msgctxt "#90012"
1519msgid "Torrents"
1520msgstr "Torrents"
1521
1522msgctxt "#90013"
1523msgid "Telegram"
1524msgstr "Telegram"
1525
1526msgctxt "#90014"
1527msgid "Cloud"
1528msgstr "Nuvem"
1529
1530msgctxt "#90015"
1531msgid "Downloads"
1532msgstr "Descarregamentos"
1533
1534msgctxt "#90016"
1535msgid "Settings"
1536msgstr "Definições"
1537
1538msgctxt "#90017"
1539msgid "History"
1540msgstr "Histórico"
1541
1542msgctxt "#90018"
1543msgid "Donate"
1544msgstr "Doar"
1545
1546msgctxt "#90019"
1547msgid "Recently Watched Files"
1548msgstr "Ficheiros vistos recentemente"
1549
1550msgctxt "#90020"
1551msgid "Recently Watched Titles"
1552msgstr "Títulos vistos recentemente"
1553
1554msgctxt "#90021"
1555msgid "TV Calendar"
1556msgstr "Calendário de TV"
1557
1558msgctxt "#90022"
1559msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:"
1560msgstr "Se gosta de usar o Jacktook e quer apoiar o seu desenvolvimento, pode pagar-me um café! O seu apoio ajuda a manter o projeto ativo e permite futuras melhorias:"
1561
1562msgctxt "#90023"
1563msgid "Support Jacktook"
1564msgstr "Apoiar o Jacktook"
1565
1566msgctxt "#90024"
1567msgid "Trending Recent"
1568msgstr "Tendências recentes"
1569
1570msgctxt "#90025"
1571msgid "Genres"
1572msgstr "Géneros"
1573
1574msgctxt "#90026"
1575msgid "Calendar"
1576msgstr "Calendário"
1577
1578msgctxt "#90027"
1579msgid "Years"
1580msgstr "Anos"
1581
1582msgctxt "#90028"
1583msgid "Trending"
1584msgstr "Tendências"
1585
1586msgctxt "#90029"
1587msgid "Most Watched"
1588msgstr "Mais vistos"
1589
1590msgctxt "#90030"
1591msgid "Most Favorited"
1592msgstr "Mais favoritados"
1593
1594msgctxt "#90031"
1595msgid "Popular Lists"
1596msgstr "Listas populares"
1597
1598msgctxt "#90032"
1599msgid "Trending Lists"
1600msgstr "Listas de tendências"
1601
1602msgctxt "#90033"
1603msgid "Recomendations"
1604msgstr "Recomendações"
1605
1606msgctxt "#90034"
1607msgid "Watched History"
1608msgstr "Histórico de visualização"
1609
1610msgctxt "#90035"
1611msgid "Watchlist"
1612msgstr "Lista de desejos"
1613
1614msgctxt "#90036"
1615msgid "Top 10 BoxOffice"
1616msgstr "Top 10 Bilheteira"
1617
1618msgctxt "#90037"
1619msgid "Popular"
1620msgstr "Popular"
1621
1622msgctxt "#90038"
1623msgid "Popular Recent"
1624msgstr "Populares recentes"
1625
1626msgctxt "#90039"
1627msgid "Airing"
1628msgstr "No ar"
1629
1630msgctxt "#90040"
1631msgid "Years"
1632msgstr "Anos"
1633
1634msgctxt "#90041"
1635msgid "Genres"
1636msgstr "Géneros"
1637
1638msgctxt "#90042"
1639msgid "Top Rated"
1640msgstr "Melhores avaliados"
1641
1642msgctxt "#90043"
1643msgid "Most Watched"
1644msgstr "Mais vistos"
1645
1646msgctxt "#90044"
1647msgid "Torrentio Configuration"
1648msgstr "Torrentio Configuração"
1649
1650msgctxt "#90045"
1651msgid "Enable Torrentio Settings"
1652msgstr "Ativar definições Torrentio"
1653
1654msgctxt "#90046"
1655msgid "Enable or disable the Torrentio Configuration"
1656msgstr "Ativar ou desativar the Torrentio Configuration"
1657
1658msgctxt "#90047"
1659msgid "Select Torrentio Providers"
1660msgstr "Selecionar provedores Torrentio"
1661
1662msgctxt "#90048"
1663msgid "Choose which providers to include in the stream request."
1664msgstr "Escolha quais os provedores a incluir no pedido de stream."
1665
1666msgctxt "#90049"
1667msgid "Scrape again"
1668msgstr "Pesquisar novamente"
1669
1670msgctxt "#90050"
1671msgid "Recommendations"
1672msgstr "Recomendações"
1673
1674msgctxt "#90051"
1675msgid "Similar"
1676msgstr "Semelhante"
1677
1678msgctxt "#90052"
1679msgid "Thread Number"
1680msgstr "Número de threads"
1681
1682msgctxt "#90053"
1683msgid "Select the number of threads (1-30)"
1684msgstr "Select the number of threads (1-30)"
1685
1686msgctxt "#90054"
1687msgid "Kodi logs"
1688msgstr "Logs do Kodi"
1689
1690msgctxt "#90055"
1691msgid "Kodi old logs"
1692msgstr "Logs antigos do Kodi"
1693
1694msgctxt "#90056"
1695msgid "Displays Kodi logs to help diagnose issues."
1696msgstr "Exibe os logs do Kodi para ajudar a diagnosticar problemas."
1697
1698msgctxt "#90057"
1699msgid "Logs"
1700msgstr "Logs"
1701
1702msgctxt "#90058"
1703msgid "Filter by Size"
1704msgstr "Filtrar por Tamanho"
1705
1706msgctxt "#90059"
1707msgid "Enable Size Filter"
1708msgstr "Ativar filtro de tamanho"
1709
1710msgctxt "#90060"
1711msgid "Minimum Size (MB)"
1712msgstr "Minimum Size (MB)"
1713
1714msgctxt "#90061"
1715msgid "Maximum Size (MB)"
1716msgstr "Maximum Size (MB)"
1717
1718msgctxt "#90062"
1719msgid "Enable Cached Search"
1720msgstr "Ativar pesquisa em cache"
1721
1722msgctxt "#90063"
1723msgid "Only show cached streams from external services"
1724msgstr "Mostrar apenas streams em cache de serviços externos"
1725
1726msgctxt "#90064"
1727msgid "Popular"
1728msgstr "Popular"
1729
1730msgctxt "#90065"
1731msgid "Languages"
1732msgstr "Idiomas"
1733
1734msgctxt "#90066"
1735msgid "Networks"
1736msgstr "Redes"
1737
1738msgctxt "#90067"
1739msgid "Collections"
1740msgstr "Coleções"
1741
1742msgctxt "#90068"
1743msgid "Search for collections:"
1744msgstr "Pesquisar coleções:"
1745
1746msgctxt "#90069"
1747msgid "Main Menu"
1748msgstr "Menu Principal"
1749
1750msgctxt "#90070"
1751msgid "Last Titles"
1752msgstr "Últimos Títulos"
1753
1754msgctxt "#90071"
1755msgid "Last Files"
1756msgstr "Últimos Ficheiros"
1757
1758msgctxt "#90072"
1759msgid "Trakt Lists"
1760msgstr "Listas Trakt"
1761
1762msgctxt "#90073"
1763msgid "Metadata"
1764msgstr "Metadados"
1765
1766msgctxt "#90074"
1767msgid "Top Lists"
1768msgstr "Listas Top"
1769
1770msgctxt "#90075"
1771msgid "User Lists"
1772msgstr "Listas do Utilizador"
1773
1774msgctxt "#90076"
1775msgid "Search Lists"
1776msgstr "Pesquisar Listas"
1777
1778msgctxt "#90077"
1779msgid "MDbList Configuration"
1780msgstr "MDbList Configuração"
1781
1782msgctxt "#90078"
1783msgid "People"
1784msgstr "Pessoas"
1785
1786msgctxt "#90079"
1787msgid "Popular People"
1788msgstr "Pessoas Populares"
1789
1790msgctxt "#90080"
1791msgid "Latest People"
1792msgstr "Pessoas Recentes"
1793
1794msgctxt "#90081"
1795msgid "Search People"
1796msgstr "Pesquisar Pessoas"
1797
1798msgctxt "#90082"
1799msgid "Download subtitles"
1800msgstr "Descarregar legendas"
1801
1802msgctxt "#90083"
1803msgid "Download video"
1804msgstr "Descarregar vídeo"
1805
1806msgctxt "#90084"
1807msgid "Browse into"
1808msgstr "Explorar"
1809
1810msgctxt "#90085"
1811msgid "Include TV show Specials"
1812msgstr "Incluir Especiais de Séries de TV"
1813
1814msgctxt "#90086"
1815msgid "Airing Today"
1816msgstr "Hoje no Ar"
1817
1818msgctxt "#90099"
1819msgid "Filter by Source Type"
1820msgstr "Filtrar por Tipo de Fonte"
1821
1822msgctxt "#90100"
1823msgid "Include BluRay/UHD sources"
1824msgstr "Incluir fontes BluRay/UHD"
1825
1826msgctxt "#90105"
1827msgid "Includes releases such as: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX"
1828msgstr "Inclui lançamentos como: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX"
1829
1830msgctxt "#90101"
1831msgid "Include WEB/HD sources"
1832msgstr "Incluir fontes WEB/HD"
1833
1834msgctxt "#90106"
1835msgid "Includes releases such as: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux"
1836msgstr "Inclui lançamentos como: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux"
1837
1838msgctxt "#90102"
1839msgid "Include DVD/TV/SAT sources"
1840msgstr "Incluir fontes DVD/TV/SAT"
1841
1842msgctxt "#90107"
1843msgid "Includes releases such as: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV"
1844msgstr "Inclui lançamentos como: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV"
1845
1846msgctxt "#90103"
1847msgid "Include CAM/Screener sources"
1848msgstr "Incluir fontes CAM/Screener"
1849
1850msgctxt "#90108"
1851msgid "Includes releases such as: CAM, TeleSync, TeleCine, SCR"
1852msgstr "Inclui lançamentos como: CAM, TeleSync, TeleCine, SCR"
1853
1854msgctxt "#90104"
1855msgid "Include Unknown sources"
1856msgstr "Incluir fontes desconhecidas"
1857
1858msgctxt "#90109"
1859msgid "Includes uncategorized or unidentified sources"
1860msgstr "Inclui fontes não categorizadas ou não identificadas"
1861
1862msgctxt "#90110"
1863msgid "Do you want to clear your watched history?."
1864msgstr "Deseja limpar o seu histórido de watched history.?"
1865
1866msgctxt "#90111"
1867msgid "Do you want to clear your history?."
1868msgstr "Deseja limpar o seu histórido de history.?"
1869
1870msgctxt "#90112"
1871msgid "Clear History"
1872msgstr "Limpar Histórico"
1873
1874msgctxt "#90113"
1875msgid "Click to delete all watched history immediately."
1876msgstr "Clique para apagar todo o histórico de visualização imediatamente."
1877
1878msgctxt "#90114"
1879msgid "History cleared"
1880msgstr "Histórico apagado"
1881
1882msgctxt "#90115"
1883msgid "Source Select"
1884msgstr "Seleção de Fonte"
1885
1886msgctxt "#90116"
1887msgid "Open Settings"
1888msgstr "Abrir Definições"
1889
1890msgctxt "#90117"
1891msgid "Select Torrentio Providers"
1892msgstr "Selecionar provedores Torrentio"
1893
1894msgctxt "#90118"
1895msgid "Port"
1896msgstr "Porta"
1897
1898msgctxt "#90119"
1899msgid "Jackett port (default:9117)"
1900msgstr "Jackett port (default:9117)"
1901
1902msgctxt "#90120"
1903msgid "Prowlarr port (default:9696)"
1904msgstr "Prowlarr port (default:9696)"
1905
1906msgctxt "#90121"
1907msgid "All Debrid Configuration"
1908msgstr "All Debrid Configuração"
1909
1910msgctxt "#90122"
1911msgid "Auto download subtitles"
1912msgstr "Descarregar legendas automaticamente"
1913
1914msgctxt "#90123"
1915msgid "Stremio Timeout"
1916msgstr "Stremio Tempo limite"
1917
1918msgctxt "#90124"
1919msgid "Webdav Client Configuration"
1920msgstr "Webdav Client Configuração"
1921
1922msgctxt "#90126"
1923msgid "Host"
1924msgstr "Host"
1925
1926msgctxt "#90127"
1927msgid "Username"
1928msgstr "Nome de utilizador"
1929
1930msgctxt "#90128"
1931msgid "Password"
1932msgstr "Palavra-passe"
1933
1934msgctxt "#90129"
1935msgid "Test Connection"
1936msgstr ""
1937
1938msgctxt "#90131"
1939msgid "Webdav"
1940msgstr ""
1941
1942msgctxt "#90132"
1943msgid "Remote Path"
1944msgstr ""
1945
1946msgctxt "#90133"
1947msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)."
1948msgstr "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)"
1949
1950msgctxt "#90134"
1951msgid "The username required for authenticating with your WebDAV server"
1952msgstr ""
1953
1954msgctxt "#90135"
1955msgid "The password for the specified username to authenticate with your WebDAV server"
1956msgstr ""
1957
1958msgctxt "#90136"
1959msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)."
1960msgstr "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)"
1961
1962msgctxt "#90137"
1963msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials."
1964msgstr ""
1965
1966msgctxt "#90138"
1967msgid "Remove Custom Addon"
1968msgstr ""
1969
1970msgctxt "#90139"
1971msgid "Playback"
1972msgstr "Reprodução"
1973
1974msgctxt "#90140"
1975msgid "Super quick play"
1976msgstr ""
1977
1978msgctxt "#90141"
1979msgid "Re-ask to play last played video"
1980msgstr ""
1981
1982msgctxt "#90142"
1983msgid "Replay Dialog"
1984msgstr ""
1985
1986msgctxt "#90143"
1987msgid "Do you want to play last watched?"
1988msgstr ""
1989
1990msgctxt "#90144"
1991msgid "Could not resolve playback URL"
1992msgstr ""
1993
1994msgctxt "#90145"
1995msgid "Precaching Enabled"
1996msgstr "Pré-carregamento Ativado"
1997
1998msgctxt "#90146"
1999msgid "Enable or disable precaching of next episodes."
2000msgstr "Ativar ou desativar o pré-carregamento dos próximos episódios."
2001
2002msgctxt "#90147"
2003msgid "Precaching Episode Count"
2004msgstr "Contagem de Episódios para Pré-carregar"
2005
2006msgctxt "#90148"
2007msgid "Number of next episodes to precache."
2008msgstr "Número de próximos episódios para pré-carregar."
2009
2010msgctxt "#30901"
2011msgid "Show Only Stremio Catalogs"
2012msgstr ""
2013
2014msgctxt "#30902"
2015msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus."
2016msgstr "Oculta itens incorporados (TMDb, Trakt, etc.) e mostra apenas catálogos do Stremio nos menus."
2017
2018msgctxt "#30903"
2019msgid "Select Live TV Addons"
2020msgstr "Selecionar extras de TV Direta"
2021
2022msgctxt "#30904"
2023msgid "Choose specific Stremio addons for Live TV content"
2024msgstr "Escolher extras específicos do Stremio para conteúdo de TV Direta"
2025
2026msgctxt "#30920"
2027msgid "Clear Trakt Cache"
2028msgstr "Limpar cache do Trakt"
2029
2030msgctxt "#30921"
2031msgid "Clear TMDB Cache"
2032msgstr "Limpar cache do TMDB"
2033
2034msgctxt "#30922"
2035msgid "Clear Providers Cache"
2036msgstr "Limpar cache de Provedores"
2037
2038msgctxt "#30923"
2039msgid "Clear Settings Cache"
2040msgstr "Limpar cache de Definições"
2041
2042msgctxt "#90149"
2043msgid "Maintenance"
2044msgstr "Manutenção"
2045
2046msgctxt "#90150"
2047msgid "Stremio: General"
2048msgstr "Stremio: Geral"
2049
2050msgctxt "#90151"
2051msgid "Stremio: Account"
2052msgstr "Stremio: Conta"
2053
2054msgctxt "#90152"
2055msgid "Stremio: Addons & Catalogs"
2056msgstr "Stremio: Addons e catálogos"
2057
2058msgctxt "#90153"
2059msgid "Stremio: Torrentio"
2060msgstr "Stremio: Torrentio"
2061
2062msgctxt "#90154"
2063msgid "Trending Recent"
2064msgstr "Tendências recentes"
2065
2066msgctxt "#90155"
2067msgid "Stremio: Filters"
2068msgstr "Stremio: Filters"
2069
2070msgctxt "#32300"
2071msgid "Filter by Quality"
2072msgstr "Filtrar por Qualidade"
2073
2074msgctxt "#32301"
2075msgid "Filter by Provider"
2076msgstr ""
2077
2078msgctxt "#32302"
2079msgid "Filter by Source"
2080msgstr ""
2081
2082msgctxt "#32303"
2083msgid "Filter by Language"
2084msgstr ""
2085
2086msgctxt "#32304"
2087msgid "Filter by Torrents"
2088msgstr ""
2089
2090msgctxt "#32305"
2091msgid "Filter by Debrid"
2092msgstr "Filtrar por Debrid"
2093
2094msgctxt "#32306"
2095msgid "Reset Filter"
2096msgstr ""
2097
2098msgctxt "#30930"
2099msgid "Ping Addons"
2100msgstr "Testar ligação aos extras"
2101
2102msgctxt "#30931"
2103msgid "Check availability of addons and show only reachable ones"
2104msgstr "Verificar a disponibilidade dos extras e mostrar apenas os alcançáveis."
2105
2106msgctxt "#90164"
2107msgid "Skip Intro / Recap"
2108msgstr "Saltar Intro / Resumo"
2109
2110msgctxt "#90165"
2111msgid "Enable Skip Intro"
2112msgstr "Ativar Saltar Intro"
2113
2114msgctxt "#90166"
2115msgid "Detect intros and recaps using IntroDB and offer to skip them during playback"
2116msgstr "Detetar intros e resumos usando IntroDB e oferecer para saltar durante a reprodução"
2117
2118msgctxt "#90167"
2119msgid "Auto-skip"
2120msgstr "Salto automático"
2121
2122msgctxt "#90168"
2123msgid "Automatically skip intros and recaps without showing the button"
2124msgstr "Saltar automaticamente intros e resumos sem mostrar o botão"
2125
2126msgctxt "#90160"
2127msgid "Skip Intro"
2128msgstr "Saltar Intro"
2129
2130msgctxt "#90161"
2131msgid "Skip Recap"
2132msgstr "Saltar Resumo"
2133
2134msgctxt "#90162"
2135msgid "Manage from Phone"
2136msgstr "Gerir do telemóvel"
2137
2138msgctxt "#90163"
2139msgid "Start a local web server and show a QR code to manage Stremio sources from your phone"
2140msgstr "Iniciar um servidor web local e mostrar um código QR para gerir as fontes do Stremio a partir do seu telemóvel"
2141
2142msgctxt "#90200"
2143msgid "Continue Watching"
2144msgstr "Continuar a Ver"
2145
2146msgctxt "#90201"
2147msgid "Library"
2148msgstr "Biblioteca"
2149
2150msgctxt "#90202"
2151msgid "My Shows"
2152msgstr "Minhas Séries"
2153
2154msgctxt "#90203"
2155msgid "My Movies"
2156msgstr "Meus Filmes"
2157
2158msgctxt "#90204"
2159msgid "Remove from Library"
2160msgstr "Remover da Biblioteca"
2161
2162msgctxt "#90205"
2163msgid "Add to Library"
2164msgstr "Adicionar à Biblioteca"
2165
2166msgctxt "#90206"
2167msgid "Remove"
2168msgstr "Remover"
2169
2170msgctxt "#90207"
2171msgid "Search Movies & TV"
2172msgstr "Pesquisar Filmes e Séries"
2173
2174msgctxt "#90208"
2175msgid "Recent Searches"
2176msgstr "Pesquisas Recentes"
2177
2178msgctxt "#90209"
2179msgid "Recent Direct Searches"
2180msgstr "Pesquisas Diretas Recentes"
2181
2182msgctxt "#90210"
2183msgid "Clear All Search History"
2184msgstr "Limpar todo o histórico de pesquisa"
2185
2186msgctxt "#90211"
2187msgid "Bypass Stremio Addons Native Sorting"
2188msgstr "Ignorar a ordenação nativa dos extras do Stremio"
2189
2190msgctxt "#90212"
2191msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds."
2192msgstr ""
2193
2194msgctxt "#90213"
2195msgid "Select Bypassed Addons"
2196msgstr "Selecionar extras a ignorar"
2197
2198msgctxt "#90214"
2199msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering."
2200msgstr ""
2201
2202msgctxt "#90215"
2203msgid "Downgrade"
2204msgstr "Reverter"
2205
2206msgctxt "#90671"
2207msgid "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails."
2208msgstr "Instala uma versao publicada anterior do Jacktook. A versao atual e salva e restaurada automaticamente se o downgrade falhar."
2209
2210msgctxt "#30932"
2211msgid "Update Action"
2212msgstr ""
2213
2214msgctxt "#30933"
2215msgid "Choose how to handle background update checks."
2216msgstr ""
2217
2218msgctxt "#30934"
2219msgid "Ask"
2220msgstr ""
2221
2222msgctxt "#30935"
2223msgid "Notify"
2224msgstr ""
2225
2226msgctxt "#90216"
2227msgid "Volume Check Enabled"
2228msgstr ""
2229
2230msgctxt "#90217"
2231msgid "Automatically limits volume if it is above the threshold before playback."
2232msgstr ""
2233
2234msgctxt "#90218"
2235msgid "Volume Threshold"
2236msgstr "Limite de volume"
2237
2238msgctxt "#90219"
2239msgid "The volume percentage above which the warning triggers."
2240msgstr ""
2241
2242msgctxt "#90220"
2243msgid "High Volume Warning"
2244msgstr "Easynews"
2245
2246msgctxt "#90221"
2247msgid "High volume detected. Adjusting to %s%% for safety."
2248msgstr "Habilitar o EasyNews como fornecedor de stream direto."
2249
2250msgctxt "#90222"
2251msgid "Fanart.tv Configuration"
2252msgstr "Nome de utilizador do EasyNews."
2253
2254msgctxt "#90223"
2255msgid "Client Key"
2256msgstr "Palavra-passe do EasyNews."
2257
2258msgctxt "#90224"
2259msgid "Your personal Fanart.tv client key for higher quality assets."
2260msgstr "Tempo limite do pedido em segundos."
2261
2262msgctxt "#90225"
2263msgid "Debrid Notifications"
2264msgstr ""
2265
2266msgctxt "#90226"
2267msgid "Debrid Expiration Notification"
2268msgstr ""
2269
2270msgctxt "#90227"
2271msgid "Enable or disable notifications when your Debrid subscription is about to expire."
2272msgstr "Ativar ou desativar notifications when your Debrid subscription is about to expire."
2273
2274msgctxt "#90228"
2275msgid "Expiration Threshold (Days)"
2276msgstr "Expiration Threshold (Days)"
2277
2278msgctxt "#90229"
2279msgid "How many days before expiration to start notifying."
2280msgstr ""
2281
2282msgctxt "#90230"
2283msgid "%s expires in %d days."
2284msgstr ""
2285
2286msgctxt "#90231"
2287msgid "%s expires today!"
2288msgstr ""
2289
2290msgctxt "#90232"
2291msgid "Easynews"
2292msgstr "Easynews"
2293
2294msgctxt "#90233"
2295msgid "Enable EasyNews as a direct stream provider."
2296msgstr ""
2297
2298msgctxt "#90234"
2299msgid "Your EasyNews username."
2300msgstr ""
2301
2302msgctxt "#90235"
2303msgid "Your EasyNews password."
2304msgstr ""
2305
2306msgctxt "#90236"
2307msgid "Request timeout in seconds."
2308msgstr ""
2309
2310msgctxt "#90237"
2311msgid "Account Information"
2312msgstr "Informações da conta"
2313
2314msgctxt "#90238"
2315msgid "Search Dialog Style"
2316msgstr ""
2317
2318msgctxt "#90239"
2319msgid "Background"
2320msgstr ""
2321
2322msgctxt "#90240"
2323msgid "Detailed Status"
2324msgstr ""
2325
2326msgctxt "#90241"
2327msgid "Search Status"
2328msgstr "Status da pesquisa"
2329
2330msgctxt "#90242"
2331msgid "Cancel"
2332msgstr "Cancelar"
2333
2334msgctxt "#90243"
2335msgid "Results found: "
2336msgstr "Resultados encontrados: "
2337
2338msgctxt "#90244"
2339msgid "Pending"
2340msgstr "Pendente"
2341
2342msgctxt "#90245"
2343msgid "In Progress"
2344msgstr "Em curso"
2345
2346msgctxt "#90246"
2347msgid "Completed"
2348msgstr "Concluído"
2349
2350msgctxt "#90247"
2351msgid "Failed"
2352msgstr "Falhou"
2353
2354msgctxt "#90248"
2355msgid "Cancelled"
2356msgstr "Cancelado"
2357
2358msgctxt "#90249"
2359msgid "Your personal Jackgram API token for authentication."
2360msgstr ""
2361
2362msgctxt "#90250"
2363msgid "Subtitles Found"
2364msgstr "Legendas Encontradas"
2365
2366msgctxt "#90251"
2367msgid "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?"
2368msgstr "Ja existem legendas descarregadas para este video.\nDeseja usar as legendas existentes?"
2369
2370msgctxt "#90252"
2371msgid "No subtitles found"
2372msgstr "Nenhuma legenda encontrada"
2373
2374msgctxt "#90253"
2375msgid "No subtitles selected"
2376msgstr "Nenhuma legenda selecionada"
2377
2378msgctxt "#90254"
2379msgid "Translate Subtitles"
2380msgstr "Traduzir Legendas"
2381
2382msgctxt "#90255"
2383msgid "Do you want to also translate the selected subtitles?"
2384msgstr "Deseja tambem traduzir as legendas selecionadas?"
2385
2386msgctxt "#90256"
2387msgid "Select Subtitle to Download"
2388msgstr "Selecionar Legenda para Descarregar"
2389
2390msgctxt "#90257"
2391msgid "Subtitles Loaded"
2392msgstr "Legendas Carregadas"
2393
2394msgctxt "#90258"
2395msgid "Backup"
2396msgstr "Copia de Seguranca das Definicoes"
2397
2398msgctxt "#90259"
2399msgid "Export Backup"
2400msgstr "Exportar Copia de Seguranca"
2401
2402msgctxt "#90260"
2403msgid "Export addon settings and custom Stremio addons to a JSON backup file."
2404msgstr "Exporta as definicoes do addon e os addons personalizados do Stremio para um ficheiro JSON de copia de seguranca."
2405
2406msgctxt "#90261"
2407msgid "Restore Backup"
2408msgstr "Restaurar Copia de Seguranca"
2409
2410msgctxt "#90262"
2411msgid "Restore addon settings and replace custom Stremio addons from a JSON backup file."
2412msgstr "Restaura as definicoes do addon e substitui os addons personalizados do Stremio a partir de um ficheiro JSON de copia de seguranca."
2413
2414msgctxt "#90263"
2415msgid "Remove Sensitive Data"
2416msgstr "Remover Dados Sensiveis"
2417
2418msgctxt "#90264"
2419msgid "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups."
2420msgstr "Remove chaves API, palavras-passe, nomes de utilizador, emails, estado de autenticacao e URLs de addons personalizados do Stremio das copias exportadas."
2421
2422msgctxt "#90265"
2423msgid "Settings backup exported"
2424msgstr "Copia de seguranca exportada"
2425
2426msgctxt "#90266"
2427msgid "Settings backup restored"
2428msgstr "Copia de seguranca restaurada"
2429
2430msgctxt "#90267"
2431msgid "Settings Backup Failed"
2432msgstr "Falha na Copia de Seguranca"
2433
2434msgctxt "#90268"
2435msgid "Select export folder"
2436msgstr "Selecionar pasta de exportacao"
2437
2438msgctxt "#90269"
2439msgid "Select backup file"
2440msgstr "Selecionar ficheiro de copia"
2441
2442msgctxt "#90270"
2443msgid "Backup file is invalid."
2444msgstr "O ficheiro de copia e invalido."
2445
2446msgctxt "#90271"
2447msgid "No export folder selected."
2448msgstr "Nenhuma pasta de exportacao selecionada."
2449
2450msgctxt "#90272"
2451msgid "No backup file selected."
2452msgstr "Nenhum ficheiro de copia selecionado."
2453
2454msgctxt "#90273"
2455msgid "Unable to export settings backup."
2456msgstr "Nao foi possivel exportar a copia de seguranca."
2457
2458msgctxt "#90274"
2459msgid "Unable to restore settings backup."
2460msgstr "Nao foi possivel restaurar a copia de seguranca."
2461
2462msgctxt "#90275"
2463msgid "Restore Backup"
2464msgstr "Restaurar Copia de Seguranca"
2465
2466msgctxt "#90276"
2467msgid "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?"
2468msgstr "Isto vai substituir as definicoes atuais do addon e trocar os addons personalizados do Stremio pelos da copia de seguranca. Deseja continuar?"
2469
2470msgctxt "#90277"
2471msgid "Restore cancelled"
2472msgstr "Restauro cancelado"
2473
2474msgctxt "#90278"
2475msgid "Reset All Settings"
2476msgstr "Repor Todas as Definicoes"
2477
2478msgctxt "#90279"
2479msgid "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?"
2480msgstr "Isto vai repor todas as definicoes do addon para os valores predefinidos e remover os seus addons personalizados do Stremio. Deseja continuar?"
2481
2482msgctxt "#90280"
2483msgid "Reset cancelled"
2484msgstr "Reposicao cancelada"
2485
2486msgctxt "#90281"
2487msgid "All addon settings reset"
2488msgstr "Todas as definicoes do addon foram repostas"
2489
2490msgctxt "#90282"
2491msgid "Reset Failed"
2492msgstr "Falha ao Repor"
2493
2494msgctxt "#90283"
2495msgid "Unable to reset addon settings."
2496msgstr "Nao foi possivel repor as definicoes do addon."
2497
2498msgctxt "#90284"
2499msgid "Reset every addon setting back to its default value and clear custom Stremio addons."
2500msgstr "Repoe cada definicao do addon para o valor predefinido e limpa os addons personalizados do Stremio."
2501
2502msgctxt "#90285"
2503msgid "Factory Reset"
2504msgstr "Reposicao de Fabrica"
2505
2506msgctxt "#90286"
2507msgid "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?"
2508msgstr "Isto vai repor as definicoes, limpar as caches, remover os addons personalizados do Stremio e apagar o historico e os dados locais do addon. Deseja continuar?"
2509
2510msgctxt "#90287"
2511msgid "Factory reset cancelled"
2512msgstr "Reposicao de fabrica cancelada"
2513
2514msgctxt "#90288"
2515msgid "Factory reset completed"
2516msgstr "Reposicao de fabrica concluida"
2517
2518msgctxt "#90289"
2519msgid "Factory Reset Failed"
2520msgstr "Falha na Reposicao de Fabrica"
2521
2522msgctxt "#90290"
2523msgid "Unable to complete factory reset."
2524msgstr "Nao foi possivel concluir a reposicao de fabrica."
2525
2526msgctxt "#90291"
2527msgid "Reset settings to defaults, clear caches, and wipe local addon history/database data."
2528msgstr "Repoe as definicoes para os valores predefinidos, limpa as caches e apaga o historico e os dados locais do addon."
2529
2530msgctxt "#90292"
2531msgid "Trakt Library"
2532msgstr "Biblioteca do Trakt"
2533
2534msgctxt "#90293"
2535msgid "Trakt Discovery"
2536msgstr "Descoberta do Trakt"
2537
2538msgctxt "#90294"
2539msgid "Collection"
2540msgstr "Colecao"
2541
2542msgctxt "#90295"
2543msgid "Favorites"
2544msgstr "Favoritos"
2545
2546msgctxt "#90296"
2547msgid "Up Next"
2548msgstr "A Seguir"
2549
2550msgctxt "#90297"
2551msgid "My Lists"
2552msgstr "As Minhas Listas"
2553
2554msgctxt "#90298"
2555msgid "Liked Lists"
2556msgstr "Listas com Gostos"
2557
2558msgctxt "#90331"
2559msgid "Stremio Addons"
2560msgstr "Addons do Stremio"
2561
2562msgctxt "#90332"
2563msgid "No addons available yet. Use Log In to Stremio or Add Custom Addon first."
2564msgstr "Ainda nao ha addons disponiveis. Use Iniciar sessao no Stremio ou Adicionar Addon Personalizado primeiro."
2565
2566msgctxt "#90333"
2567msgid "Stremio Stream Addons"
2568msgstr "Addons de streams do Stremio"
2569
2570msgctxt "#90334"
2571msgid "Stremio Catalog Addons"
2572msgstr "Addons de catalogo do Stremio"
2573
2574msgctxt "#90335"
2575msgid "Stremio Live TV Addons"
2576msgstr "Addons de TV ao vivo do Stremio"
2577
2578msgctxt "#90336"
2579msgid "Refresh addons imported from your Stremio account. Custom addons remain unchanged."
2580msgstr "Atualiza os addons importados da sua conta Stremio. Os addons personalizados permanecem inalterados."
2581
2582msgctxt "#90337"
2583msgid "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first."
2584msgstr "Escolha quais addons de streams do Stremio, importados ou personalizados, deseja usar. Se ainda nao houver addons disponiveis, inicie sessao ou adicione primeiro um addon personalizado."
2585
2586msgctxt "#90338"
2587msgid "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first."
2588msgstr "Escolha quais addons de catalogo do Stremio, importados ou personalizados, deseja usar. Se ainda nao houver addons disponiveis, inicie sessao ou adicione primeiro um addon personalizado."
2589
2590msgctxt "#90339"
2591msgid "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first."
2592msgstr "Escolha quais addons de TV ao vivo do Stremio, importados ou personalizados, deseja usar. Se ainda nao houver addons disponiveis, inicie sessao ou adicione primeiro um addon personalizado."
2593
2594msgctxt "#90340"
2595msgid "Remove custom Stremio addons you previously added manually."
2596msgstr "Remova os addons personalizados do Stremio que adicionou manualmente."
2597
2598msgctxt "#90341"
2599msgid "Choose Torrent Client"
2600msgstr "Escolher cliente de torrent"
2601
2602msgctxt "#90342"
2603msgid "Clear Database Cache"
2604msgstr "Limpar cache da base de dados"
2605
2606msgctxt "#90343"
2607msgid "Clear Stremio Cache"
2608msgstr "Limpar cache do Stremio"
2609
2610msgctxt "#90344"
2611msgid "Clear Debrid Cache"
2612msgstr "Limpar cache de Debrid"
2613
2614msgctxt "#90345"
2615msgid "Clear MDBList Cache"
2616msgstr "Limpar cache do MDBList"
2617
2618msgctxt "#90346"
2619msgid "Restore backup from"
2620msgstr "Restaurar copia de seguranca de"
2621
2622msgctxt "#90347"
2623msgid "File"
2624msgstr "Arquivo"
2625
2626msgctxt "#90348"
2627msgid "URL"
2628msgstr "URL"
2629
2630msgctxt "#90349"
2631msgid "Enter backup URL"
2632msgstr "Introduza o URL da copia de seguranca"
2633
2634msgctxt "#90350"
2635msgid "No backup URL provided."
2636msgstr "Nao foi fornecido nenhum URL de copia de seguranca."
2637
2638msgctxt "#90351"
2639msgid "Unable to download settings backup."
2640msgstr "Nao foi possivel transferir a copia de seguranca."
2641
2642msgctxt "#90352"
2643msgid "Source Select Style"
2644msgstr ""
2645
2646msgctxt "#90353"
2647msgid "Modern"
2648msgstr ""
2649
2650msgctxt "#90354"
2651msgid "OLED"
2652msgstr ""
2653
2654msgctxt "#90355"
2655msgid "Cyberpunk"
2656msgstr ""
2657
2658msgctxt "#90356"
2659msgid "Forest"
2660msgstr ""
2661
2662msgctxt "#90357"
2663msgid "Gold"
2664msgstr ""
2665
2666msgctxt "#90358"
2667msgid "No playable results found"
2668msgstr "Nao foram encontrados resultados reproduziveis"
2669
2670msgctxt "#90359"
2671msgid "Add to TorrServer"
2672msgstr "Adicionar ao TorrServer"
2673
2674msgctxt "#90360"
2675msgid "Added to TorrServer"
2676msgstr "Adicionado ao TorrServer"
2677
2678msgctxt "#90361"
2679msgid "Could not resolve torrent details"
2680msgstr "Nao foi possivel resolver os detalhes do torrent"
2681
2682msgctxt "#90362"
2683msgid "Added to %s cloud"
2684msgstr "Adicionado a nuvem do %s"
2685
2686msgctxt "#90363"
2687msgid "Choose Debrid Service"
2688msgstr "Escolher servico Debrid"
2689
2690msgctxt "#90364"
2691msgid "No supported debrid service is enabled"
2692msgstr "Nenhum servico Debrid compativel esta habilitado"
2693
2694msgctxt "#90365"
2695msgid "Download to Debrid"
2696msgstr "Transferir para Debrid"
2697
2698msgctxt "#90366"
2699msgid "Include Unknown quality"
2700msgstr "Incluir qualidade desconhecida"
2701
2702msgctxt "#90367"
2703msgid "Includes releases where video quality could not be identified"
2704msgstr "Inclui releases onde a qualidade do video nao pode ser identificada"
2705
2706msgctxt "#90368"
2707msgid "Search by Keyword"
2708msgstr "Buscar por palavra-chave"
2709
2710msgctxt "#90369"
2711msgid "Latest Movies"
2712msgstr "Últimos filmes"
2713
2714msgctxt "#90370"
2715msgid "Latest Series"
2716msgstr "Últimas séries"
2717
2718msgctxt "#90371"
2719msgid "Raw Files"
2720msgstr "Arquivos brutos"
2721
2722msgctxt "#90372"
2723msgid "MDblist"
2724msgstr "MDblist"
2725
2726msgctxt "#90373"
2727msgid "Active Downloads"
2728msgstr "Downloads ativos"
2729
2730msgctxt "#90374"
2731msgid "Cancel Download"
2732msgstr "Cancelar download"
2733
2734msgctxt "#90375"
2735msgid "Delete File"
2736msgstr "Excluir arquivo"
2737
2738msgctxt "#90376"
2739msgid "Search Collections"
2740msgstr "Buscar coleções"
2741
2742msgctxt "#90377"
2743msgid "Popular Collections"
2744msgstr "Coleções populares"
2745
2746msgctxt "#90378"
2747msgid "Top Rated Collections"
2748msgstr "Coleções mais bem avaliadas"
2749
2750msgctxt "#90379"
2751msgid "Recommendations"
2752msgstr "Recomendações"
2753
2754msgctxt "#90380"
2755msgid "Similar"
2756msgstr "Semelhantes"
2757
2758msgctxt "#90381"
2759msgid "Keyword: %s"
2760msgstr "Palavra-chave: %s"
2761
2762msgctxt "#90410"
2763msgid "Error modifying Trakt Watchlist"
2764msgstr "Erro ao modificar a Watchlist do Trakt"
2765
2766msgctxt "#90411"
2767msgid "Marked as Watched on Trakt"
2768msgstr "Marcado como Visto no Trakt"
2769
2770msgctxt "#90412"
2771msgid "Error modifying Trakt Watched status"
2772msgstr "Erro ao modificar o estado de Visto no Trakt"
2773
2774msgctxt "#90413"
2775msgid "Play"
2776msgstr "Reproduzir"
2777
2778msgctxt "#90414"
2779msgid "Trailer"
2780msgstr "Trailer"
2781
2782msgctxt "#90415"
2783msgid "Watched"
2784msgstr "Visto"
2785
2786msgctxt "#90416"
2787msgid "Refresh"
2788msgstr "Atualizar"
2789
2790msgctxt "#90418"
2791msgid "No credits found for this ID"
2792msgstr "Nenhum crédito encontrado para este ID"
2793
2794msgctxt "#90419"
2795msgid "Credited People"
2796msgstr "Pessoas Creditadas"
2797
2798msgctxt "#90420"
2799msgid "Person Details"
2800msgstr "Detalhes da Pessoa"
2801
2802msgctxt "#90421"
2803msgid "Person not found"
2804msgstr "Pessoa não encontrada"
2805
2806msgctxt "#90422"
2807msgid "Jackgram not activated"
2808msgstr "Jackgram não ativado."
2809
2810msgctxt "#90423"
2811msgid "Not yet implemented"
2812msgstr "Ainda não implementado"
2813
2814msgctxt "#90424"
2815msgid "Unsupported debrid type"
2816msgstr "Tipo de debrid não suportado"
2817
2818msgctxt "#90425"
2819msgid "Easynews credentials required"
2820msgstr "Credenciais do Easynews obrigatórias"
2821
2822msgctxt "#90426"
2823msgid "No Corrupt or Missing Databases"
2824msgstr "Sem bases de dados corrompidas ou em falta"
2825
2826msgctxt "#90427"
2827msgid "Otaku (script.otaku.mappings) not found"
2828msgstr "Otaku (script.otaku.mappings) não encontrado"
2829
2830msgctxt "#90431"
2831msgid "No items found in this list"
2832msgstr "Nenhum item encontrado nesta lista"
2833
2834msgctxt "#90432"
2835msgid "Databases Rebuilt"
2836msgstr "Bases de dados reconstruídas"
2837
2838msgctxt "#90433"
2839msgid "[B]Following Databases Rebuilt:[/B][CR][CR]%s"
2840msgstr "[B]As seguintes bases de dados foram reconstruídas:[/B][CR][CR]%s"
2841
2842msgctxt "#90434"
2843msgid "Added to Trakt watchlist"
2844msgstr "Adicionado à watchlist do Trakt"
2845
2846msgctxt "#90435"
2847msgid "Failed to add to Trakt watchlist"
2848msgstr "Falha ao adicionar à watchlist do Trakt"
2849
2850msgctxt "#90436"
2851msgid "Removed from Trakt watchlist"
2852msgstr "Removido da watchlist do Trakt"
2853
2854msgctxt "#90437"
2855msgid "Failed to remove from Trakt watchlist"
2856msgstr "Falha ao remover da watchlist do Trakt"
2857
2858msgctxt "#90438"
2859msgid "Failed to mark as watched on Trakt"
2860msgstr "Falha ao marcar como visto no Trakt"
2861
2862msgctxt "#90439"
2863msgid "Marked as unwatched on Trakt"
2864msgstr "Marcado como não visto no Trakt"
2865
2866msgctxt "#90440"
2867msgid "Failed to mark as unwatched on Trakt"
2868msgstr "Falha ao marcar como não visto no Trakt"
2869
2870msgctxt "#90441"
2871msgid "Added to Trakt collection"
2872msgstr "Adicionado à coleção do Trakt"
2873
2874msgctxt "#90442"
2875msgid "Removed from Trakt collection"
2876msgstr "Removido da coleção do Trakt"
2877
2878msgctxt "#90443"
2879msgid "Failed to add to Trakt collection"
2880msgstr "Falha ao adicionar à coleção do Trakt"
2881
2882msgctxt "#90444"
2883msgid "Failed to remove from Trakt collection"
2884msgstr "Falha ao remover da coleção do Trakt"
2885
2886msgctxt "#90445"
2887msgid "Added to Trakt favorites"
2888msgstr "Adicionado aos favoritos do Trakt"
2889
2890msgctxt "#90446"
2891msgid "Removed from Trakt favorites"
2892msgstr "Removido dos favoritos do Trakt"
2893
2894msgctxt "#90447"
2895msgid "Failed to add to Trakt favorites"
2896msgstr "Falha ao adicionar aos favoritos do Trakt"
2897
2898msgctxt "#90448"
2899msgid "Failed to remove from Trakt favorites"
2900msgstr "Falha ao remover dos favoritos do Trakt"
2901
2902msgctxt "#90449"
2903msgid "Created Trakt list"
2904msgstr "Lista do Trakt criada"
2905
2906msgctxt "#90450"
2907msgid "Failed to create Trakt list"
2908msgstr "Falha ao criar lista do Trakt"
2909
2910msgctxt "#90451"
2911msgid "Deleted Trakt list"
2912msgstr "Lista do Trakt eliminada"
2913
2914msgctxt "#90452"
2915msgid "Failed to delete Trakt list"
2916msgstr "Falha ao eliminar lista do Trakt"
2917
2918msgctxt "#90453"
2919msgid "Liked Trakt list"
2920msgstr "Gosto na lista do Trakt"
2921
2922msgctxt "#90454"
2923msgid "Failed to like Trakt list"
2924msgstr "Falha ao colocar gosto na lista do Trakt"
2925
2926msgctxt "#90455"
2927msgid "Unliked Trakt list"
2928msgstr "Gosto removido da lista do Trakt"
2929
2930msgctxt "#90456"
2931msgid "Failed to unlike Trakt list"
2932msgstr "Falha ao remover gosto da lista do Trakt"
2933
2934msgctxt "#90457"
2935msgid "Added to Trakt list"
2936msgstr "Adicionado à lista do Trakt"
2937
2938msgctxt "#90459"
2939msgid "Failed to add to Trakt list"
2940msgstr "Falha ao adicionar à lista do Trakt"
2941
2942msgctxt "#90461"
2943msgid "No Trakt lists found"
2944msgstr "Nenhuma lista do Trakt encontrada"
2945
2946msgctxt "#90462"
2947msgid "Item not found in Trakt collection"
2948msgstr "Item não encontrado na coleção do Trakt"
2949
2950msgctxt "#90463"
2951msgid "Trakt could not match this item for collection"
2952msgstr "O Trakt não conseguiu corresponder este item à coleção"
2953
2954msgctxt "#90464"
2955msgid "No Trakt collection changes were applied"
2956msgstr "Nenhuma alteração à coleção do Trakt foi aplicada"
2957
2958msgctxt "#90465"
2959msgid "Could not build Trakt collection payload for show"
2960msgstr "Não foi possível criar a payload da coleção do Trakt para a série"
2961
2962msgctxt "#90466"
2963msgid "Delete this Trakt list?"
2964msgstr "Eliminar esta lista do Trakt?"
2965
2966
2967msgctxt "#90510"
2968msgid "No meta available"
2969msgstr "Sem metadados"
2970
2971msgctxt "#90511"
2972msgid "No content available for"
2973msgstr "Sem conteúdo para"
2974
2975msgctxt "#90512"
2976msgid "Season"
2977msgstr "Temporada"
2978
2979msgctxt "#90513"
2980msgid "No episodes available"
2981msgstr "Sem episódios"
2982
2983msgctxt "#90514"
2984msgid "No videos available"
2985msgstr "Sem vídeos"
2986
2987msgctxt "#90515"
2988msgid "Next Page"
2989msgstr "Próxima página"
2990
2991msgctxt "#90516"
2992msgid "No metas available"
2993msgstr "Sem metadados disponíveis"
2994
2995msgctxt "#90517"
2996msgid "No seasons available"
2997msgstr "Sem temporadas disponíveis"
2998
2999msgctxt "#90518"
3000msgid "Invalid query"
3001msgstr "Consulta inválida"
3002
3003msgctxt "#90519"
3004msgid "No lists found"
3005msgstr "Nenhuma lista encontrada"
3006
3007msgctxt "#90520"
3008msgid "No user lists found"
3009msgstr "Nenhuma lista de utilizador encontrada"
3010
3011msgctxt "#90521"
3012msgid "No top lists found"
3013msgstr "Nenhuma lista principal encontrada"
3014
3015msgctxt "#90522"
3016msgid "Custom Addon"
3017msgstr "Addon personalizado"
3018
3019msgctxt "#90523"
3020msgid "Enter the custom Stremio addon URL"
3021msgstr "Introduza o URL do addon personalizado do Stremio"
3022
3023msgctxt "#90524"
3024msgid "No URL provided."
3025msgstr "Nenhum URL fornecido."
3026
3027msgctxt "#90525"
3028msgid "Failed to fetch manifest: %s"
3029msgstr "Falha ao obter o manifesto: %s"
3030
3031msgctxt "#90526"
3032msgid "Manifest missing 'id' or 'name'."
3033msgstr "Falta 'id' ou 'name' no manifesto."
3034
3035msgctxt "#90527"
3036msgid "Custom Stremio addon added successfully!"
3037msgstr "Addon personalizado do Stremio adicionado com sucesso!"
3038
3039msgctxt "#90528"
3040msgid "Addon does not provide 'stream' or 'catalog' resources."
3041msgstr "O addon nao fornece recursos 'stream' ou 'catalog'."
3042
3043msgctxt "#90529"
3044msgid "This addon is already added to your list."
3045msgstr "Este addon ja foi adicionado a sua lista."
3046
3047msgctxt "#90530"
3048msgid "Failed to add custom addon: %s"
3049msgstr "Falha ao adicionar addon personalizado: %s"
3050
3051msgctxt "#90531"
3052msgid "Remove Addon"
3053msgstr "Remover addon"
3054
3055msgctxt "#90532"
3056msgid "No custom Stremio addons to remove."
3057msgstr "Nao ha addons personalizados do Stremio para remover."
3058
3059msgctxt "#90533"
3060msgid "Remove Stremio Addons"
3061msgstr "Remover addons do Stremio"
3062
3063msgctxt "#90534"
3064msgid "Selected addon(s) removed."
3065msgstr "Addon(s) selecionado(s) removido(s)."
3066
3067msgctxt "#90535"
3068msgid "Untitled List"
3069msgstr "Lista sem titulo"
3070
3071msgctxt "#90536"
3072msgid "Removed from Trakt list"
3073msgstr "Removido da lista do Trakt"
3074
3075msgctxt "#90537"
3076msgid "Failed to remove from Trakt list"
3077msgstr "Falha ao remover da lista do Trakt"
3078
3079msgctxt "#90538"
3080msgid "Resume from %.1f%%"
3081msgstr "Retomar a partir de %.1f%%"
3082
3083msgctxt "#90539"
3084msgid "Start from Beginning"
3085msgstr "Iniciar do principio"
3086
3087msgctxt "#90540"
3088msgid "Authorize Debrid Services"
3089msgstr "Autorizar servicos Debrid"
3090
3091msgctxt "#90541"
3092msgid "Navigate to: [B]%s[/B]"
3093msgstr "Navegue para: [B]%s[/B]"
3094
3095msgctxt "#90542"
3096msgid "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]"
3097msgstr "Introduza o seguinte codigo: [COLOR orangered][B]%s[/B][/COLOR]"
3098
3099msgctxt "#90543"
3100msgid "Premiumize Auth"
3101msgstr "Autenticacao Premiumize"
3102
3103msgctxt "#90544"
3104msgid "Success"
3105msgstr "Sucesso"
3106
3107msgctxt "#90545"
3108msgid "Authentication completed."
3109msgstr "Autenticacao concluida."
3110
3111msgctxt "#90546"
3112msgid "Error:"
3113msgstr "Erro:"
3114
3115msgctxt "#90547"
3116msgid "Error: %s"
3117msgstr "Erro: %s"
3118
3119msgctxt "#90548"
3120msgid "Auth Error"
3121msgstr "Erro de autenticacao"
3122
3123msgctxt "#90549"
3124msgid "Torbox authentication successful."
3125msgstr "Autenticacao do Torbox concluida com sucesso."
3126
3127msgctxt "#90554"
3128msgid "Please wait..."
3129msgstr "Por favor, aguarde..."
3130
3131msgctxt "#90555"
3132msgid "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3133msgstr "Vá para:\n[COLOR cyan]%s[/COLOR]\nIntroduza o codigo: [COLOR seagreen][B]%s[/B][/COLOR]"
3134
3135msgctxt "#90556"
3136msgid "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3137msgstr "Vá para:\n[COLOR cyan]%s[/COLOR]\n\nIntroduza o codigo: [COLOR seagreen][B]%s[/B][/COLOR]"
3138
3139msgctxt "#90557"
3140msgid "Pastebin Link:\n[COLOR cyan]%s[/COLOR]"
3141msgstr "Ligacao Pastebin:\n[COLOR cyan]%s[/COLOR]"
3142
3143msgctxt "#90670"
3144msgid "Local URL:\n[COLOR cyan]%s[/COLOR]"
3145msgstr "URL local:\n[COLOR cyan]%s[/COLOR]"
3146
3147msgctxt "#90558"
3148msgid "QR Code Authentication"
3149msgstr "Autenticacao por codigo QR"
3150
3151msgctxt "#90559"
3152msgid "Kodi 20 or above required. Please update Kodi to use this addon."
3153msgstr "E necessario Kodi 20 ou superior. Atualize o Kodi para usar este addon."
3154
3155msgctxt "#90560"
3156msgid "Failed to start web server. Port may be in use."
3157msgstr "Falha ao iniciar o servidor web. A porta pode estar em uso."
3158
3159msgctxt "#90561"
3160msgid "Authentification Removed."
3161msgstr "Autenticacao removida."
3162
3163msgctxt "#90562"
3164msgid "WebDAV Error"
3165msgstr "Erro WebDAV"
3166
3167msgctxt "#90563"
3168msgid "Trakt Authorization"
3169msgstr "Autorizacao Trakt"
3170
3171msgctxt "#90564"
3172msgid "Do you want to continue transfer in background?"
3173msgstr "Pretende continuar a transferencia em segundo plano?"
3174
3175msgctxt "#90565"
3176msgid "Cloud Transfer"
3177msgstr "Transferencia para a nuvem"
3178
3179msgctxt "#90566"
3180msgid "Kodi Logs"
3181msgstr "Registos do Kodi"
3182
3183msgctxt "#90567"
3184msgid "Show Logs"
3185msgstr "Mostrar registos"
3186
3187msgctxt "#90568"
3188msgid "Export to paste.kodi.tv"
3189msgstr "Exportar para paste.kodi.tv"
3190
3191msgctxt "#90569"
3192msgid "Kodi Logs Exported"
3193msgstr "Registos do Kodi exportados"
3194
3195msgctxt "#90570"
3196msgid "Providers Selection Dialog"
3197msgstr "Dialogo de selecao de fornecedores"
3198
3199msgctxt "#90571"
3200msgid "Your current Providers are: \n%s\n\nDo you want to change?"
3201msgstr "Os seus fornecedores atuais sao: \n%s\n\nPretende alterar?"
3202
3203msgctxt "#90572"
3204msgid "Select Providers"
3205msgstr "Selecionar fornecedores"
3206
3207msgctxt "#90573"
3208msgid "Selection Dialog"
3209msgstr "Dialogo de selecao"
3210
3211msgctxt "#90574"
3212msgid "Successfully selected: %s"
3213msgstr "Selecionado com sucesso: %s"
3214
3215msgctxt "#90575"
3216msgid "Selection"
3217msgstr "Selecao"
3218
3219msgctxt "#90576"
3220msgid "No providers selected"
3221msgstr "Nenhum fornecedor selecionado"
3222
3223msgctxt "#90577"
3224msgid "Changelog"
3225msgstr "Registo de alteracoes"
3226
3227msgctxt "#90578"
3228msgid "[B]Unable to check for updates[/B]"
3229msgstr "[B]Nao foi possivel verificar atualizacoes[/B]"
3230
3231msgctxt "#90579"
3232msgid "[B]No update available[/B]"
3233msgstr "[B]Nenhuma atualizacao disponivel[/B]"
3234
3235msgctxt "#90580"
3236msgid "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]"
3237msgstr "Instalado: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]"
3238
3239msgctxt "#90581"
3240msgid "[B]Update available. Do you want to update?[/B]"
3241msgstr "[B]Atualizacao disponivel. Pretende atualizar?[/B]"
3242
3243msgctxt "#90582"
3244msgid "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]"
3245msgstr "[B]Atualizacao disponivel (v%s).[/B] [I]Verifique as definicoes para atualizar.[/I]"
3246
3247msgctxt "#90583"
3248msgid "Error fetching versions: %s"
3249msgstr "Erro ao obter versoes: %s"
3250
3251msgctxt "#90584"
3252msgid "No older/different versions found."
3253msgstr "Nao foram encontradas versoes mais antigas ou diferentes."
3254
3255msgctxt "#90585"
3256msgid "Select Version to Downgrade"
3257msgstr "Selecione a versao para downgrade"
3258
3259msgctxt "#90586"
3260msgid "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?"
3261msgstr "Tem a certeza de que pretende fazer downgrade ou reinstalar a versao [B]%s[/B]?"
3262
3263msgctxt "#90587"
3264msgid "[B]Downloading update...[/B]"
3265msgstr "[B]A transferir atualizacao...[/B]"
3266
3267msgctxt "#90588"
3268msgid "Error: Unable to download update."
3269msgstr "Erro: nao foi possivel transferir a atualizacao."
3270
3271msgctxt "#90589"
3272msgid "Error saving update file: %s"
3273msgstr "Erro ao guardar o ficheiro de atualizacao: %s"
3274
3275msgctxt "#90590"
3276msgid "Error extracting update. Please install manually."
3277msgstr "Erro ao extrair a atualizacao. Instale manualmente."
3278
3279msgctxt "#90591"
3280msgid "Do you want to view the changelog for the new version?"
3281msgstr "Pretende ver o registo de alteracoes da nova versao?"
3282
3283msgctxt "#90592"
3284msgid "New Release v%s - Changelog"
3285msgstr "Nova versao v%s - Registo de alteracoes"
3286
3287msgctxt "#90593"
3288msgid "[B]Update complete.[/B]"
3289msgstr "[B]Atualizacao concluida.[/B]"
3290
3291msgctxt "#90594"
3292msgid "Real Debrid Auth"
3293msgstr "Autenticacao Real Debrid"
3294
3295msgctxt "#90595"
3296msgid "Torbox Auth"
3297msgstr "Autenticacao Torbox"
3298
3299msgctxt "#90596"
3300msgid "Debrider Auth"
3301msgstr "Autenticacao Debrider"
3302
3303msgctxt "#90597"
3304msgid "Download Error"
3305msgstr "Erro de transferencia"
3306
3307msgctxt "#90598"
3308msgid "Failed to download text: %s"
3309msgstr "Falha ao transferir o texto: %s"
3310
3311msgctxt "#90599"
3312msgid "General Error"
3313msgstr "Erro geral"
3314
3315msgctxt "#90600"
3316msgid "Could not display text file."
3317msgstr "Nao foi possivel apresentar o ficheiro de texto."
3318
3319msgctxt "#90601"
3320msgid "Failed to export logs."
3321msgstr "Falha ao exportar os registos."
3322
3323msgctxt "#90602"
3324msgid "Error reading log: %s"
3325msgstr "Erro ao ler o registo: %s"
3326
3327msgctxt "#90603"
3328msgid "Kodi log file not found."
3329msgstr "Ficheiro de registo do Kodi nao encontrado."
3330
3331msgctxt "#90604"
3332msgid "%s Auth"
3333msgstr "Autenticacao de %s"
3334
3335msgctxt "#90605"
3336msgid "Ok"
3337msgstr "Ok"
3338
3339msgctxt "#90606"
3340msgid "No"
3341msgstr "Nao"
3342
3343msgctxt "#90607"
3344msgid "Pinging addons for availability..."
3345msgstr "A verificar disponibilidade dos addons..."
3346
3347msgctxt "#90608"
3348msgid "Pinging addons... %d/%d"
3349msgstr "A verificar addons... %d/%d"
3350
3351msgctxt "#90609"
3352msgid "Ping Results"
3353msgstr "Resultados do ping"
3354
3355msgctxt "#90610"
3356msgid "Reachable: %d\nUnreachable: %d"
3357msgstr "Acessiveis: %d\nInacessiveis: %d"
3358
3359msgctxt "#90611"
3360msgid "Select Category to Filter"
3361msgstr "Selecione a categoria a filtrar"
3362
3363msgctxt "#90612"
3364msgid "Stremio Add-ons Import"
3365msgstr "Importacao de addons do Stremio"
3366
3367msgctxt "#90613"
3368msgid "To import your add-ons, please log in with your Stremio email and password."
3369msgstr "Para importar os seus addons, inicie sessao com o email e a palavra-passe do Stremio."
3370
3371msgctxt "#90614"
3372msgid "Enter your Email"
3373msgstr "Introduza o seu email"
3374
3375msgctxt "#90615"
3376msgid "Enter your Password"
3377msgstr "Introduza a sua palavra-passe"
3378
3379msgctxt "#90616"
3380msgid "Login Failed"
3381msgstr "Falha no inicio de sessao"
3382
3383msgctxt "#90617"
3384msgid "Failed to login: %s"
3385msgstr "Falha ao iniciar sessao: %s"
3386
3387msgctxt "#90618"
3388msgid "Add-ons Import Failed"
3389msgstr "Falha na importacao de addons"
3390
3391msgctxt "#90619"
3392msgid "Please try again later and report the issue if the problem persists. For more details, check the log file."
3393msgstr "Tente novamente mais tarde e reporte o problema se persistir. Para mais detalhes, consulte o ficheiro de registo."
3394
3395msgctxt "#90620"
3396msgid "Addons Imported"
3397msgstr "Addons importados"
3398
3399msgctxt "#90621"
3400msgid "Successfully imported addons from your account."
3401msgstr "Os addons da sua conta foram importados com sucesso."
3402
3403msgctxt "#90622"
3404msgid "Update Stremio Addons"
3405msgstr "Atualizar addons do Stremio"
3406
3407msgctxt "#90623"
3408msgid "Do you want to update the Addons from your account?"
3409msgstr "Pretende atualizar os addons da sua conta?"
3410
3411msgctxt "#90624"
3412msgid "Log Out from Stremio"
3413msgstr "Terminar sessao no Stremio"
3414
3415msgctxt "#90625"
3416msgid "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."
3417msgstr "Tem a certeza de que pretende terminar sessao? Pode continuar a usar o Stremio sem iniciar sessao, mas as suas definicoes serao repostas para a configuracao predefinida."
3418
3419msgctxt "#90626"
3420msgid "Log Out"
3421msgstr "Terminar sessao"
3422
3423msgctxt "#90627"
3424msgid "Use Existing"
3425msgstr "Usar existentes"
3426
3427msgctxt "#90628"
3428msgid "Download New"
3429msgstr "Transferir novos"
3430
3431msgctxt "#90629"
3432msgid "Playback Failed"
3433msgstr "Falha na reproducao"
3434
3435msgctxt "#90630"
3436msgid "The video could not be played.\nDo you want to retry?"
3437msgstr "Nao foi possivel reproduzir o video.\nPretende tentar novamente?"
3438
3439msgctxt "#90631"
3440msgid "Retry"
3441msgstr "Tentar novamente"
3442
3443msgctxt "#90632"
3444msgid "Information"
3445msgstr "Informacao"
3446
3447msgctxt "#90633"
3448msgid "Review"
3449msgstr "Critica"
3450
3451msgctxt "#90634"
3452msgid "Comment"
3453msgstr "Comentario"
3454
3455msgctxt "#90635"
3456msgid "Trivia"
3457msgstr "Curiosidades"
3458
3459msgctxt "#90636"
3460msgid "Goof"
3461msgstr "Erro"
3462
3463msgctxt "#90637"
3464msgid "Parental Guide"
3465msgstr "Guia parental"
3466
3467msgctxt "#90638"
3468msgid "Converting Magnet...\n\n%s"
3469msgstr "A converter magnet...\n\n%s"
3470
3471msgctxt "#90639"
3472msgid "Real Debrid Error."
3473msgstr "Erro do Real Debrid."
3474
3475msgctxt "#90640"
3476msgid "File already cached"
3477msgstr "Ficheiro ja em cache"
3478
3479msgctxt "#90641"
3480msgid "Saving file to the Real Debrid Cloud"
3481msgstr "A guardar ficheiro na nuvem do Real Debrid"
3482
3483msgctxt "#90642"
3484msgid "File cached"
3485msgstr "Ficheiro em cache"
3486
3487msgctxt "#90643"
3488msgid "Saving File to the Real Debrid Cloud...\n%s\n\n"
3489msgstr "A guardar ficheiro na nuvem do Real Debrid...\n%s\n\n"
3490
3491msgctxt "#90644"
3492msgid "Downloading %s GB @ %s mbps from %s peers, %s %% completed"
3493msgstr "A transferir %s GB a %s mbps de %s peers, %s %% concluido"
3494
3495msgctxt "#90645"
3496msgid "Trakt"
3497msgstr "Trakt"
3498
3499msgctxt "#90651"
3500msgid "Easynews Account Info"
3501msgstr "Informacao da conta Easynews"
3502
3503msgctxt "#90652"
3504msgid "DeepL API Error"
3505msgstr "Erro da API DeepL"
3506
3507msgctxt "#90653"
3508msgid "Download"
3509msgstr "Transferencia"
3510
3511msgctxt "#90654"
3512msgid "Failed to start download: %s"
3513msgstr "Falha ao iniciar a transferencia: %s"
3514
3515msgctxt "#90655"
3516msgid "Modify Search"
3517msgstr "Modificar pesquisa"
3518
3519msgctxt "#90656"
3520msgid "Torbox"
3521msgstr "Torbox"
3522
3523msgctxt "#90657"
3524msgid "Easy-Debrid"
3525msgstr "Easy-Debrid"
3526
3527msgctxt "#90658"
3528msgid "All-Debrid"
3529msgstr "All-Debrid"
3530
3531msgctxt "#90659"
3532msgid "Real-Debrid"
3533msgstr "Real-Debrid"
3534
3535msgctxt "#90660"
3536msgid "Getting results from providers..."
3537msgstr "A obter resultados dos fornecedores..."
3538
3539msgctxt "#90661"
3540msgid "Jacktook [COLOR FFFF6B00]%s[/COLOR]"
3541msgstr "Jacktook [COLOR FFFF6B00]%s[/COLOR]"
3542
3543msgctxt "#90662"
3544msgid "Downloads Viewer"
3545msgstr "Visualizador de transferencias"
3546
3547msgctxt "#90663"
3548msgid "WebDAV Text Viewer"
3549msgstr "Visualizador de texto WebDAV"
3550
3551msgctxt "#90664"
3552msgid "Selected Item"
3553msgstr "Item selecionado"
3554
3555msgctxt "#90665"
3556msgid "Subtitle No. %d"
3557msgstr "Legenda n.o %d"
3558
3559msgctxt "#90666"
3560msgid "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?"
3561msgstr "Utilizacao gratuita do DeepL disponivel.\nCaracteres restantes na quota gratuita: %s\nCaracteres a traduzir: %s\n\nPretende continuar com a traducao?"
3562
3563msgctxt "#90667"
3564msgid "Free DeepL Usage"
3565msgstr "Utilizacao gratuita do DeepL"
3566
3567msgctxt "#90668"
3568msgid "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?"
3569msgstr "Total de caracteres a traduzir: %s\nCusto estimado: $%.2f USD\n\nPretende continuar com a traducao?"
3570
3571msgctxt "#90669"
3572msgid "Translation Cost"
3573msgstr "Custo da traducao"
3574
3575msgctxt "#90672"
3576msgid "Play Trailer"
3577msgstr "Reproduzir trailer"
3578
3579msgctxt "#90673"
3580msgid "Trailer unavailable"
3581msgstr "Trailer indisponivel"
3582
3583msgctxt "#90690"
3584msgid "Clear All Items"
3585msgstr "Clear All Items"
3586
3587msgctxt "#90691"
3588msgid "Remove all items from this library?"
3589msgstr "Remove all items from this library?"
3590
3591msgctxt "#90692"
3592msgid "Library cleared"
3593msgstr "Library cleared"
3594
3595msgctxt "#90693"
3596msgid "No items to remove"
3597msgstr "No items to remove"
3598
3599msgctxt "#90694"
3600msgid "Added to %s cloud but still downloading. Please wait before playing."
3601msgstr "Adicionado a nuvem do %s mas ainda baixando. Por favor aguarde antes de reproduzir."
3602
3603msgctxt "#90695"
3604msgid "Torrent Not Ready"
3605msgstr "Torrent Não Pronto"
3606
3607msgctxt "#90696"
3608msgid "This torrent is still downloading in %s and is not ready yet."
3609msgstr "Este torrent ainda está baixando no %s e não está pronto ainda."
3610
3611msgctxt "#90697"
3612msgid "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?"
3613msgstr "Deseja reproduzir usando o cliente torrent (pode ser mais lento) ou cancelar e esperar o debrid terminar?"
3614
3615msgctxt "#90698"
3616msgid "Play with Torrent"
3617msgstr "Reproduzir com Torrent"
3618
3619msgctxt "#90699"
3620msgid "Cancel & Wait"
3621msgstr "Cancelar e Esperar"
3622
3623msgctxt "#90742"
3624msgid "Addon Settings"
3625msgstr "Configurações do addon"
3626
3627msgctxt "#90743"
3628msgid "Set Views"
3629msgstr "Definir visualizações"
3630
3631msgctxt "#90744"
3632msgid "Upload Subtitle"
3633msgstr "Carregar legendas"
3634
3635msgctxt "#90745"
3636msgid "Select Subtitle File"
3637msgstr "Selecionar ficheiro de legendas"
3638
3639msgctxt "#90746"
3640msgid "Local File"
3641msgstr "Ficheiro local"
3642
3643msgctxt "#90747"
3644msgid "Upload from Browser"
3645msgstr "Carregar do navegador"
3646
3647msgctxt "#90748"
3648msgid "Upload subtitle from your browser"
3649msgstr "Carregar legenda do seu navegador"
3650
3651msgctxt "#90749"
3652msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file"
3653msgstr "Digitalize o código QR ou abra o URL (%s) no seu navegador para carregar um ficheiro de legendas"
3654
3655msgctxt "#90750"
3656msgid "Search Title Language"
3657msgstr "Idioma do título de pesquisa"
3658
3659msgctxt "#90751"
3660msgid "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches."
3661msgstr "Controla como as pesquisas por título de Easynews/Jackett/Prowlarr são priorizadas nas pesquisas de contexto."
3662
3663msgctxt "#90752"
3664msgid "English first"
3665msgstr "Inglês primeiro"
3666
3667msgctxt "#90753"
3668msgid "English only"
3669msgstr "Apenas inglês"
3670
3671msgctxt "#90754"
3672msgid "Localized first"
3673msgstr "Localizado primeiro"
plugin.video.jacktook/resources/language/Russian/strings.po +3672 -0
……@@ -0,0 +1,3672 @@
1# Kodi Media Center language file
2# Addon Name: Jacktook
3# Addon id: plugin.video.jacktook
4# Addon Provider: Sam-Max
5#
6msgid ""
7msgstr ""
8"Report-Msgid-Bugs-To: \n"
9"POT-Creation-Date: 2023-09-19 19:53+0200\n"
10"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
11"Language: ru\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
18msgctxt "#30000"
19msgid "Players"
20msgstr "Плееры"
21
22msgctxt "#30001"
23msgid "Clients"
24msgstr "Клиенты"
25
26msgctxt "#30002"
27msgid "Torrent Client"
28msgstr "Торрент-клиент"
29
30msgctxt "#30020"
31msgid "Indexers"
32msgstr "Индексаторы"
33
34msgctxt "#30021"
35msgid "Indexer"
36msgstr "Индексатор"
37
38msgctxt "#30022"
39msgid "Jackett Configuration:"
40msgstr "Настройка Jackett:"
41
42msgctxt "#30023"
43msgid "Client Key"
44msgstr "Ключ клиента"
45
46msgctxt "#30025"
47msgid "Host"
48msgstr "Хост"
49
50msgctxt "#30026"
51msgid "Description length"
52msgstr "Длина описания"
53
54msgctxt "#30027"
55msgid "Insecured connection"
56msgstr "Небезопасное соединение"
57
58msgctxt "#30028"
59msgid "Sort by"
60msgstr "Сортировать по"
61
62msgctxt "#30029"
63msgid "Total Results"
64msgstr "Всего результатов"
65
66msgctxt "#30030"
67msgid "Timeout"
68msgstr "Тайм-аут"
69
70msgctxt "#30031"
71msgid "Timeout"
72msgstr "Тайм-аут"
73
74msgctxt "#30050"
75msgid "Prowlarr Configuration:"
76msgstr "Настройка Prowlarr:"
77
78msgctxt "#30051"
79msgid "Client Key"
80msgstr "Ключ клиента"
81
82msgctxt "#30052"
83msgid "Host"
84msgstr "Хост"
85
86msgctxt "#30053"
87msgid "Indexers-Ids"
88msgstr "ID индексаторов"
89
90msgctxt "#30054"
91msgid "Anime IndexersIds"
92msgstr "ID индексаторов аниме"
93
94msgctxt "#30100"
95msgid "TMDB"
96msgstr "TMDB"
97
98msgctxt "#30102"
99msgid "TMDB Configuration"
100msgstr "Настройка TMDB"
101
102msgctxt "#30103"
103msgid "Client Key"
104msgstr "Ключ клиента"
105
106msgctxt "#30104"
107msgid "Clear tmdb cache"
108msgstr "Очистить кэш TMDB"
109
110msgctxt "#30105"
111msgid "Cache expiration hours"
112msgstr "Часы истечения кэша"
113
114msgctxt "#30106"
115msgid "Clear Cache"
116msgstr "Очистить кэш"
117
118msgctxt "#30107"
119msgid "Enable Cache"
120msgstr "Включить кэш"
121
122msgctxt "#30140"
123msgid "AniList"
124msgstr "AniList"
125
126msgctxt "#30142"
127msgid "AniList Configuration:"
128msgstr "Настройка AniList:"
129
130msgctxt "#30143"
131msgid "Client ID"
132msgstr "ID клиента"
133
134msgctxt "#30144"
135msgid "Client Secret"
136msgstr "Секрет клиента"
137
138msgctxt "#30150"
139msgid "Services"
140msgstr "Сервисы"
141
142msgctxt "#30152"
143msgid "Real Debrid Configuration"
144msgstr "Настройка Real Debrid"
145
146msgctxt "#30154"
147msgid "Token"
148msgstr "Токен"
149
150msgctxt "#30200"
151msgid "Fanart.TV"
152msgstr "Fanart.TV"
153
154msgctxt "#30202"
155msgid "Fanart.TV Configuration:"
156msgstr "Настройка Fanart.TV:"
157
158msgctxt "#30203"
159msgid "Client ID"
160msgstr "ID клиента"
161
162msgctxt "#30221"
163msgid "No Client Key provided for"
164msgstr "Не предоставлен ключ клиента для"
165
166msgctxt "#30223"
167msgid "You need to configure"
168msgstr "Вам нужно настроить"
169
170msgctxt "#30228"
171msgid "The request to Torrentio failed."
172msgstr "Запрос к Torrentio не удался."
173
174msgctxt "#30229"
175msgid "The request to Jackett failed."
176msgstr "Запрос к Jackett не удался."
177
178msgctxt "#30230"
179msgid "The request to Prowlarr failed."
180msgstr "Запрос к Prowlarr не удался."
181
182msgctxt "#30231"
183msgid "The request to Elhosted failed."
184msgstr "Запрос к Elhosted не удался."
185
186msgctxt "#30232"
187msgid "The request to Jackgram failed."
188msgstr "Запрос к Jackgram не удался."
189
190msgctxt "#30233"
191msgid "The request to Mediafusion failed."
192msgstr "Запрос к Mediafusion не удался."
193
194msgctxt "#30234"
195msgid "The request to Peerflix failed."
196msgstr "Запрос к Peerflix не удался."
197
198msgctxt "#30235"
199msgid "Stremio"
200msgstr "Stremio"
201
202msgctxt "#30236"
203msgid "Stremio add-ons"
204msgstr "Аддоны Stremio"
205
206msgctxt "#30237"
207msgid "Import your config"
208msgstr "Импортировать конфигурацию"
209
210msgctxt "#30238"
211msgid "Manage add-ons"
212msgstr "Управление аддонами"
213
214msgctxt "#30240"
215msgid "TMDB cache cleared"
216msgstr "Кэш TMDB очищен"
217
218msgctxt "#30241"
219msgid "Search on TMDB:"
220msgstr "Поиск на TMDB:"
221
222msgctxt "#30242"
223msgid "Search on AniList:"
224msgstr "Поиск на AniList:"
225
226msgctxt "#30243"
227msgid "Search for a keyword:"
228msgstr "Поиск по ключевому слову:"
229
230msgctxt "#30244"
231msgid "Cache cleared"
232msgstr "Кэш очищен"
233
234msgctxt "#30249"
235msgid "Addon Jacktook Burst not found - (script.jacktook.burst)"
236msgstr "Надстройка Jacktook Burst не найдена - (script.jacktook.burst)"
237
238msgctxt "#30250"
239msgid "Addon Torrest not found - (plugin.video.torrest)"
240msgstr "Аддон Torrest не найден - (plugin.video.torrest)"
241
242msgctxt "#30251"
243msgid "No sources found to play"
244msgstr "Источники для воспроизведения не найдены"
245
246msgctxt "#30252"
247msgid "Addon Elementum not found - (plugin.video.elementum)"
248msgstr "Аддон Elementum не найден - (plugin.video.elementum)"
249
250msgctxt "#30253"
251msgid "Addon Jacktorr not found - (plugin.video.jacktorr)"
252msgstr "Аддон Jacktorr не найден - (plugin.video.jacktorr)"
253
254msgctxt "#30254"
255msgid "Do you want to install Elementum?"
256msgstr "Вы хотите установить Elementum?"
257
258msgctxt "#30255"
259msgid "Do you want to install Jacktorr?"
260msgstr "Вы хотите установить Jacktorr?"
261
262msgctxt "#30256"
263msgid "Do you want to install Torrest?"
264msgstr "Вы хотите установить Torrest?"
265
266msgctxt "#30257"
267msgid "Do you want to install Jacktook Burst?"
268msgstr "Вы хотите установить Jacktook Burst?"
269
270msgctxt "#30650"
271msgid "Queued"
272msgstr "В очереди"
273
274msgctxt "#30651"
275msgid "Checking"
276msgstr "Проверка"
277
278msgctxt "#30652"
279msgid "Finding"
280msgstr "Поиск"
281
282msgctxt "#30653"
283msgid "Downloading"
284msgstr "Загрузка"
285
286msgctxt "#30654"
287msgid "Finished"
288msgstr "Завершено"
289
290msgctxt "#30655"
291msgid "Seeding"
292msgstr "Раздача"
293
294msgctxt "#30656"
295msgid "Allocating"
296msgstr "Выделение"
297
298msgctxt "#30657"
299msgid "Checking resume data"
300msgstr "Проверка данных возобновления"
301
302msgctxt "#30658"
303msgid "Paused"
304msgstr "Пауза"
305
306msgctxt "#30659"
307msgid "Buffering"
308msgstr "Буферизация"
309
310msgctxt "#30660"
311msgid "Unknown"
312msgstr "Неизвестно"
313
314msgctxt "#30700"
315msgid "Buffer and play"
316msgstr "Буферизация и воспроизведение"
317
318msgctxt "#30701"
319msgid "Stop downloading"
320msgstr "Остановить загрузку"
321
322msgctxt "#30702"
323msgid "Downloads"
324msgstr "Загрузки"
325
326msgctxt "#30703"
327msgid "Resume"
328msgstr "Возобновить"
329
330msgctxt "#30704"
331msgid "Pause"
332msgstr "Пауза"
333
334msgctxt "#30705"
335msgid "Remove torrent"
336msgstr "Удалить торрент"
337
338msgctxt "#30706"
339msgid "Remove torrent and files"
340msgstr "Удалить торрент и файлы"
341
342msgctxt "#30707"
343msgid "Torrent status"
344msgstr "Статус торрента"
345
346msgctxt "#30708"
347msgid "Stop"
348msgstr "Стоп"
349
350msgctxt "#30709"
351msgid "Drop torrent"
352msgstr "Сбросить торрент"
353
354msgctxt "#30710"
355msgid "Include Season Packs"
356msgstr "Включить паки сезонов"
357
358msgctxt "#30711"
359msgid "Torrent Settings"
360msgstr "Настройки торрента"
361
362msgctxt "#30712"
363msgid "General"
364msgstr "Общие"
365
366msgctxt "#30713"
367msgid "Enable"
368msgstr "Включить"
369
370msgctxt "#30714"
371msgid "Jacktorr"
372msgstr "Jacktorr"
373
374msgctxt "#30715"
375msgid "Torrest"
376msgstr "Torrest"
377
378msgctxt "#30716"
379msgid "Elementum"
380msgstr "Elementum"
381
382msgctxt "#30717"
383msgid "General"
384msgstr "Общие"
385
386msgctxt "#30718"
387msgid "Auto-Play"
388msgstr "Автовоспроизведение"
389
390msgctxt "#30719"
391msgid "Enable Next Episode Dialog"
392msgstr "Включить диалог следующей серии"
393
394msgctxt "#30720"
395msgid "Seconds left before show Next Episode Dialog"
396msgstr "Секунд до показа диалога следующей серии"
397
398msgctxt "#30721"
399msgid "Cache Enabled"
400msgstr "Кэш включен"
401
402msgctxt "#30722"
403msgid "Cache Expiration (hours)"
404msgstr "Истечение кэша (часы)"
405
406msgctxt "#30723"
407msgid "Cache"
408msgstr "Кэш"
409
410msgctxt "#30724"
411msgid "Update"
412msgstr "Обновить"
413
414msgctxt "#30725"
415msgid "Indexers Configuration"
416msgstr "Настройка индексаторов"
417
418msgctxt "#30726"
419msgid "Sort"
420msgstr "Сортировка"
421
422msgctxt "#30727"
423msgid "Quality"
424msgstr "Качество"
425
426msgctxt "#30938"
427msgid "Silent Resume"
428msgstr "Тихий возврат"
429
430msgctxt "#30939"
431msgid "Auto-resume from last position without asking"
432msgstr "Автоматически возобновлять с последней позиции без вопросов"
433
434msgctxt "#30728"
435msgid "Seeds"
436msgstr "Сиды"
437
438msgctxt "#30729"
439msgid "Size"
440msgstr "Размер"
441
442msgctxt "#30730"
443msgid "Cached"
444msgstr "В кэше"
445
446msgctxt "#30731"
447msgid "Date"
448msgstr "Дата"
449
450msgctxt "#30732"
451msgid "None"
452msgstr "Нет"
453
454msgctxt "#30733"
455msgid "Torrentio Configuration"
456msgstr "Настройка Torrentio"
457
458msgctxt "#30734"
459msgid "Enable"
460msgstr "Включить"
461
462msgctxt "#30736"
463msgid "Subtitle Language"
464msgstr "Язык субтитров"
465
466msgctxt "#30737"
467msgid "GB"
468msgstr "Английский"
469
470msgctxt "#30738"
471msgid "JP"
472msgstr "Японский"
473
474msgctxt "#30739"
475msgid "RU"
476msgstr "Русский"
477
478msgctxt "#30740"
479msgid "IT"
480msgstr "Итальянский"
481
482msgctxt "#30741"
483msgid "PT"
484msgstr "Португальский"
485
486msgctxt "#30742"
487msgid "ES"
488msgstr "Испанский"
489
490msgctxt "#30743"
491msgid "MX"
492msgstr "Мексиканский"
493
494msgctxt "#30744"
495msgid "KR"
496msgstr "Корейский"
497
498msgctxt "#30745"
499msgid "CN"
500msgstr "Китайский"
501
502msgctxt "#30746"
503msgid "TW"
504msgstr "Тайваньский"
505
506msgctxt "#30747"
507msgid "FR"
508msgstr "Французский"
509
510msgctxt "#30748"
511msgid "DE"
512msgstr "Немецкий"
513
514msgctxt "#30749"
515msgid "NL"
516msgstr "Голландский"
517
518msgctxt "#30750"
519msgid "IN"
520msgstr "Индийский"
521
522msgctxt "#30751"
523msgid "PL"
524msgstr "Польский"
525
526msgctxt "#30752"
527msgid "LT"
528msgstr "Литовский"
529
530msgctxt "#30753"
531msgid "CZ"
532msgstr "Чешский"
533
534msgctxt "#30754"
535msgid "SK"
536msgstr "Словацкий"
537
538msgctxt "#30755"
539msgid "SI"
540msgstr "Словенский"
541
542msgctxt "#30756"
543msgid "HU"
544msgstr "Венгерский"
545
546msgctxt "#30757"
547msgid "RO"
548msgstr "Румынский"
549
550msgctxt "#30758"
551msgid "HR"
552msgstr "Хорватский"
553
554msgctxt "#30759"
555msgid "UA"
556msgstr "Украинский"
557
558msgctxt "#30760"
559msgid "GR"
560msgstr "Греческий"
561
562msgctxt "#30761"
563msgid "DK"
564msgstr "Датский"
565
566msgctxt "#30762"
567msgid "FI"
568msgstr "Финский"
569
570msgctxt "#30763"
571msgid "SE"
572msgstr "Шведский"
573
574msgctxt "#30764"
575msgid "NO"
576msgstr "Норвежский"
577
578msgctxt "#30765"
579msgid "TR"
580msgstr "Турецкий"
581
582msgctxt "#30766"
583msgid "Torrentio Providers"
584msgstr "Провайдеры Torrentio"
585
586msgctxt "#30767"
587msgid "Jackgram Configuration"
588msgstr "Настройка Jackgram"
589
590msgctxt "#30768"
591msgid "Host"
592msgstr "Хост"
593
594msgctxt "#30769"
595msgid "Elfhosted/KnightCrawler Configuration"
596msgstr "Настройка Elfhosted/KnightCrawler"
597
598msgctxt "#30770"
599msgid "Language"
600msgstr "Язык"
601
602msgctxt "#30771"
603msgid "Zilean Configuration"
604msgstr "Настройка Zilean"
605
606msgctxt "#30772"
607msgid "Zilean Host"
608msgstr "Хост Zilean"
609
610msgctxt "#30774"
611msgid "Zilean Timeout"
612msgstr "Тайм-аут Zilean"
613
614msgctxt "#30775"
615msgid "Jackett Configuration"
616msgstr "Настройка Jackett"
617
618msgctxt "#30776"
619msgid "Prowlarr Configuration"
620msgstr "Настройка Prowlarr"
621
622msgctxt "#30777"
623msgid "MediaFusion configuration"
624msgstr "Настройка MediaFusion"
625
626msgctxt "#30779"
627msgid "Manifest Url"
628msgstr "URL манифеста"
629
630msgctxt "#30780"
631msgid "Jacktook Burst Configuration"
632msgstr "Настройка Jacktook Burst"
633
634msgctxt "#30781"
635msgid "Burst Configuration"
636msgstr "Настройка Burst"
637
638msgctxt "#30782"
639msgid "User"
640msgstr "Пользователь"
641
642msgctxt "#30783"
643msgid "Authorize"
644msgstr "Авторизовать"
645
646msgctxt "#30784"
647msgid "Remove Authorization"
648msgstr "Удалить авторизацию"
649
650msgctxt "#30785"
651msgid "Premiumize Configuration"
652msgstr "Настройка Premiumize"
653
654msgctxt "#30786"
655msgid "Enabled"
656msgstr "Включено"
657
658msgctxt "#30787"
659msgid "Token"
660msgstr "Токен"
661
662msgctxt "#30788"
663msgid "TorBox Configuration"
664msgstr "Настройка TorBox"
665
666msgctxt "#30789"
667msgid "Debrider Configuration"
668msgstr "Настройка Debrider"
669
670msgctxt "#30790"
671msgid "Uncached"
672msgstr "Не в кэше"
673
674msgctxt "#30791"
675msgid "Show uncached torrents"
676msgstr "Показывать торренты не в кэше"
677
678msgctxt "#30792"
679msgid "Use Kodi Language"
680msgstr "Использовать язык Kodi"
681
682msgctxt "#30793"
683msgid "Trakt"
684msgstr "Trakt"
685
686msgctxt "#30794"
687msgid "Trakt Configuration"
688msgstr "Настройка Trakt"
689
690msgctxt "#30795"
691msgid "Expires"
692msgstr "Истекает"
693
694msgctxt "#30796"
695msgid "Refresh"
696msgstr "Обновить"
697
698msgctxt "#30797"
699msgid "Token"
700msgstr "Токен"
701
702msgctxt "#30798"
703msgid "Trakt Client"
704msgstr "Клиент Trakt"
705
706msgctxt "#30800"
707msgid "Trakt Secret"
708msgstr "Секрет Trakt"
709
710msgctxt "#30802"
711msgid "Log out"
712msgstr "Выйти"
713
714msgctxt "#30804"
715msgid "Menus"
716msgstr "Меню"
717
718msgctxt "#30805"
719msgid "Show Telegram Menu"
720msgstr "Показывать меню Telegram"
721
722msgctxt "#30806"
723msgid "Clear cache when update"
724msgstr "Очищать кэш при обновлении"
725
726msgctxt "#30807"
727msgid "Peerfix Configuration"
728msgstr "Настройка Peerfix"
729
730msgctxt "#30894"
731msgid "Show Changelog"
732msgstr "Показать список изменений"
733
734msgctxt "#30895"
735msgid "View the latest changes and updates to the addon."
736msgstr "Просмотр последних изменений и обновлений аддона."
737
738msgctxt "#30896"
739msgid "Calendar"
740msgstr "Календарь"
741
742msgctxt "#30897"
743msgid "Use broadcast day adjustment"
744msgstr "Использовать корректировку дня трансляции"
745
746msgctxt "#30898"
747msgid "Adjust broadcast day based on manual offset"
748msgstr "Корректировка дня трансляции на основе ручного смещения"
749
750msgctxt "#30899"
751msgid "Broadcast Offset (Hours)"
752msgstr "Смещение трансляции (часы)"
753
754msgctxt "#30900"
755msgid "Manual hour adjustment for broadcast day simulation"
756msgstr "Ручная корректировка часов для симуляции дня трансляции"
757
758msgctxt "#30905"
759msgid "Trakt Calendar Previous Days"
760msgstr "Предыдущие дни календаря Trakt"
761
762msgctxt "#30906"
763msgid "Number of days in the past to show in the Trakt calendar."
764msgstr "Количество дней в прошлом для показа в календаре Trakt."
765
766msgctxt "#30907"
767msgid "Trakt Calendar Future Days"
768msgstr "Будущие дни календаря Trakt"
769
770msgctxt "#30908"
771msgid "Number of days in the future to show in the Trakt calendar."
772msgstr "Количество дней в будущем для показа в календаре Trakt."
773
774msgctxt "#30909"
775msgid "Show All Shows in Calendar"
776msgstr "Показывать все шоу в календаре"
777
778msgctxt "#30918"
779msgid "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead."
780msgstr "{provider} не может напрямую воспроизводить упакованные релизы (.rar/.zip). Выберите источник с видеофайлами."
781
782msgctxt "#30910"
783msgid "Show all airing shows instead of just your watchlist/collection."
784msgstr "Показывать все идущие шоу вместо только вашего списка наблюдения/коллекции."
785
786msgctxt "#30911"
787msgid "Your Torbox account username/email."
788msgstr "Имя пользователя/email вашего аккаунта Torbox."
789
790msgctxt "#30912"
791msgid "Authorize add-on to access Torbox account."
792msgstr "Авторизовать аддон для доступа к аккаунту Torbox."
793
794msgctxt "#30913"
795msgid "Revoke Torbox account access."
796msgstr "Отозвать доступ к аккаунту Torbox."
797
798msgctxt "#30914"
799msgid "Trakt Sync Interval (minutes)"
800msgstr "Интервал синхронизации Trakt (минуты)"
801
802msgctxt "#30915"
803msgid "How often Jacktook checks Trakt for remote activity changes while Kodi is running."
804msgstr "Как часто Jacktook проверяет Trakt на наличие изменений активности, пока запущен Kodi."
805
806msgctxt "#30916"
807msgid "Account Info"
808msgstr "Информация об аккаунте"
809
810msgctxt "#30917"
811msgid "View your Trakt account profile and library stats."
812msgstr "Просмотр профиля вашего аккаунта Trakt и статистики библиотеки."
813
814msgctxt "#30924"
815msgid "Trakt List Name"
816msgstr "Имя списка Trakt"
817
818msgctxt "#30925"
819msgid "Trakt List Description"
820msgstr "Описание списка Trakt"
821
822msgctxt "#30926"
823msgid "Create Trakt List"
824msgstr "Создать список Trakt"
825
826msgctxt "#30927"
827msgid "Choose Trakt List"
828msgstr "Выбрать список Trakt"
829
830msgctxt "#30936"
831msgid "Enable or disable Trakt scrobbling and resume playback from the last tracked position."
832msgstr "Включить или выключить скробблинг Trakt и возобновление воспроизведения с последней отслеженной позиции."
833
834msgctxt "#30937"
835msgid "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view."
836msgstr "Очистить кэшированные данные Trakt и заставить Jacktook получать свежие результаты Trakt при следующем открытии вида Trakt."
837
838msgctxt "#40000"
839msgid "Arabic (United Arab Emirates)"
840msgstr "Арабский (ОАЭ)"
841
842msgctxt "#40001"
843msgid "Arabic (Saudi Arabia)"
844msgstr "Арабский (Саудовская Аравия)"
845
846msgctxt "#40002"
847msgid "Belarusian (Belarus)"
848msgstr "Белорусский (Беларусь)"
849
850msgctxt "#40003"
851msgid "Bulgarian (Bulgaria)"
852msgstr "Болгарский (Болгария)"
853
854msgctxt "#40004"
855msgid "Bengali (Bangladesh)"
856msgstr "Бенгальский (Бангладеш)"
857
858msgctxt "#40005"
859msgid "Catalan (Spain)"
860msgstr "Каталанский (Испания)"
861
862msgctxt "#40006"
863msgid "Chamorro (Guam)"
864msgstr "Чаморро (Гуам)"
865
866msgctxt "#40007"
867msgid "Czech (Czech Republic)"
868msgstr "Чешский (Чехия)"
869
870msgctxt "#40008"
871msgid "Danish (Denmark)"
872msgstr "Датский (Дания)"
873
874msgctxt "#40009"
875msgid "German (Austria)"
876msgstr "Немецкий (Австрия)"
877
878msgctxt "#40010"
879msgid "German (Switzerland)"
880msgstr "Немецкий (Швейцария)"
881
882msgctxt "#40011"
883msgid "German (Germany)"
884msgstr "Немецкий (Германия)"
885
886msgctxt "#40012"
887msgid "Greek (Greece)"
888msgstr "Греческий (Греция)"
889
890msgctxt "#40013"
891msgid "English (Australia)"
892msgstr "Английский (Австралия)"
893
894msgctxt "#40014"
895msgid "English (Canada)"
896msgstr "Английский (Канада)"
897
898msgctxt "#40015"
899msgid "English (United Kingdom)"
900msgstr "Английский (Великобритания)"
901
902msgctxt "#40016"
903msgid "English (Ireland)"
904msgstr "Английский (Ирландия)"
905
906msgctxt "#40017"
907msgid "English (New Zealand)"
908msgstr "Английский (Новая Зеландия)"
909
910msgctxt "#40018"
911msgid "English (United States of America)"
912msgstr "Английский (США)"
913
914msgctxt "#40019"
915msgid "Esperanto (None)"
916msgstr "Эсперанто"
917
918msgctxt "#40020"
919msgid "Spanish (Spain)"
920msgstr "Испанский (Испания)"
921
922msgctxt "#40021"
923msgid "Spanish (Mexico)"
924msgstr "Испанский (Мексика)"
925
926msgctxt "#40022"
927msgid "Estonian (Estonia)"
928msgstr "Эстонский (Эстония)"
929
930msgctxt "#40023"
931msgid "Basque (Spain)"
932msgstr "Баскский (Испания)"
933
934msgctxt "#40024"
935msgid "Persian (Iran)"
936msgstr "Персидский (Иран)"
937
938msgctxt "#40025"
939msgid "Finnish (Finland)"
940msgstr "Финский (Финляндия)"
941
942msgctxt "#40026"
943msgid "French (Canada)"
944msgstr "Французский (Канада)"
945
946msgctxt "#40027"
947msgid "French (France)"
948msgstr "Французский (Франция)"
949
950msgctxt "#40028"
951msgid "Galician (Spain)"
952msgstr "Галисийский (Испания)"
953
954msgctxt "#40029"
955msgid "Hebrew (Israel)"
956msgstr "Иврит (Израиль)"
957
958msgctxt "#40030"
959msgid "Hindi (India)"
960msgstr "Хинди (Индия)"
961
962msgctxt "#40031"
963msgid "Hungarian (Hungary)"
964msgstr "Венгерский (Венгрия)"
965
966msgctxt "#40032"
967msgid "Indonesian (Indonesia)"
968msgstr "Индонезийский (Индонезия)"
969
970msgctxt "#40033"
971msgid "Italian (Italy)"
972msgstr "Итальянский (Италия)"
973
974msgctxt "#40034"
975msgid "Japanese (Japan)"
976msgstr "Японский (Япония)"
977
978msgctxt "#40035"
979msgid "Georgian (Georgia)"
980msgstr "Грузинский (Грузия)"
981
982msgctxt "#40036"
983msgid "Kazakh (Kazakhstan)"
984msgstr "Казахский (Казахстан)"
985
986msgctxt "#40037"
987msgid "Kannada (India)"
988msgstr "Каннада (Индия)"
989
990msgctxt "#40038"
991msgid "Korean (South Korea)"
992msgstr "Корейский (Южная Корея)"
993
994msgctxt "#40039"
995msgid "Lithuanian (Lithuania)"
996msgstr "Литовский (Литва)"
997
998msgctxt "#40040"
999msgid "Latvian (Latvia)"
1000msgstr "Латышский (Латвия)"
1001
1002msgctxt "#40041"
1003msgid "Malayalam (India)"
1004msgstr "Малаялам (Индия)"
1005
1006msgctxt "#40042"
1007msgid "Malay (Malaysia)"
1008msgstr "Малайский (Малайзия)"
1009
1010msgctxt "#40043"
1011msgid "Malay (Singapore)"
1012msgstr "Малайский (Сингапур)"
1013
1014msgctxt "#40044"
1015msgid "Norwegian Bokmål (Norway)"
1016msgstr "Норвежский букмол (Норвегия)"
1017
1018msgctxt "#40045"
1019msgid "Dutch (Netherlands)"
1020msgstr "Голландский (Нидерланды)"
1021
1022msgctxt "#40046"
1023msgid "Norwegian (Norway)"
1024msgstr "Норвежский (Норвегия)"
1025
1026msgctxt "#40047"
1027msgid "Polish (Poland)"
1028msgstr "Польский (Польша)"
1029
1030msgctxt "#40048"
1031msgid "Portuguese (Brazil)"
1032msgstr "Португальский (Бразилия)"
1033
1034msgctxt "#40049"
1035msgid "Portuguese (Portugal)"
1036msgstr "Португальский (Португалия)"
1037
1038msgctxt "#40050"
1039msgid "Romanian (Romania)"
1040msgstr "Румынский (Румыния)"
1041
1042msgctxt "#40051"
1043msgid "Russian (Russia)"
1044msgstr "Русский (Россия)"
1045
1046msgctxt "#40052"
1047msgid "Sinhalese (Sri Lanka)"
1048msgstr "Сингальский (Шри-Ланка)"
1049
1050msgctxt "#40053"
1051msgid "Slovak (Slovakia)"
1052msgstr "Словацкий (Словакия)"
1053
1054msgctxt "#40054"
1055msgid "Slovenian (Slovenia)"
1056msgstr "Словенский (Словения)"
1057
1058msgctxt "#40055"
1059msgid "Serbian (Serbia)"
1060msgstr "Сербский (Сербия)"
1061
1062msgctxt "#40056"
1063msgid "Swedish (Sweden)"
1064msgstr "Шведский (Швеция)"
1065
1066msgctxt "#40057"
1067msgid "Tamil (India)"
1068msgstr "Тамильский (Индия)"
1069
1070msgctxt "#40058"
1071msgid "Telugu (India)"
1072msgstr "Телугу (Индия)"
1073
1074msgctxt "#40059"
1075msgid "Thai (Thailand)"
1076msgstr "Тайский (Таиланд)"
1077
1078msgctxt "#40060"
1079msgid "Tagalog (Philippines)"
1080msgstr "Тагальский (Филиппины)"
1081
1082msgctxt "#40061"
1083msgid "Turkish (Turkey)"
1084msgstr "Турецкий (Турция)"
1085
1086msgctxt "#40062"
1087msgid "Ukrainian (Ukraine)"
1088msgstr "Украинский (Украина)"
1089
1090msgctxt "#40063"
1091msgid "Vietnamese (Vietnam)"
1092msgstr "Вьетнамский (Вьетнам)"
1093
1094msgctxt "#40064"
1095msgid "Mandarin (China)"
1096msgstr "Китайский (Китай)"
1097
1098msgctxt "#40065"
1099msgid "Mandarin (Hong Kong)"
1100msgstr "Китайский (Гонконг)"
1101
1102msgctxt "#40066"
1103msgid "Mandarin (Taiwan)"
1104msgstr "Китайский (Тайвань)"
1105
1106msgctxt "#40067"
1107msgid "Zulu (South Africa)"
1108msgstr "Зулу (Южная Африка)"
1109
1110msgctxt "#42418"
1111msgid "Language and country"
1112msgstr "Язык и страна"
1113
1114msgctxt "#42419"
1115msgid "The language and country settings used when looking up data from TMDb"
1116msgstr "Настройки языка и страны, используемые при поиске данных в TMDb"
1117
1118msgctxt "#42420"
1119msgid "EN"
1120msgstr "RU"
1121
1122msgctxt "#30801"
1123msgid "Zilean server address (include port if required)."
1124msgstr "Адрес сервера Zilean (включая порт, если требуется)."
1125
1126msgctxt "#30803"
1127msgid "Display torrents not yet cached on your debrid service."
1128msgstr "Отображать торренты, еще не кэшированные в вашем debrid-сервисе."
1129
1130msgctxt "#30808"
1131msgid "Fanart.tv Client Key (for artwork)."
1132msgstr "Ключ клиента Fanart.tv (для обложек)."
1133
1134msgctxt "#30809"
1135msgid "Improve performance by caching metadata."
1136msgstr "Улучшить производительность за счет кэширования метаданных."
1137
1138msgctxt "#30810"
1139msgid "Seconds before Jackett requests time out (e.g., 30)."
1140msgstr "Секунд до тайм-аута запросов Jackett (например, 30)."
1141
1142msgctxt "#30811"
1143msgid "Trakt client secret from OAuth application settings."
1144msgstr "Секрет клиента Trakt из настроек приложения OAuth."
1145
1146msgctxt "#30812"
1147msgid "Limit TV show results to specific seasons/episodes."
1148msgstr "Ограничить результаты ТВ-шоу конкретными сезонами/сериями."
1149
1150msgctxt "#30813"
1151msgid "Hours before cached data is automatically refreshed (e.g., 24)."
1152msgstr "Часов до автоматического обновления кэшированных данных (например, 24)."
1153
1154msgctxt "#30814"
1155msgid "Premiumize Client Token (get from account page)."
1156msgstr "Токен клиента Premiumize (получите на странице аккаунта)."
1157
1158msgctxt "#30815"
1159msgid "Jackett server URL (e.g., http://localhost)."
1160msgstr "URL сервера Jackett (например, http://localhost)."
1161
1162msgctxt "#30816"
1163msgid "Display Telegram integration options in UI."
1164msgstr "Отображать параметры интеграции Telegram в интерфейсе."
1165
1166msgctxt "#30817"
1167msgid "Your Real-Debrid account username/email."
1168msgstr "Имя пользователя/email вашего аккаунта Real-Debrid."
1169
1170msgctxt "#30818"
1171msgid "Trakt client ID from https://trakt.tv/oauth/apps."
1172msgstr "ID клиента Trakt с https://trakt.tv/oauth/apps."
1173
1174msgctxt "#30819"
1175msgid "Connect Trakt.tv account to sync watch progress."
1176msgstr "Подключить аккаунт Trakt.tv для синхронизации прогресса просмотра."
1177
1178msgctxt "#30820"
1179msgid "Seconds before Zilean requests time out (e.g., 30)."
1180msgstr "Секунд до тайм-аута запросов Zilean (например, 30)."
1181
1182msgctxt "#30821"
1183msgid "Your Jackett Client Key (found in dashboard)."
1184msgstr "Ваш ключ клиента Jackett (находится в панели управления)."
1185
1186msgctxt "#30822"
1187msgid "Sort results by criteria (e.g., 'date', 'title', 'size')."
1188msgstr "Сортировать результаты по критериям (например, 'дата', 'название', 'размер')."
1189
1190msgctxt "#30823"
1191msgid "Configure Burst mode settings and providers."
1192msgstr "Настроить параметры и провайдеров режима Burst."
1193
1194msgctxt "#30824"
1195msgid "Enable Premiumize.me cloud service."
1196msgstr "Включить облачный сервис Premiumize.me."
1197
1198msgctxt "#30825"
1199msgid "Truncate descriptions after [X] characters (0 for full text)."
1200msgstr "Обрезать описания после [X] символов (0 для полного текста)."
1201
1202msgctxt "#30826"
1203msgid "Enable Prowlarr indexer management."
1204msgstr "Включить управление индексаторами Prowlarr."
1205
1206msgctxt "#30827"
1207msgid "Jackgram server URL (e.g., http://localhost:9117)."
1208msgstr "URL сервера Jackgram (например, http://localhost:9117)."
1209
1210msgctxt "#30828"
1211msgid "Debrider Client Key for authentication."
1212msgstr "Ключ клиента Debrider для аутентификации."
1213
1214msgctxt "#30829"
1215msgid "Delay (in seconds) before next episode dialog appears (e.g., 10)."
1216msgstr "Задержка (в секундах) перед появлением диалога следующей серии (например, 10)."
1217
1218msgctxt "#30830"
1219msgid "Specify the torrent client."
1220msgstr "Укажите торрент-клиент."
1221
1222msgctxt "#30831"
1223msgid "Enable Debrider cached torrent support."
1224msgstr "Включить поддержку кэшированных торрентов Debrider."
1225
1226msgctxt "#30832"
1227msgid "Torbox Client Token (found in account settings)."
1228msgstr "Токен клиента Torbox (находится в настройках аккаунта)."
1229
1230msgctxt "#30833"
1231msgid "Trakt refresh token (auto-managed)."
1232msgstr "Токен обновления Trakt (управляется автоматически)."
1233
1234msgctxt "#30834"
1235msgid "Show confirmation dialog before playing next episode."
1236msgstr "Показывать диалог подтверждения перед воспроизведением следующей серии."
1237
1238msgctxt "#30835"
1239msgid "Authorize add-on to access Real-Debrid account."
1240msgstr "Авторизовать аддон для доступа к аккаунту Real-Debrid."
1241
1242msgctxt "#30836"
1243msgid "Enable torrent-based media content"
1244msgstr "Включить медиаконтент на основе торрентов"
1245
1246msgctxt "#30837"
1247msgid "Indicates if Real-Debrid account is linked."
1248msgstr "Указывает, привязан ли аккаунт Real-Debrid."
1249
1250msgctxt "#30838"
1251msgid "Automatically play next media item when current playback ends."
1252msgstr "Автоматически воспроизводить следующий медиаэлемент после окончания текущего."
1253
1254msgctxt "#30839"
1255msgid "Check for and install the latest add-on updates."
1256msgstr "Проверять и устанавливать последние обновления аддона."
1257
1258msgctxt "#30840"
1259msgid "Comma-separated Prowlarr indexer IDs to use (e.g., 1,5,7)."
1260msgstr "ID индексаторов Prowlarr через запятую (например, 1,5,7)."
1261
1262msgctxt "#30841"
1263msgid "Timeout (seconds) for Prowlarr requests (e.g., 20)."
1264msgstr "Тайм-аут (секунды) для запросов Prowlarr (например, 20)."
1265
1266msgctxt "#30842"
1267msgid "Trakt authorization expiration date (read-only)."
1268msgstr "Дата истечения авторизации Trakt (только для чтения)."
1269
1270msgctxt "#30843"
1271msgid "Enable Torbox.de cloud torrent integration."
1272msgstr "Включить интеграцию облачных торрентов Torbox.de."
1273
1274msgctxt "#30844"
1275msgid "Trakt Access Token (auto-filled after auth)."
1276msgstr "Токен доступа Trakt (заполняется автоматически после авторизации)."
1277
1278msgctxt "#30845"
1279msgid "Link Premiumize.me account to add-on."
1280msgstr "Привязать аккаунт Premiumize.me к аддону."
1281
1282msgctxt "#30846"
1283msgid "Clear cached data automatically after updating the add-on."
1284msgstr "Автоматически очищать кэшированные данные после обновления аддона."
1285
1286msgctxt "#30847"
1287msgid "Click to delete all cached data immediately."
1288msgstr "Нажмите, чтобы немедленно удалить все кэшированные данные."
1289
1290msgctxt "#30848"
1291msgid "Real-Debrid Client Token (auto-filled after auth)."
1292msgstr "Токен клиента Real-Debrid (заполняется автоматически после авторизации)."
1293
1294msgctxt "#30849"
1295msgid "Enable Jackett torrent proxy support."
1296msgstr "Включить поддержку прокси-торрентов Jackett."
1297
1298msgctxt "#30850"
1299msgid "Enable Jackgram integration for enhanced search."
1300msgstr "Включить интеграцию Jackgram для расширенного поиска."
1301
1302msgctxt "#30851"
1303msgid "Prowlarr instance URL (e.g., http://localhost)."
1304msgstr "URL экземпляра Prowlarr (например, http://localhost)."
1305
1306msgctxt "#30852"
1307msgid "Maximum number of search results to display (e.g., 50)."
1308msgstr "Максимальное количество результатов поиска (например, 50)."
1309
1310msgctxt "#30853"
1311msgid "Revoke Real-Debrid account access."
1312msgstr "Отозвать доступ к аккаунту Real-Debrid."
1313
1314msgctxt "#30854"
1315msgid "Prowlarr Client Key (found in Settings > General)."
1316msgstr "Ключ клиента Prowlarr (находится в Настройки > Общие)."
1317
1318msgctxt "#30855"
1319msgid "Trakt.tv username (auto-populated after auth)."
1320msgstr "Имя пользователя Trakt.tv (заполняется автоматически после авторизации)."
1321
1322msgctxt "#30856"
1323msgid "Disconnect Trakt.tv account from add-on."
1324msgstr "Отключить аккаунт Trakt.tv от аддона."
1325
1326msgctxt "#30857"
1327msgid "Enable Zilean integration for metadata fetching."
1328msgstr "Включить интеграцию Zilean для получения метаданных."
1329
1330msgctxt "#30858"
1331msgid "Your Client Key (required for metadata)"
1332msgstr "Ваш ключ клиента (требуется для метаданных)"
1333
1334msgctxt "#30859"
1335msgid "Enable Burst mode for accelerated searches."
1336msgstr "Включить режим Burst для ускоренного поиска."
1337
1338msgctxt "#30860"
1339msgid "Others"
1340msgstr "Другие"
1341
1342msgctxt "#30861"
1343msgid "Filters"
1344msgstr "Фильтры"
1345
1346msgctxt "#30862"
1347msgid "Torrent (P2P) Configuration"
1348msgstr "Конфигурация торрента (P2P)"
1349
1350msgctxt "#30863"
1351msgid "Specify the torrent client"
1352msgstr "Укажите торрент-клиент"
1353
1354msgctxt "#30864"
1355msgid "Sources"
1356msgstr "Источники"
1357
1358msgctxt "#30865"
1359msgid "General Filters"
1360msgstr "Общие фильтры"
1361
1362msgctxt "#30866"
1363msgid "Sign in to your Stremio account to access add-ons and personalized features."
1364msgstr "Войдите в свой аккаунт Stremio для доступа к аддонам и персональным функциям."
1365
1366msgctxt "#30867"
1367msgid "Sign out of your Stremio account. This will disconnect your account but won't delete your data."
1368msgstr "Выйдите из своего аккаунта Stremio. Это отключит ваш аккаунт, но не удалит ваши данные."
1369
1370msgctxt "#30868"
1371msgid "Log Out of Stremio"
1372msgstr "Выйти из Stremio"
1373
1374msgctxt "#30869"
1375msgid "Enable Stremio add-ons integration."
1376msgstr "Включить интеграцию аддонов Stremio."
1377
1378msgctxt "#30870"
1379msgid "Select Stream Addons"
1380msgstr "Выбрать аддоны потоков"
1381
1382msgctxt "#30871"
1383msgid "Log In to Stremio"
1384msgstr "Войти в Stremio"
1385
1386msgctxt "#30872"
1387msgid "Enable or disable Stremio add-ons."
1388msgstr "Включить или выключить аддоны Stremio."
1389
1390msgctxt "#30873"
1391msgid "Select language for player subtitle selection"
1392msgstr "Выберите язык для выбора субтитров в плеере"
1393
1394msgctxt "#30874"
1395msgid "Update Addons"
1396msgstr "Обновить аддоны"
1397
1398msgctxt "#30875"
1399msgid "Select Catalogs Addons"
1400msgstr "Выбрать аддоны каталогов"
1401
1402msgctxt "#30876"
1403msgid "Download directory"
1404msgstr "Каталог загрузки"
1405
1406msgctxt "#30877"
1407msgid "Enable Trakt.tv Scrobbling"
1408msgstr "Включить скробблинг Trakt.tv"
1409
1410msgctxt "#30878"
1411msgid "Auto select subtitle when playing"
1412msgstr "Автовыбор субтитров при воспроизведении"
1413
1414msgctxt "#30879"
1415msgid "Auto-play quality"
1416msgstr "Качество автовоспроизведения"
1417
1418msgctxt "#30880"
1419msgid "1080p"
1420msgstr "1080p"
1421
1422msgctxt "#30881"
1423msgid "720p"
1424msgstr "720p"
1425
1426msgctxt "#30882"
1427msgid "4k"
1428msgstr "4k"
1429
1430msgctxt "#30883"
1431msgid "480p"
1432msgstr "480p"
1433
1434msgctxt "#30884"
1435msgid "Subtitles Configuration"
1436msgstr "Настройка субтитров"
1437
1438msgctxt "#30885"
1439msgid "Addon Host"
1440msgstr "Хост аддона"
1441
1442msgctxt "#30886"
1443msgid "Subtitles"
1444msgstr "Субтитры"
1445
1446msgctxt "#30887"
1447msgid "Primary Language"
1448msgstr "Основной язык"
1449
1450msgctxt "#30888"
1451msgid "Secondary Language"
1452msgstr "Дополнительный язык"
1453
1454msgctxt "#30889"
1455msgid "Deepl Enabled"
1456msgstr "Deepl включен"
1457
1458msgctxt "#30890"
1459msgid "Deepl Client Key"
1460msgstr "Ключ клиента Deepl"
1461
1462msgctxt "#30891"
1463msgid "Translator Configuration"
1464msgstr "Настройка переводчика"
1465
1466msgctxt "#30892"
1467msgid "Deepl Target Language"
1468msgstr "Целевой язык Deepl"
1469
1470msgctxt "#30893"
1471msgid "Stremio Subtitle Configuration"
1472msgstr "Настройка субтитров Stremio"
1473
1474msgctxt "#90001"
1475msgid "Add Custom Addon"
1476msgstr "Добавить пользовательский аддон"
1477
1478msgctxt "#90002"
1479msgid "Add a custom Stremio addon by entering its URL"
1480msgstr "Добавить пользовательский аддон Stremio, введя его URL"
1481
1482msgctxt "#90003"
1483msgid "Auto-select audio when playing"
1484msgstr "Автовыбор аудио при воспроизведении"
1485
1486msgctxt "#90004"
1487msgid "Audio Language"
1488msgstr "Язык аудио"
1489
1490msgctxt "#90005"
1491msgid "Select audio language for playing the video"
1492msgstr "Выберите язык аудио для воспроизведения видео"
1493
1494msgctxt "#90006"
1495msgid "Search"
1496msgstr "Поиск"
1497
1498msgctxt "#90007"
1499msgid "TV Shows"
1500msgstr "ТВ-шоу"
1501
1502msgctxt "#90008"
1503msgid "Movies"
1504msgstr "Фильмы"
1505
1506msgctxt "#90009"
1507msgid "Anime"
1508msgstr "Аниме"
1509
1510msgctxt "#90010"
1511msgid "Live TV"
1512msgstr "Прямой эфир"
1513
1514msgctxt "#90011"
1515msgid "Direct Search"
1516msgstr "Прямой поиск"
1517
1518msgctxt "#90012"
1519msgid "Torrents"
1520msgstr "Торренты"
1521
1522msgctxt "#90013"
1523msgid "Telegram"
1524msgstr "Telegram"
1525
1526msgctxt "#90014"
1527msgid "Cloud"
1528msgstr "Облако"
1529
1530msgctxt "#90015"
1531msgid "Downloads"
1532msgstr "Загрузки"
1533
1534msgctxt "#90016"
1535msgid "Settings"
1536msgstr "Настройки"
1537
1538msgctxt "#90017"
1539msgid "History"
1540msgstr "История"
1541
1542msgctxt "#90018"
1543msgid "Donate"
1544msgstr "Пожертвовать"
1545
1546msgctxt "#90019"
1547msgid "Recently Watched Files"
1548msgstr "Недавно просмотренные файлы"
1549
1550msgctxt "#90020"
1551msgid "Recently Watched Titles"
1552msgstr "Недавно просмотренные названия"
1553
1554msgctxt "#90021"
1555msgid "TV Calendar"
1556msgstr "ТВ-календарь"
1557
1558msgctxt "#90022"
1559msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:"
1560msgstr "Если вам нравится использовать Jacktook и вы хотите поддержать его разработку, вы можете угостить меня кофе! Ваша поддержка помогает проекту оставаться активным и позволяет вносить будущие улучшения:"
1561
1562msgctxt "#90023"
1563msgid "Support Jacktook"
1564msgstr "Поддержать Jacktook"
1565
1566msgctxt "#90024"
1567msgid "Trending Recent"
1568msgstr "Популярные недавно"
1569
1570msgctxt "#90025"
1571msgid "Genres"
1572msgstr "Жанры"
1573
1574msgctxt "#90026"
1575msgid "Calendar"
1576msgstr "Календарь"
1577
1578msgctxt "#90027"
1579msgid "Years"
1580msgstr "Годы"
1581
1582msgctxt "#90028"
1583msgid "Trending"
1584msgstr "В тренде"
1585
1586msgctxt "#90029"
1587msgid "Most Watched"
1588msgstr "Самые просматриваемые"
1589
1590msgctxt "#90030"
1591msgid "Most Favorited"
1592msgstr "Самые любимые"
1593
1594msgctxt "#90031"
1595msgid "Popular Lists"
1596msgstr "Популярные списки"
1597
1598msgctxt "#90032"
1599msgid "Trending Lists"
1600msgstr "Трендовые списки"
1601
1602msgctxt "#90033"
1603msgid "Recomendations"
1604msgstr "Рекомендации"
1605
1606msgctxt "#90034"
1607msgid "Watched History"
1608msgstr "История просмотров"
1609
1610msgctxt "#90035"
1611msgid "Watchlist"
1612msgstr "Список наблюдения"
1613
1614msgctxt "#90036"
1615msgid "Top 10 BoxOffice"
1616msgstr "Топ-10 сборов"
1617
1618msgctxt "#90037"
1619msgid "Popular"
1620msgstr "Популярные"
1621
1622msgctxt "#90038"
1623msgid "Popular Recent"
1624msgstr "Популярные недавно"
1625
1626msgctxt "#90039"
1627msgid "Airing"
1628msgstr "В эфире"
1629
1630msgctxt "#90040"
1631msgid "Years"
1632msgstr "Годы"
1633
1634msgctxt "#90041"
1635msgid "Genres"
1636msgstr "Жанры"
1637
1638msgctxt "#90042"
1639msgid "Top Rated"
1640msgstr "Самые рейтинговые"
1641
1642msgctxt "#90043"
1643msgid "Most Watched"
1644msgstr "Самые просматриваемые"
1645
1646msgctxt "#90044"
1647msgid "Torrentio Configuration"
1648msgstr "Настройка Torrentio"
1649
1650msgctxt "#90045"
1651msgid "Enable Torrentio Settings"
1652msgstr "Включить настройки Torrentio"
1653
1654msgctxt "#90046"
1655msgid "Enable or disable the Torrentio Configuration"
1656msgstr "Включить или выключить настройку Torrentio"
1657
1658msgctxt "#90047"
1659msgid "Select Torrentio Providers"
1660msgstr "Выбрать провайдеров Torrentio"
1661
1662msgctxt "#90048"
1663msgid "Choose which providers to include in the stream request."
1664msgstr "Выберите, каких провайдеров включить в запрос потока."
1665
1666msgctxt "#90049"
1667msgid "Scrape again"
1668msgstr "Повторный поиск"
1669
1670msgctxt "#90050"
1671msgid "Recommendations"
1672msgstr "Рекомендации"
1673
1674msgctxt "#90051"
1675msgid "Similar"
1676msgstr "Похожие"
1677
1678msgctxt "#90052"
1679msgid "Thread Number"
1680msgstr "Количество потоков"
1681
1682msgctxt "#90053"
1683msgid "Select the number of threads (1-30)"
1684msgstr "Выберите количество потоков (1-30)"
1685
1686msgctxt "#90054"
1687msgid "Kodi logs"
1688msgstr "Логи Kodi"
1689
1690msgctxt "#90055"
1691msgid "Kodi old logs"
1692msgstr "Старые логи Kodi"
1693
1694msgctxt "#90056"
1695msgid "Displays Kodi logs to help diagnose issues."
1696msgstr "Отображает логи Kodi для помощи в диагностике проблем."
1697
1698msgctxt "#90057"
1699msgid "Logs"
1700msgstr "Логи"
1701
1702msgctxt "#90058"
1703msgid "Filter by Size"
1704msgstr "Фильтр по размеру"
1705
1706msgctxt "#90059"
1707msgid "Enable Size Filter"
1708msgstr "Включить фильтр по размеру"
1709
1710msgctxt "#90060"
1711msgid "Minimum Size (MB)"
1712msgstr "Минимальный размер (МБ)"
1713
1714msgctxt "#90061"
1715msgid "Maximum Size (MB)"
1716msgstr "Максимальный размер (МБ)"
1717
1718msgctxt "#90062"
1719msgid "Enable Cached Search"
1720msgstr "Включить поиск в кэше"
1721
1722msgctxt "#90063"
1723msgid "Only show cached streams from external services"
1724msgstr "Показывать только кэшированные потоки из внешних сервисов"
1725
1726msgctxt "#90064"
1727msgid "Popular"
1728msgstr "Популярные"
1729
1730msgctxt "#90065"
1731msgid "Languages"
1732msgstr "Языки"
1733
1734msgctxt "#90066"
1735msgid "Networks"
1736msgstr "Сети"
1737
1738msgctxt "#90067"
1739msgid "Collections"
1740msgstr "Коллекции"
1741
1742msgctxt "#90068"
1743msgid "Search for collections:"
1744msgstr "Поиск коллекций:"
1745
1746msgctxt "#90069"
1747msgid "Main Menu"
1748msgstr "Главное меню"
1749
1750msgctxt "#90070"
1751msgid "Last Titles"
1752msgstr "Последние названия"
1753
1754msgctxt "#90071"
1755msgid "Last Files"
1756msgstr "Последние файлы"
1757
1758msgctxt "#90072"
1759msgid "Trakt Lists"
1760msgstr "Списки Trakt"
1761
1762msgctxt "#90073"
1763msgid "Metadata"
1764msgstr "Метаданные"
1765
1766msgctxt "#90074"
1767msgid "Top Lists"
1768msgstr "Топ-списки"
1769
1770msgctxt "#90075"
1771msgid "User Lists"
1772msgstr "Пользовательские списки"
1773
1774msgctxt "#90076"
1775msgid "Search Lists"
1776msgstr "Поиск списков"
1777
1778msgctxt "#90077"
1779msgid "MDbList Configuration"
1780msgstr "Настройка MDbList"
1781
1782msgctxt "#90078"
1783msgid "People"
1784msgstr "Люди"
1785
1786msgctxt "#90079"
1787msgid "Popular People"
1788msgstr "Популярные люди"
1789
1790msgctxt "#90080"
1791msgid "Latest People"
1792msgstr "Последние люди"
1793
1794msgctxt "#90081"
1795msgid "Search People"
1796msgstr "Поиск людей"
1797
1798msgctxt "#90082"
1799msgid "Download subtitles"
1800msgstr "Загрузить субтитры"
1801
1802msgctxt "#90083"
1803msgid "Download video"
1804msgstr "Загрузить видео"
1805
1806msgctxt "#90084"
1807msgid "Browse into"
1808msgstr "Просмотреть внутри"
1809
1810msgctxt "#90085"
1811msgid "Include TV show Specials"
1812msgstr "Включить спецвыпуски ТВ-шоу"
1813
1814msgctxt "#90086"
1815msgid "Airing Today"
1816msgstr "Сегодня в эфире"
1817
1818msgctxt "#90099"
1819msgid "Filter by Source Type"
1820msgstr "Фильтр по типу источника"
1821
1822msgctxt "#90100"
1823msgid "Include BluRay/UHD sources"
1824msgstr "Включить источники BluRay/UHD"
1825
1826msgctxt "#90105"
1827msgid "Includes releases such as: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX"
1828msgstr "Включает релизы, такие как: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX"
1829
1830msgctxt "#90101"
1831msgid "Include WEB/HD sources"
1832msgstr "Включить источники WEB/HD"
1833
1834msgctxt "#90106"
1835msgid "Includes releases such as: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux"
1836msgstr "Включает релизы, такие как: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux"
1837
1838msgctxt "#90102"
1839msgid "Include DVD/TV/SAT sources"
1840msgstr "Включить источники DVD/TV/SAT"
1841
1842msgctxt "#90107"
1843msgid "Includes releases such as: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV"
1844msgstr "Включает релизы, такие как: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV"
1845
1846msgctxt "#90103"
1847msgid "Include CAM/Screener sources"
1848msgstr "Включить источники CAM/Screener"
1849
1850msgctxt "#90108"
1851msgid "Includes releases such as: CAM, TeleSync, TeleCine, SCR"
1852msgstr "Включает релизы, такие как: CAM, TeleSync, TeleCine, SCR"
1853
1854msgctxt "#90104"
1855msgid "Include Unknown sources"
1856msgstr "Включить неизвестные источники"
1857
1858msgctxt "#90109"
1859msgid "Includes uncategorized or unidentified sources"
1860msgstr "Включает некатегоризированные или неидентифицированные источники"
1861
1862msgctxt "#90110"
1863msgid "Do you want to clear your watched history?."
1864msgstr "Вы хотите очистить историю просмотров?"
1865
1866msgctxt "#90111"
1867msgid "Do you want to clear your history?."
1868msgstr "Вы хотите очистить историю?"
1869
1870msgctxt "#90112"
1871msgid "Clear History"
1872msgstr "Очистить историю"
1873
1874msgctxt "#90113"
1875msgid "Click to delete all watched history immediately."
1876msgstr "Нажмите, чтобы немедленно удалить всю историю просмотров."
1877
1878msgctxt "#90114"
1879msgid "History cleared"
1880msgstr "История очищена"
1881
1882msgctxt "#90115"
1883msgid "Source Select"
1884msgstr "Выбор источника"
1885
1886msgctxt "#90116"
1887msgid "Open Settings"
1888msgstr "Открыть настройки"
1889
1890msgctxt "#90117"
1891msgid "Select Torrentio Providers"
1892msgstr "Выбрать провайдеров Torrentio"
1893
1894msgctxt "#90118"
1895msgid "Port"
1896msgstr "Порт"
1897
1898msgctxt "#90119"
1899msgid "Jackett port (default:9117)"
1900msgstr "Порт Jackett (по умолчанию: 9117)"
1901
1902msgctxt "#90120"
1903msgid "Prowlarr port (default:9696)"
1904msgstr "Порт Prowlarr (по умолчанию: 9696)"
1905
1906msgctxt "#90121"
1907msgid "All Debrid Configuration"
1908msgstr "Настройка All Debrid"
1909
1910msgctxt "#90122"
1911msgid "Auto download subtitles"
1912msgstr "Автозагрузка субтитров"
1913
1914msgctxt "#90123"
1915msgid "Stremio Timeout"
1916msgstr "Тайм-аут Stremio"
1917
1918msgctxt "#90124"
1919msgid "Webdav Client Configuration"
1920msgstr "Настройка клиента Webdav"
1921
1922msgctxt "#90126"
1923msgid "Host"
1924msgstr "Хост"
1925
1926msgctxt "#90127"
1927msgid "Username"
1928msgstr "Имя пользователя"
1929
1930msgctxt "#90128"
1931msgid "Password"
1932msgstr "Пароль"
1933
1934msgctxt "#90129"
1935msgid "Test Connection"
1936msgstr "Проверить соединение"
1937
1938msgctxt "#90131"
1939msgid "Webdav"
1940msgstr "Webdav"
1941
1942msgctxt "#90132"
1943msgid "Remote Path"
1944msgstr "Удаленный путь"
1945
1946msgctxt "#90133"
1947msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)."
1948msgstr "Имя хоста или IP-адрес вашего WebDAV-сервера (например, http://mywebdav.com или https://192.168.1.100)."
1949
1950msgctxt "#90134"
1951msgid "The username required for authenticating with your WebDAV server"
1952msgstr "Имя пользователя, необходимое для аутентификации на вашем WebDAV-сервере"
1953
1954msgctxt "#90135"
1955msgid "The password for the specified username to authenticate with your WebDAV server"
1956msgstr "Пароль для указанного имени пользователя для аутентификации на вашем WebDAV-сервере"
1957
1958msgctxt "#90136"
1959msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)."
1960msgstr "Базовый путь или каталог на WebDAV-сервере, где расположены ваши медиафайлы (например, /movies)."
1961
1962msgctxt "#90137"
1963msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials."
1964msgstr "Нажмите, чтобы проверить, может ли клиент WebDAV успешно подключиться к настроенному серверу с использованием предоставленных учетных данных."
1965
1966msgctxt "#90138"
1967msgid "Remove Custom Addon"
1968msgstr "Удалить пользовательский аддон"
1969
1970msgctxt "#90139"
1971msgid "Playback"
1972msgstr "Воспроизведение"
1973
1974msgctxt "#90140"
1975msgid "Super quick play"
1976msgstr "Супербыстрое воспроизведение"
1977
1978msgctxt "#90141"
1979msgid "Re-ask to play last played video"
1980msgstr "Спрашивать снова о воспроизведении последнего торрента"
1981
1982msgctxt "#90142"
1983msgid "Replay Dialog"
1984msgstr "Диалог повтора"
1985
1986msgctxt "#90143"
1987msgid "Do you want to play last watched?"
1988msgstr "Вы хотите воспроизвести последнее просмотренное?"
1989
1990msgctxt "#90144"
1991msgid "Could not resolve playback URL"
1992msgstr "Не удалось разрешить URL воспроизведения"
1993
1994msgctxt "#90145"
1995msgid "Precaching Enabled"
1996msgstr "Предкэширование включено"
1997
1998msgctxt "#90146"
1999msgid "Enable or disable precaching of next episodes."
2000msgstr "Включить или выключить предкэширование следующих серий."
2001
2002msgctxt "#90147"
2003msgid "Precaching Episode Count"
2004msgstr "Количество предкэшируемых серий"
2005
2006msgctxt "#90148"
2007msgid "Number of next episodes to precache."
2008msgstr "Количество следующих серий для предкэширования."
2009
2010msgctxt "#30901"
2011msgid "Show Only Stremio Catalogs"
2012msgstr "Показывать только каталоги Stremio"
2013
2014msgctxt "#30902"
2015msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus."
2016msgstr "Скрывает встроенные элементы (TMDb, Trakt и т. д.) и показывает только каталоги Stremio в меню."
2017
2018msgctxt "#30903"
2019msgid "Select Live TV Addons"
2020msgstr "Выбрать аддоны Live TV"
2021
2022msgctxt "#30904"
2023msgid "Choose specific Stremio addons for Live TV content"
2024msgstr "Выберите конкретные аддоны Stremio для контента Live TV"
2025
2026msgctxt "#30920"
2027msgid "Clear Trakt Cache"
2028msgstr "Очистить кэш Trakt"
2029
2030msgctxt "#30921"
2031msgid "Clear TMDB Cache"
2032msgstr "Очистить кэш TMDB"
2033
2034msgctxt "#30922"
2035msgid "Clear Providers Cache"
2036msgstr "Очистить кэш провайдеров"
2037
2038msgctxt "#30923"
2039msgid "Clear Settings Cache"
2040msgstr "Очистить кэш настроек"
2041
2042msgctxt "#90149"
2043msgid "Maintenance"
2044msgstr "Обслуживание"
2045
2046msgctxt "#90150"
2047msgid "Stremio: General"
2048msgstr "Stremio: Общие"
2049
2050msgctxt "#90151"
2051msgid "Stremio: Account"
2052msgstr "Stremio: Аккаунт"
2053
2054msgctxt "#90152"
2055msgid "Stremio: Addons & Catalogs"
2056msgstr "Stremio: Аддоны и каталоги"
2057
2058msgctxt "#90153"
2059msgid "Stremio: Torrentio"
2060msgstr "Stremio: Torrentio"
2061
2062msgctxt "#90154"
2063msgid "Trending Recent"
2064msgstr "Популярные недавно"
2065
2066msgctxt "#90155"
2067msgid "Stremio: Filters"
2068msgstr "Stremio: Фильтры"
2069
2070msgctxt "#32300"
2071msgid "Filter by Quality"
2072msgstr "Фильтр по качеству"
2073
2074msgctxt "#32301"
2075msgid "Filter by Provider"
2076msgstr "Фильтр по провайдеру"
2077
2078msgctxt "#32302"
2079msgid "Filter by Source"
2080msgstr "Фильтр по источнику"
2081
2082msgctxt "#32303"
2083msgid "Filter by Language"
2084msgstr "Фильтр по языку"
2085
2086msgctxt "#32304"
2087msgid "Filter by Torrents"
2088msgstr "Фильтр по торрентам"
2089
2090msgctxt "#32305"
2091msgid "Filter by Debrid"
2092msgstr "Фильтр по Debrid"
2093
2094msgctxt "#32306"
2095msgid "Reset Filter"
2096msgstr "Сбросить фильтр"
2097
2098msgctxt "#30930"
2099msgid "Ping Addons"
2100msgstr "Пинг аддонов"
2101
2102msgctxt "#30931"
2103msgid "Check availability of addons and show only reachable ones"
2104msgstr "Проверить доступность аддонов и показывать только доступные"
2105
2106msgctxt "#90164"
2107msgid "Skip Intro / Recap"
2108msgstr "Пропустить интро / пересказ"
2109
2110msgctxt "#90165"
2111msgid "Enable Skip Intro"
2112msgstr "Включить пропуск интро"
2113
2114msgctxt "#90166"
2115msgid "Detect intros and recaps using IntroDB and offer to skip them during playback"
2116msgstr "Обнаруживать интро и пересказы с помощью IntroDB и предлагать пропустить их во время воспроизведения"
2117
2118msgctxt "#90167"
2119msgid "Auto-skip"
2120msgstr "Автопропуск"
2121
2122msgctxt "#90168"
2123msgid "Automatically skip intros and recaps without showing the button"
2124msgstr "Автоматически пропускать интро и пересказы без показа кнопки"
2125
2126msgctxt "#90160"
2127msgid "Skip Intro"
2128msgstr "Пропустить интро"
2129
2130msgctxt "#90161"
2131msgid "Skip Recap"
2132msgstr "Пропустить пересказ"
2133
2134msgctxt "#90162"
2135msgid "Manage from Phone"
2136msgstr "Управление с телефона"
2137
2138msgctxt "#90163"
2139msgid "Start a local web server and show a QR code to manage Stremio sources from your phone"
2140msgstr "Запустить локальный веб-сервер и показать QR-код для управления источниками Stremio с телефона"
2141
2142msgctxt "#90200"
2143msgid "Continue Watching"
2144msgstr "Продолжить просмотр"
2145
2146msgctxt "#90201"
2147msgid "Library"
2148msgstr "Библиотека"
2149
2150msgctxt "#90202"
2151msgid "My Shows"
2152msgstr "Мои шоу"
2153
2154msgctxt "#90203"
2155msgid "My Movies"
2156msgstr "Мои фильмы"
2157
2158msgctxt "#90204"
2159msgid "Remove from Library"
2160msgstr "Удалить из библиотеки"
2161
2162msgctxt "#90205"
2163msgid "Add to Library"
2164msgstr "Добавить в библиотеку"
2165
2166msgctxt "#90206"
2167msgid "Remove"
2168msgstr "Удалить"
2169
2170msgctxt "#90207"
2171msgid "Search Movies & TV"
2172msgstr "Поиск фильмов и ТВ"
2173
2174msgctxt "#90208"
2175msgid "Recent Searches"
2176msgstr "Недавние поиски"
2177
2178msgctxt "#90209"
2179msgid "Recent Direct Searches"
2180msgstr "Недавние прямые поиски"
2181
2182msgctxt "#90210"
2183msgid "Clear All Search History"
2184msgstr "Очистить всю историю поиска"
2185
2186msgctxt "#90211"
2187msgid "Bypass Stremio Addons Native Sorting"
2188msgstr "Обход нативной сортировки аддонов Stremio"
2189
2190msgctxt "#90212"
2191msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds."
2192msgstr "Если включено, результаты указанных аддонов не будут переупорядочиваться или фильтроваться нативными порогами качества и размера Jacktook."
2193
2194msgctxt "#90213"
2195msgid "Select Bypassed Addons"
2196msgstr "Выбрать аддоны для обхода"
2197
2198msgctxt "#90214"
2199msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering."
2200msgstr "Выберите, какие аддоны Stremio обходят нативную фильтрацию размера и качества Jacktook."
2201
2202msgctxt "#90215"
2203msgid "Downgrade"
2204msgstr "Понижение версии"
2205
2206msgctxt "#90671"
2207msgid "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails."
2208msgstr "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails."
2209
2210msgctxt "#30932"
2211msgid "Update Action"
2212msgstr "Действие при обновлении"
2213
2214msgctxt "#30933"
2215msgid "Choose how to handle background update checks."
2216msgstr "Выберите, как обрабатывать проверки фоновых обновлений."
2217
2218msgctxt "#30934"
2219msgid "Ask"
2220msgstr "Спрашивать"
2221
2222msgctxt "#30935"
2223msgid "Notify"
2224msgstr "Уведомлять"
2225
2226msgctxt "#90216"
2227msgid "Volume Check Enabled"
2228msgstr "Проверка громкости включена"
2229
2230msgctxt "#90217"
2231msgid "Automatically limits volume if it is above the threshold before playback."
2232msgstr "Автоматически ограничивает громкость, если она выше порога перед воспроизведением."
2233
2234msgctxt "#90218"
2235msgid "Volume Threshold"
2236msgstr "Порог громкости"
2237
2238msgctxt "#90219"
2239msgid "The volume percentage above which the warning triggers."
2240msgstr "Процент громкости, выше которого срабатывает предупреждение."
2241
2242msgctxt "#90220"
2243msgid "High Volume Warning"
2244msgstr "Предупреждение о высокой громкости"
2245
2246msgctxt "#90221"
2247msgid "High volume detected. Adjusting to %s%% for safety."
2248msgstr "Обнаружена высокая громкость. Регулировка до %s%% для безопасности."
2249
2250msgctxt "#90222"
2251msgid "Fanart.tv Configuration"
2252msgstr "Конфигурация Fanart.tv"
2253
2254msgctxt "#90223"
2255msgid "Client Key"
2256msgstr "Ключ клиента"
2257
2258msgctxt "#90224"
2259msgid "Your personal Fanart.tv client key for higher quality assets."
2260msgstr "Ваш личный ключ клиента Fanart.tv для ресурсов более высокого качества."
2261
2262msgctxt "#90225"
2263msgid "Debrid Notifications"
2264msgstr "Уведомления Debrid"
2265
2266msgctxt "#90226"
2267msgid "Debrid Expiration Notification"
2268msgstr "Уведомление об истечении Debrid"
2269
2270msgctxt "#90227"
2271msgid "Enable or disable notifications when your Debrid subscription is about to expire."
2272msgstr "Включить или выключить уведомления, когда срок вашей подписки Debrid подходит к концу."
2273
2274msgctxt "#90228"
2275msgid "Expiration Threshold (Days)"
2276msgstr "Порог истечения (дни)"
2277
2278msgctxt "#90229"
2279msgid "How many days before expiration to start notifying."
2280msgstr "За сколько дней до истечения начинать уведомление."
2281
2282msgctxt "#90230"
2283msgid "%s expires in %d days."
2284msgstr "%s истекает через %d дней."
2285
2286msgctxt "#90231"
2287msgid "%s expires today!"
2288msgstr "%s истекает сегодня!"
2289
2290msgctxt "#90232"
2291msgid "Easynews"
2292msgstr "Easynews"
2293
2294msgctxt "#90233"
2295msgid "Enable EasyNews as a direct stream provider."
2296msgstr "Включить EasyNews как провайдера прямого потока."
2297
2298msgctxt "#90234"
2299msgid "Your EasyNews username."
2300msgstr "Ваше имя пользователя EasyNews."
2301
2302msgctxt "#90235"
2303msgid "Your EasyNews password."
2304msgstr "Ваш пароль EasyNews."
2305
2306msgctxt "#90236"
2307msgid "Request timeout in seconds."
2308msgstr "Тайм-аут запроса в секундах."
2309
2310msgctxt "#90237"
2311msgid "Account Information"
2312msgstr "Информация об аккаунте"
2313
2314msgctxt "#90238"
2315msgid "Search Dialog Style"
2316msgstr "Стиль диалога поиска"
2317
2318msgctxt "#90239"
2319msgid "Background"
2320msgstr "Фон"
2321
2322msgctxt "#90240"
2323msgid "Detailed Status"
2324msgstr "Детальный статус"
2325
2326msgctxt "#90241"
2327msgid "Search Status"
2328msgstr "Статус поиска"
2329
2330msgctxt "#90242"
2331msgid "Cancel"
2332msgstr "Отмена"
2333
2334msgctxt "#90243"
2335msgid "Results found: "
2336msgstr "Найдено результатов: "
2337
2338msgctxt "#90244"
2339msgid "Pending"
2340msgstr "В ожидании"
2341
2342msgctxt "#90245"
2343msgid "In Progress"
2344msgstr "В процессе"
2345
2346msgctxt "#90246"
2347msgid "Completed"
2348msgstr "Завершено"
2349
2350msgctxt "#90247"
2351msgid "Failed"
2352msgstr "Ошибка"
2353
2354msgctxt "#90248"
2355msgid "Cancelled"
2356msgstr "Отменено"
2357
2358msgctxt "#90249"
2359msgid "Your personal Jackgram API token for authentication."
2360msgstr "Ваш личный токен API Jackgram для аутентификации."
2361
2362msgctxt "#90250"
2363msgid "Subtitles Found"
2364msgstr "Субтитры найдены"
2365
2366msgctxt "#90251"
2367msgid "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?"
2368msgstr "Для этого видео уже есть загруженные субтитры.\nВы хотите использовать существующие субтитры?"
2369
2370msgctxt "#90252"
2371msgid "No subtitles found"
2372msgstr "Субтитры не найдены"
2373
2374msgctxt "#90253"
2375msgid "No subtitles selected"
2376msgstr "Субтитры не выбраны"
2377
2378msgctxt "#90254"
2379msgid "Translate Subtitles"
2380msgstr "Перевести субтитры"
2381
2382msgctxt "#90255"
2383msgid "Do you want to also translate the selected subtitles?"
2384msgstr "Вы хотите также перевести выбранные субтитры?"
2385
2386msgctxt "#90256"
2387msgid "Select Subtitle to Download"
2388msgstr "Выбрать субтитры для загрузки"
2389
2390msgctxt "#90257"
2391msgid "Subtitles Loaded"
2392msgstr "Субтитры загружены"
2393
2394msgctxt "#90258"
2395msgid "Backup"
2396msgstr "Резервное копирование"
2397
2398msgctxt "#90259"
2399msgid "Export Backup"
2400msgstr "Экспорт бэкапа"
2401
2402msgctxt "#90260"
2403msgid "Export addon settings and custom Stremio addons to a JSON backup file."
2404msgstr "Экспорт настроек аддона и пользовательских аддонов Stremio в файл JSON."
2405
2406msgctxt "#90261"
2407msgid "Restore Backup"
2408msgstr "Восстановить бэкап"
2409
2410msgctxt "#90262"
2411msgid "Restore addon settings and replace custom Stremio addons from a JSON backup file."
2412msgstr "Восстановить настройки аддона и заменить пользовательские аддоны Stremio из файла JSON."
2413
2414msgctxt "#90263"
2415msgid "Remove Sensitive Data"
2416msgstr "Удалить конфиденциальные данные"
2417
2418msgctxt "#90264"
2419msgid "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups."
2420msgstr "Удалить ключи API, пароли, имена пользователей, email-адреса, состояние авторизации и URL-адреса пользовательских аддонов Stremio из экспортируемых бэкапов."
2421
2422msgctxt "#90265"
2423msgid "Settings backup exported"
2424msgstr "Бэкап настроек экспортирован"
2425
2426msgctxt "#90266"
2427msgid "Settings backup restored"
2428msgstr "Бэкап настроек восстановлен"
2429
2430msgctxt "#90267"
2431msgid "Settings Backup Failed"
2432msgstr "Сбой бэкапа настроек"
2433
2434msgctxt "#90268"
2435msgid "Select export folder"
2436msgstr "Выберите папку для экспорта"
2437
2438msgctxt "#90269"
2439msgid "Select backup file"
2440msgstr "Выберите файл бэкапа"
2441
2442msgctxt "#90270"
2443msgid "Backup file is invalid."
2444msgstr "Файл бэкапа недействителен."
2445
2446msgctxt "#90271"
2447msgid "No export folder selected."
2448msgstr "Папка для экспорта не выбрана."
2449
2450msgctxt "#90272"
2451msgid "No backup file selected."
2452msgstr "Файл бэкапа не выбран."
2453
2454msgctxt "#90273"
2455msgid "Unable to export settings backup."
2456msgstr "Не удалось экспортировать бэкап настроек."
2457
2458msgctxt "#90274"
2459msgid "Unable to restore settings backup."
2460msgstr "Не удалось восстановить бэкап настроек."
2461
2462msgctxt "#90275"
2463msgid "Restore Backup"
2464msgstr "Восстановить бэкап"
2465
2466msgctxt "#90276"
2467msgid "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?"
2468msgstr "Это перезапишет ваши текущие настройки аддона и заменит ваши пользовательские аддоны Stremio данными из бэкапа. Продолжить?"
2469
2470msgctxt "#90277"
2471msgid "Restore cancelled"
2472msgstr "Восстановление отменено"
2473
2474msgctxt "#90278"
2475msgid "Reset All Settings"
2476msgstr "Сбросить все настройки"
2477
2478msgctxt "#90279"
2479msgid "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?"
2480msgstr "Это сбросит все настройки аддона к значениям по умолчанию и удалит ваши пользовательские аддоны Stremio. Продолжить?"
2481
2482msgctxt "#90280"
2483msgid "Reset cancelled"
2484msgstr "Сброс отменен"
2485
2486msgctxt "#90281"
2487msgid "All addon settings reset"
2488msgstr "Все настройки аддона сброшены"
2489
2490msgctxt "#90282"
2491msgid "Reset Failed"
2492msgstr "Сброс не удался"
2493
2494msgctxt "#90283"
2495msgid "Unable to reset addon settings."
2496msgstr "Не удалось сбросить настройки аддона."
2497
2498msgctxt "#90284"
2499msgid "Reset every addon setting back to its default value and clear custom Stremio addons."
2500msgstr "Сбросить каждую настройку аддона к значению по умолчанию и очистить пользовательские аддоны Stremio."
2501
2502msgctxt "#90285"
2503msgid "Factory Reset"
2504msgstr "Заводской сброс"
2505
2506msgctxt "#90286"
2507msgid "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?"
2508msgstr "Это сбросит настройки, очистит кэши, удалит пользовательские аддоны Stremio и сотрет локальную историю аддона и данные базы данных. Продолжить?"
2509
2510msgctxt "#90287"
2511msgid "Factory reset cancelled"
2512msgstr "Заводской сброс отменен"
2513
2514msgctxt "#90288"
2515msgid "Factory reset completed"
2516msgstr "Заводской сброс завершен"
2517
2518msgctxt "#90289"
2519msgid "Factory Reset Failed"
2520msgstr "Заводской сброс не удался"
2521
2522msgctxt "#90290"
2523msgid "Unable to complete factory reset."
2524msgstr "Не удалось выполнить заводской сброс."
2525
2526msgctxt "#90291"
2527msgid "Reset settings to defaults, clear caches, and wipe local addon history/database data."
2528msgstr "Сбросить настройки к значениям по умолчанию, очистить кэши и стереть локальную историю аддона и данные базы данных."
2529
2530msgctxt "#90292"
2531msgid "Trakt Library"
2532msgstr "Библиотека Trakt"
2533
2534msgctxt "#90293"
2535msgid "Trakt Discovery"
2536msgstr "Дискавери Trakt"
2537
2538msgctxt "#90294"
2539msgid "Collection"
2540msgstr "Коллекция"
2541
2542msgctxt "#90295"
2543msgid "Favorites"
2544msgstr "Избранное"
2545
2546msgctxt "#90296"
2547msgid "Up Next"
2548msgstr "Далее"
2549
2550msgctxt "#90297"
2551msgid "My Lists"
2552msgstr "Мои списки"
2553
2554msgctxt "#90298"
2555msgid "Liked Lists"
2556msgstr "Понравившиеся списки"
2557
2558msgctxt "#90331"
2559msgid "Stremio Addons"
2560msgstr "Аддоны Stremio"
2561
2562msgctxt "#90332"
2563msgid "No addons available yet. Use Log In to Stremio or Add Custom Addon first."
2564msgstr "Аддоны пока не доступны. Сначала войдите в Stremio или добавьте пользовательский аддон."
2565
2566msgctxt "#90333"
2567msgid "Stremio Stream Addons"
2568msgstr "Аддоны потоков Stremio"
2569
2570msgctxt "#90334"
2571msgid "Stremio Catalog Addons"
2572msgstr "Аддоны каталогов Stremio"
2573
2574msgctxt "#90335"
2575msgid "Stremio Live TV Addons"
2576msgstr "Аддоны Live TV Stremio"
2577
2578msgctxt "#90336"
2579msgid "Refresh addons imported from your Stremio account. Custom addons remain unchanged."
2580msgstr "Обновить аддоны, импортированные из вашего аккаунта Stremio. Пользовательские аддоны останутся без изменений."
2581
2582msgctxt "#90337"
2583msgid "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first."
2584msgstr "Выберите, какие импортированные или пользовательские аддоны потоков Stremio использовать. Если они еще не доступны, сначала войдите в систему или добавьте пользовательский аддон."
2585
2586msgctxt "#90338"
2587msgid "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first."
2588msgstr "Выберите, какие импортированные или пользовательские аддоны каталогов Stremio использовать. Если они еще не доступны, сначала войдите в систему или добавьте пользовательский аддон."
2589
2590msgctxt "#90339"
2591msgid "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first."
2592msgstr "Выберите, какие импортированные или пользовательские аддоны Live TV Stremio использовать. Если они еще не доступны, сначала войдите в систему или добавьте пользовательский аддон."
2593
2594msgctxt "#90340"
2595msgid "Remove custom Stremio addons you previously added manually."
2596msgstr "Удалить пользовательские аддоны Stremio, которые вы ранее добавили вручную."
2597
2598msgctxt "#90341"
2599msgid "Choose Torrent Client"
2600msgstr "Выберите торрент-клиент"
2601
2602msgctxt "#90342"
2603msgid "Clear Database Cache"
2604msgstr "Очистить кэш базы данных"
2605
2606msgctxt "#90343"
2607msgid "Clear Stremio Cache"
2608msgstr "Очистить кэш Stremio"
2609
2610msgctxt "#90344"
2611msgid "Clear Debrid Cache"
2612msgstr "Очистить кэш Debrid"
2613
2614msgctxt "#90345"
2615msgid "Clear MDBList Cache"
2616msgstr "Очистить кэш MDBList"
2617
2618msgctxt "#90346"
2619msgid "Restore backup from"
2620msgstr "Восстановить бэкап из"
2621
2622msgctxt "#90347"
2623msgid "File"
2624msgstr "Файл"
2625
2626msgctxt "#90348"
2627msgid "URL"
2628msgstr "URL"
2629
2630msgctxt "#90349"
2631msgid "Enter backup URL"
2632msgstr "Введите URL бэкапа"
2633
2634msgctxt "#90350"
2635msgid "No backup URL provided."
2636msgstr "URL бэкапа не предоставлен."
2637
2638msgctxt "#90351"
2639msgid "Unable to download settings backup."
2640msgstr "Не удалось загрузить бэкап настроек."
2641
2642msgctxt "#90352"
2643msgid "Source Select Style"
2644msgstr "Стиль выбора источника"
2645
2646msgctxt "#90353"
2647msgid "Modern"
2648msgstr "Современный"
2649
2650msgctxt "#90354"
2651msgid "OLED"
2652msgstr "OLED"
2653
2654msgctxt "#90355"
2655msgid "Cyberpunk"
2656msgstr "Киберпанк"
2657
2658msgctxt "#90356"
2659msgid "Forest"
2660msgstr "Лес"
2661
2662msgctxt "#90357"
2663msgid "Gold"
2664msgstr "Золото"
2665
2666msgctxt "#90358"
2667msgid "No playable results found"
2668msgstr "Воспроизводимые результаты не найдены"
2669
2670msgctxt "#90359"
2671msgid "Add to TorrServer"
2672msgstr "Добавить в TorrServer"
2673
2674msgctxt "#90360"
2675msgid "Added to TorrServer"
2676msgstr "Добавлено в TorrServer"
2677
2678msgctxt "#90361"
2679msgid "Could not resolve torrent details"
2680msgstr "Не удалось разрешить детали торрента"
2681
2682msgctxt "#90362"
2683msgid "Added to %s cloud"
2684msgstr "Добавлено в облако %s"
2685
2686msgctxt "#90363"
2687msgid "Choose Debrid Service"
2688msgstr "Выберите сервис Debrid"
2689
2690msgctxt "#90364"
2691msgid "No supported debrid service is enabled"
2692msgstr "Ни один поддерживаемый сервис debrid не включен"
2693
2694msgctxt "#90365"
2695msgid "Download to Debrid"
2696msgstr "Загрузить в Debrid"
2697
2698msgctxt "#90366"
2699msgid "Include Unknown quality"
2700msgstr "Включить неизвестное качество"
2701
2702msgctxt "#90367"
2703msgid "Includes releases where video quality could not be identified"
2704msgstr "Включает релизы, качество видео в которых не удалось определить"
2705
2706msgctxt "#90368"
2707msgid "Search by Keyword"
2708msgstr "Поиск по ключевому слову"
2709
2710msgctxt "#90369"
2711msgid "Latest Movies"
2712msgstr "Последние фильмы"
2713
2714msgctxt "#90370"
2715msgid "Latest Series"
2716msgstr "Последние сериалы"
2717
2718msgctxt "#90371"
2719msgid "Raw Files"
2720msgstr "Исходные файлы"
2721
2722msgctxt "#90372"
2723msgid "MDblist"
2724msgstr "MDblist"
2725
2726msgctxt "#90373"
2727msgid "Active Downloads"
2728msgstr "Активные загрузки"
2729
2730msgctxt "#90374"
2731msgid "Cancel Download"
2732msgstr "Отменить загрузку"
2733
2734msgctxt "#90375"
2735msgid "Delete File"
2736msgstr "Удалить файл"
2737
2738msgctxt "#90376"
2739msgid "Search Collections"
2740msgstr "Поиск коллекций"
2741
2742msgctxt "#90377"
2743msgid "Popular Collections"
2744msgstr "Популярные коллекции"
2745
2746msgctxt "#90378"
2747msgid "Top Rated Collections"
2748msgstr "Лучшие коллекции"
2749
2750msgctxt "#90379"
2751msgid "Recommendations"
2752msgstr "Рекомендации"
2753
2754msgctxt "#90380"
2755msgid "Similar"
2756msgstr "Похожие"
2757
2758msgctxt "#90381"
2759msgid "Keyword: %s"
2760msgstr "Ключевое слово: %s"
2761
2762msgctxt "#90410"
2763msgid "Error modifying Trakt Watchlist"
2764msgstr "Ошибка изменения списка наблюдения Trakt"
2765
2766msgctxt "#90411"
2767msgid "Marked as Watched on Trakt"
2768msgstr "Отмечено как просмотренное на Trakt"
2769
2770msgctxt "#90412"
2771msgid "Error modifying Trakt Watched status"
2772msgstr "Ошибка изменения статуса просмотра на Trakt"
2773
2774msgctxt "#90413"
2775msgid "Play"
2776msgstr "Воспроизвести"
2777
2778msgctxt "#90414"
2779msgid "Trailer"
2780msgstr "Трейлер"
2781
2782msgctxt "#90415"
2783msgid "Watched"
2784msgstr "Просмотрено"
2785
2786msgctxt "#90416"
2787msgid "Refresh"
2788msgstr "Обновить"
2789
2790msgctxt "#90418"
2791msgid "No credits found for this ID"
2792msgstr "Кредиты для этого ID не найдены"
2793
2794msgctxt "#90419"
2795msgid "Credited People"
2796msgstr "Актеры и съемочная группа"
2797
2798msgctxt "#90420"
2799msgid "Person Details"
2800msgstr "Сведения о человеке"
2801
2802msgctxt "#90421"
2803msgid "Person not found"
2804msgstr "Человек не найден"
2805
2806msgctxt "#90422"
2807msgid "Jackgram not activated"
2808msgstr "Индексатор Jackgram не активирован."
2809
2810msgctxt "#90423"
2811msgid "Not yet implemented"
2812msgstr "Еще не реализовано"
2813
2814msgctxt "#90424"
2815msgid "Unsupported debrid type"
2816msgstr "Неподдерживаемый тип debrid"
2817
2818msgctxt "#90425"
2819msgid "Easynews credentials required"
2820msgstr "Требуются учетные данные Easynews"
2821
2822msgctxt "#90426"
2823msgid "No Corrupt or Missing Databases"
2824msgstr "Нет поврежденных или отсутствующих баз данных"
2825
2826msgctxt "#90427"
2827msgid "Otaku (script.otaku.mappings) not found"
2828msgstr "Otaku (script.otaku.mappings) не найден"
2829
2830msgctxt "#90431"
2831msgid "No items found in this list"
2832msgstr "В этом списке ничего не найдено"
2833
2834msgctxt "#90432"
2835msgid "Databases Rebuilt"
2836msgstr "Базы данных пересозданы"
2837
2838msgctxt "#90433"
2839msgid "[B]Following Databases Rebuilt:[/B][CR][CR]%s"
2840msgstr "[B]Были пересозданы следующие базы данных:[/B][CR][CR]%s"
2841
2842msgctxt "#90434"
2843msgid "Added to Trakt watchlist"
2844msgstr "Добавлено в список просмотра Trakt"
2845
2846msgctxt "#90435"
2847msgid "Failed to add to Trakt watchlist"
2848msgstr "Не удалось добавить в список просмотра Trakt"
2849
2850msgctxt "#90436"
2851msgid "Removed from Trakt watchlist"
2852msgstr "Удалено из списка просмотра Trakt"
2853
2854msgctxt "#90437"
2855msgid "Failed to remove from Trakt watchlist"
2856msgstr "Не удалось удалить из списка просмотра Trakt"
2857
2858msgctxt "#90438"
2859msgid "Failed to mark as watched on Trakt"
2860msgstr "Не удалось отметить как просмотренное в Trakt"
2861
2862msgctxt "#90439"
2863msgid "Marked as unwatched on Trakt"
2864msgstr "Отмечено как непросмотренное в Trakt"
2865
2866msgctxt "#90440"
2867msgid "Failed to mark as unwatched on Trakt"
2868msgstr "Не удалось отметить как непросмотренное в Trakt"
2869
2870msgctxt "#90441"
2871msgid "Added to Trakt collection"
2872msgstr "Добавлено в коллекцию Trakt"
2873
2874msgctxt "#90442"
2875msgid "Removed from Trakt collection"
2876msgstr "Удалено из коллекции Trakt"
2877
2878msgctxt "#90443"
2879msgid "Failed to add to Trakt collection"
2880msgstr "Не удалось добавить в коллекцию Trakt"
2881
2882msgctxt "#90444"
2883msgid "Failed to remove from Trakt collection"
2884msgstr "Не удалось удалить из коллекции Trakt"
2885
2886msgctxt "#90445"
2887msgid "Added to Trakt favorites"
2888msgstr "Добавлено в избранное Trakt"
2889
2890msgctxt "#90446"
2891msgid "Removed from Trakt favorites"
2892msgstr "Удалено из избранного Trakt"
2893
2894msgctxt "#90447"
2895msgid "Failed to add to Trakt favorites"
2896msgstr "Не удалось добавить в избранное Trakt"
2897
2898msgctxt "#90448"
2899msgid "Failed to remove from Trakt favorites"
2900msgstr "Не удалось удалить из избранного Trakt"
2901
2902msgctxt "#90449"
2903msgid "Created Trakt list"
2904msgstr "Список Trakt создан"
2905
2906msgctxt "#90450"
2907msgid "Failed to create Trakt list"
2908msgstr "Не удалось создать список Trakt"
2909
2910msgctxt "#90451"
2911msgid "Deleted Trakt list"
2912msgstr "Список Trakt удален"
2913
2914msgctxt "#90452"
2915msgid "Failed to delete Trakt list"
2916msgstr "Не удалось удалить список Trakt"
2917
2918msgctxt "#90453"
2919msgid "Liked Trakt list"
2920msgstr "Лайк поставлен списку Trakt"
2921
2922msgctxt "#90454"
2923msgid "Failed to like Trakt list"
2924msgstr "Не удалось поставить лайк списку Trakt"
2925
2926msgctxt "#90455"
2927msgid "Unliked Trakt list"
2928msgstr "Лайк со списка Trakt снят"
2929
2930msgctxt "#90456"
2931msgid "Failed to unlike Trakt list"
2932msgstr "Не удалось снять лайк со списка Trakt"
2933
2934msgctxt "#90457"
2935msgid "Added to Trakt list"
2936msgstr "Добавлено в список Trakt"
2937
2938msgctxt "#90459"
2939msgid "Failed to add to Trakt list"
2940msgstr "Не удалось добавить в список Trakt"
2941
2942msgctxt "#90461"
2943msgid "No Trakt lists found"
2944msgstr "Списки Trakt не найдены"
2945
2946msgctxt "#90462"
2947msgid "Item not found in Trakt collection"
2948msgstr "Элемент не найден в коллекции Trakt"
2949
2950msgctxt "#90463"
2951msgid "Trakt could not match this item for collection"
2952msgstr "Trakt не смог сопоставить этот элемент с коллекцией"
2953
2954msgctxt "#90464"
2955msgid "No Trakt collection changes were applied"
2956msgstr "Изменения в коллекции Trakt не были применены"
2957
2958msgctxt "#90465"
2959msgid "Could not build Trakt collection payload for show"
2960msgstr "Не удалось сформировать payload коллекции Trakt для сериала"
2961
2962msgctxt "#90466"
2963msgid "Delete this Trakt list?"
2964msgstr "Удалить этот список Trakt?"
2965
2966msgctxt "#90510"
2967msgid "No meta available"
2968msgstr "Метаданные недоступны"
2969
2970msgctxt "#90511"
2971msgid "No content available for"
2972msgstr "Нет доступного контента для"
2973
2974msgctxt "#90512"
2975msgid "Season"
2976msgstr "Сезон"
2977
2978msgctxt "#90513"
2979msgid "No episodes available"
2980msgstr "Эпизоды недоступны"
2981
2982msgctxt "#90514"
2983msgid "No videos available"
2984msgstr "Видео недоступны"
2985
2986msgctxt "#90515"
2987msgid "Next Page"
2988msgstr "Следующая страница"
2989
2990msgctxt "#90516"
2991msgid "No metas available"
2992msgstr "Метаданные недоступны"
2993
2994msgctxt "#90517"
2995msgid "No seasons available"
2996msgstr "Сезоны недоступны"
2997
2998msgctxt "#90518"
2999msgid "Invalid query"
3000msgstr "Недопустимый запрос"
3001
3002msgctxt "#90519"
3003msgid "No lists found"
3004msgstr "Списки не найдены"
3005
3006msgctxt "#90520"
3007msgid "No user lists found"
3008msgstr "Пользовательские списки не найдены"
3009
3010msgctxt "#90521"
3011msgid "No top lists found"
3012msgstr "Лучшие списки не найдены"
3013
3014msgctxt "#90522"
3015msgid "Custom Addon"
3016msgstr "Пользовательский аддон"
3017
3018msgctxt "#90523"
3019msgid "Enter the custom Stremio addon URL"
3020msgstr "Введите URL пользовательского аддона Stremio"
3021
3022msgctxt "#90524"
3023msgid "No URL provided."
3024msgstr "URL не указан."
3025
3026msgctxt "#90525"
3027msgid "Failed to fetch manifest: %s"
3028msgstr "Не удалось получить манифест: %s"
3029
3030msgctxt "#90526"
3031msgid "Manifest missing 'id' or 'name'."
3032msgstr "В манифесте отсутствует 'id' или 'name'."
3033
3034msgctxt "#90527"
3035msgid "Custom Stremio addon added successfully!"
3036msgstr "Пользовательский аддон Stremio успешно добавлен!"
3037
3038msgctxt "#90528"
3039msgid "Addon does not provide 'stream' or 'catalog' resources."
3040msgstr "Аддон не предоставляет ресурсы 'stream' или 'catalog'."
3041
3042msgctxt "#90529"
3043msgid "This addon is already added to your list."
3044msgstr "Этот аддон уже добавлен в ваш список."
3045
3046msgctxt "#90530"
3047msgid "Failed to add custom addon: %s"
3048msgstr "Не удалось добавить пользовательский аддон: %s"
3049
3050msgctxt "#90531"
3051msgid "Remove Addon"
3052msgstr "Удалить аддон"
3053
3054msgctxt "#90532"
3055msgid "No custom Stremio addons to remove."
3056msgstr "Нет пользовательских аддонов Stremio для удаления."
3057
3058msgctxt "#90533"
3059msgid "Remove Stremio Addons"
3060msgstr "Удалить аддоны Stremio"
3061
3062msgctxt "#90534"
3063msgid "Selected addon(s) removed."
3064msgstr "Выбранные аддоны удалены."
3065
3066msgctxt "#90535"
3067msgid "Untitled List"
3068msgstr "Список без названия"
3069
3070msgctxt "#90536"
3071msgid "Removed from Trakt list"
3072msgstr "Удалено из списка Trakt"
3073
3074msgctxt "#90537"
3075msgid "Failed to remove from Trakt list"
3076msgstr "Не удалось удалить из списка Trakt"
3077
3078msgctxt "#90538"
3079msgid "Resume from %.1f%%"
3080msgstr "Продолжить с %.1f%%"
3081
3082msgctxt "#90539"
3083msgid "Start from Beginning"
3084msgstr "Начать сначала"
3085
3086msgctxt "#90540"
3087msgid "Authorize Debrid Services"
3088msgstr "Авторизовать сервисы Debrid"
3089
3090msgctxt "#90541"
3091msgid "Navigate to: [B]%s[/B]"
3092msgstr "Перейдите по адресу: [B]%s[/B]"
3093
3094msgctxt "#90542"
3095msgid "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]"
3096msgstr "Введите следующий код: [COLOR orangered][B]%s[/B][/COLOR]"
3097
3098msgctxt "#90543"
3099msgid "Premiumize Auth"
3100msgstr "Авторизация Premiumize"
3101
3102msgctxt "#90544"
3103msgid "Success"
3104msgstr "Успешно"
3105
3106msgctxt "#90545"
3107msgid "Authentication completed."
3108msgstr "Аутентификация завершена."
3109
3110msgctxt "#90546"
3111msgid "Error:"
3112msgstr "Ошибка:"
3113
3114msgctxt "#90547"
3115msgid "Error: %s"
3116msgstr "Ошибка: %s"
3117
3118msgctxt "#90548"
3119msgid "Auth Error"
3120msgstr "Ошибка авторизации"
3121
3122msgctxt "#90549"
3123msgid "Torbox authentication successful."
3124msgstr "Аутентификация Torbox успешно завершена."
3125
3126msgctxt "#90554"
3127msgid "Please wait..."
3128msgstr "Пожалуйста, подождите..."
3129
3130msgctxt "#90555"
3131msgid "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3132msgstr "Перейдите:\n[COLOR cyan]%s[/COLOR]\nВведите код: [COLOR seagreen][B]%s[/B][/COLOR]"
3133
3134msgctxt "#90556"
3135msgid "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3136msgstr "Перейдите:\n[COLOR cyan]%s[/COLOR]\n\nВведите код: [COLOR seagreen][B]%s[/B][/COLOR]"
3137
3138msgctxt "#90557"
3139msgid "Pastebin Link:\n[COLOR cyan]%s[/COLOR]"
3140msgstr "Ссылка Pastebin:\n[COLOR cyan]%s[/COLOR]"
3141
3142msgctxt "#90670"
3143msgid "Local URL:\n[COLOR cyan]%s[/COLOR]"
3144msgstr "Локальный URL:\n[COLOR cyan]%s[/COLOR]"
3145
3146msgctxt "#90558"
3147msgid "QR Code Authentication"
3148msgstr "Аутентификация по QR-коду"
3149
3150msgctxt "#90559"
3151msgid "Kodi 20 or above required. Please update Kodi to use this addon."
3152msgstr "Требуется Kodi 20 или выше. Обновите Kodi, чтобы использовать этот аддон."
3153
3154msgctxt "#90560"
3155msgid "Failed to start web server. Port may be in use."
3156msgstr "Не удалось запустить веб-сервер. Возможно, порт уже используется."
3157
3158msgctxt "#90561"
3159msgid "Authentification Removed."
3160msgstr "Аутентификация удалена."
3161
3162msgctxt "#90562"
3163msgid "WebDAV Error"
3164msgstr "Ошибка WebDAV"
3165
3166msgctxt "#90563"
3167msgid "Trakt Authorization"
3168msgstr "Авторизация Trakt"
3169
3170msgctxt "#90564"
3171msgid "Do you want to continue transfer in background?"
3172msgstr "Хотите продолжить передачу в фоновом режиме?"
3173
3174msgctxt "#90565"
3175msgid "Cloud Transfer"
3176msgstr "Передача в облако"
3177
3178msgctxt "#90566"
3179msgid "Kodi Logs"
3180msgstr "Журналы Kodi"
3181
3182msgctxt "#90567"
3183msgid "Show Logs"
3184msgstr "Показать журналы"
3185
3186msgctxt "#90568"
3187msgid "Export to paste.kodi.tv"
3188msgstr "Экспортировать в paste.kodi.tv"
3189
3190msgctxt "#90569"
3191msgid "Kodi Logs Exported"
3192msgstr "Журналы Kodi экспортированы"
3193
3194msgctxt "#90570"
3195msgid "Providers Selection Dialog"
3196msgstr "Диалог выбора провайдеров"
3197
3198msgctxt "#90571"
3199msgid "Your current Providers are: \n%s\n\nDo you want to change?"
3200msgstr "Текущие провайдеры: \n%s\n\nХотите изменить?"
3201
3202msgctxt "#90572"
3203msgid "Select Providers"
3204msgstr "Выберите провайдеров"
3205
3206msgctxt "#90573"
3207msgid "Selection Dialog"
3208msgstr "Диалог выбора"
3209
3210msgctxt "#90574"
3211msgid "Successfully selected: %s"
3212msgstr "Успешно выбрано: %s"
3213
3214msgctxt "#90575"
3215msgid "Selection"
3216msgstr "Выбор"
3217
3218msgctxt "#90576"
3219msgid "No providers selected"
3220msgstr "Провайдеры не выбраны"
3221
3222msgctxt "#90577"
3223msgid "Changelog"
3224msgstr "Список изменений"
3225
3226msgctxt "#90578"
3227msgid "[B]Unable to check for updates[/B]"
3228msgstr "[B]Не удалось проверить обновления[/B]"
3229
3230msgctxt "#90579"
3231msgid "[B]No update available[/B]"
3232msgstr "[B]Обновлений нет[/B]"
3233
3234msgctxt "#90580"
3235msgid "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]"
3236msgstr "Установлено: [B]%s[/B][CR]Доступно онлайн: [B]%s[/B][CR][CR]"
3237
3238msgctxt "#90581"
3239msgid "[B]Update available. Do you want to update?[/B]"
3240msgstr "[B]Доступно обновление. Хотите обновить?[/B]"
3241
3242msgctxt "#90582"
3243msgid "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]"
3244msgstr "[B]Доступно обновление (v%s).[/B] [I]Проверьте настройки для обновления.[/I]"
3245
3246msgctxt "#90583"
3247msgid "Error fetching versions: %s"
3248msgstr "Ошибка получения версий: %s"
3249
3250msgctxt "#90584"
3251msgid "No older/different versions found."
3252msgstr "Старые или отличающиеся версии не найдены."
3253
3254msgctxt "#90585"
3255msgid "Select Version to Downgrade"
3256msgstr "Выберите версию для отката"
3257
3258msgctxt "#90586"
3259msgid "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?"
3260msgstr "Вы уверены, что хотите откатить или переустановить версию [B]%s[/B]?"
3261
3262msgctxt "#90587"
3263msgid "[B]Downloading update...[/B]"
3264msgstr "[B]Загрузка обновления...[/B]"
3265
3266msgctxt "#90588"
3267msgid "Error: Unable to download update."
3268msgstr "Ошибка: не удалось загрузить обновление."
3269
3270msgctxt "#90589"
3271msgid "Error saving update file: %s"
3272msgstr "Ошибка сохранения файла обновления: %s"
3273
3274msgctxt "#90590"
3275msgid "Error extracting update. Please install manually."
3276msgstr "Ошибка распаковки обновления. Установите вручную."
3277
3278msgctxt "#90591"
3279msgid "Do you want to view the changelog for the new version?"
3280msgstr "Хотите посмотреть список изменений новой версии?"
3281
3282msgctxt "#90592"
3283msgid "New Release v%s - Changelog"
3284msgstr "Новый релиз v%s - Список изменений"
3285
3286msgctxt "#90593"
3287msgid "[B]Update complete.[/B]"
3288msgstr "[B]Обновление завершено.[/B]"
3289
3290msgctxt "#90594"
3291msgid "Real Debrid Auth"
3292msgstr "Авторизация Real Debrid"
3293
3294msgctxt "#90595"
3295msgid "Torbox Auth"
3296msgstr "Авторизация Torbox"
3297
3298msgctxt "#90596"
3299msgid "Debrider Auth"
3300msgstr "Авторизация Debrider"
3301
3302msgctxt "#90597"
3303msgid "Download Error"
3304msgstr "Ошибка загрузки"
3305
3306msgctxt "#90598"
3307msgid "Failed to download text: %s"
3308msgstr "Не удалось загрузить текст: %s"
3309
3310msgctxt "#90599"
3311msgid "General Error"
3312msgstr "Общая ошибка"
3313
3314msgctxt "#90600"
3315msgid "Could not display text file."
3316msgstr "Не удалось отобразить текстовый файл."
3317
3318msgctxt "#90601"
3319msgid "Failed to export logs."
3320msgstr "Не удалось экспортировать журналы."
3321
3322msgctxt "#90602"
3323msgid "Error reading log: %s"
3324msgstr "Ошибка чтения журнала: %s"
3325
3326msgctxt "#90603"
3327msgid "Kodi log file not found."
3328msgstr "Файл журнала Kodi не найден."
3329
3330msgctxt "#90604"
3331msgid "%s Auth"
3332msgstr "Авторизация %s"
3333
3334msgctxt "#90605"
3335msgid "Ok"
3336msgstr "Ок"
3337
3338msgctxt "#90606"
3339msgid "No"
3340msgstr "Нет"
3341
3342msgctxt "#90607"
3343msgid "Pinging addons for availability..."
3344msgstr "Проверка доступности аддонов..."
3345
3346msgctxt "#90608"
3347msgid "Pinging addons... %d/%d"
3348msgstr "Проверка аддонов... %d/%d"
3349
3350msgctxt "#90609"
3351msgid "Ping Results"
3352msgstr "Результаты ping"
3353
3354msgctxt "#90610"
3355msgid "Reachable: %d\nUnreachable: %d"
3356msgstr "Доступно: %d\nНедоступно: %d"
3357
3358msgctxt "#90611"
3359msgid "Select Category to Filter"
3360msgstr "Выберите категорию для фильтрации"
3361
3362msgctxt "#90612"
3363msgid "Stremio Add-ons Import"
3364msgstr "Импорт аддонов Stremio"
3365
3366msgctxt "#90613"
3367msgid "To import your add-ons, please log in with your Stremio email and password."
3368msgstr "Чтобы импортировать ваши аддоны, войдите с помощью email и пароля Stremio."
3369
3370msgctxt "#90614"
3371msgid "Enter your Email"
3372msgstr "Введите ваш email"
3373
3374msgctxt "#90615"
3375msgid "Enter your Password"
3376msgstr "Введите ваш пароль"
3377
3378msgctxt "#90616"
3379msgid "Login Failed"
3380msgstr "Ошибка входа"
3381
3382msgctxt "#90617"
3383msgid "Failed to login: %s"
3384msgstr "Не удалось войти: %s"
3385
3386msgctxt "#90618"
3387msgid "Add-ons Import Failed"
3388msgstr "Ошибка импорта аддонов"
3389
3390msgctxt "#90619"
3391msgid "Please try again later and report the issue if the problem persists. For more details, check the log file."
3392msgstr "Пожалуйста, попробуйте позже и сообщите о проблеме, если она не исчезнет. Подробности смотрите в файле журнала."
3393
3394msgctxt "#90620"
3395msgid "Addons Imported"
3396msgstr "Аддоны импортированы"
3397
3398msgctxt "#90621"
3399msgid "Successfully imported addons from your account."
3400msgstr "Аддоны из вашей учетной записи успешно импортированы."
3401
3402msgctxt "#90622"
3403msgid "Update Stremio Addons"
3404msgstr "Обновить аддоны Stremio"
3405
3406msgctxt "#90623"
3407msgid "Do you want to update the Addons from your account?"
3408msgstr "Хотите обновить аддоны из вашей учетной записи?"
3409
3410msgctxt "#90624"
3411msgid "Log Out from Stremio"
3412msgstr "Выйти из Stremio"
3413
3414msgctxt "#90625"
3415msgid "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."
3416msgstr "Вы уверены, что хотите выйти? Вы можете продолжать использовать Stremio без входа, но настройки будут сброшены к значениям по умолчанию."
3417
3418msgctxt "#90626"
3419msgid "Log Out"
3420msgstr "Выйти"
3421
3422msgctxt "#90627"
3423msgid "Use Existing"
3424msgstr "Использовать существующие"
3425
3426msgctxt "#90628"
3427msgid "Download New"
3428msgstr "Скачать новые"
3429
3430msgctxt "#90629"
3431msgid "Playback Failed"
3432msgstr "Ошибка воспроизведения"
3433
3434msgctxt "#90630"
3435msgid "The video could not be played.\nDo you want to retry?"
3436msgstr "Видео не удалось воспроизвести.\nХотите попробовать снова?"
3437
3438msgctxt "#90631"
3439msgid "Retry"
3440msgstr "Повторить"
3441
3442msgctxt "#90632"
3443msgid "Information"
3444msgstr "Информация"
3445
3446msgctxt "#90633"
3447msgid "Review"
3448msgstr "Обзор"
3449
3450msgctxt "#90634"
3451msgid "Comment"
3452msgstr "Комментарий"
3453
3454msgctxt "#90635"
3455msgid "Trivia"
3456msgstr "Факты"
3457
3458msgctxt "#90636"
3459msgid "Goof"
3460msgstr "Ляп"
3461
3462msgctxt "#90637"
3463msgid "Parental Guide"
3464msgstr "Родительский контроль"
3465
3466msgctxt "#90638"
3467msgid "Converting Magnet...\n\n%s"
3468msgstr "Преобразование magnet...\n\n%s"
3469
3470msgctxt "#90639"
3471msgid "Real Debrid Error."
3472msgstr "Ошибка Real Debrid."
3473
3474msgctxt "#90640"
3475msgid "File already cached"
3476msgstr "Файл уже в кеше"
3477
3478msgctxt "#90641"
3479msgid "Saving file to the Real Debrid Cloud"
3480msgstr "Сохранение файла в облако Real Debrid"
3481
3482msgctxt "#90642"
3483msgid "File cached"
3484msgstr "Файл в кеше"
3485
3486msgctxt "#90643"
3487msgid "Saving File to the Real Debrid Cloud...\n%s\n\n"
3488msgstr "Сохранение файла в облако Real Debrid...\n%s\n\n"
3489
3490msgctxt "#90644"
3491msgid "Downloading %s GB @ %s mbps from %s peers, %s %% completed"
3492msgstr "Загрузка %s ГБ @ %s mbps от %s пиров, %s %% завершено"
3493
3494msgctxt "#90645"
3495msgid "Trakt"
3496msgstr "Trakt"
3497
3498msgctxt "#90651"
3499msgid "Easynews Account Info"
3500msgstr "Информация об аккаунте Easynews"
3501
3502msgctxt "#90652"
3503msgid "DeepL API Error"
3504msgstr "Ошибка API DeepL"
3505
3506msgctxt "#90653"
3507msgid "Download"
3508msgstr "Загрузка"
3509
3510msgctxt "#90654"
3511msgid "Failed to start download: %s"
3512msgstr "Не удалось начать загрузку: %s"
3513
3514msgctxt "#90655"
3515msgid "Modify Search"
3516msgstr "Изменить поиск"
3517
3518msgctxt "#90656"
3519msgid "Torbox"
3520msgstr "Torbox"
3521
3522msgctxt "#90657"
3523msgid "Easy-Debrid"
3524msgstr "Easy-Debrid"
3525
3526msgctxt "#90658"
3527msgid "All-Debrid"
3528msgstr "All-Debrid"
3529
3530msgctxt "#90659"
3531msgid "Real-Debrid"
3532msgstr "Real-Debrid"
3533
3534msgctxt "#90660"
3535msgid "Getting results from providers..."
3536msgstr "Получение результатов от провайдеров..."
3537
3538msgctxt "#90661"
3539msgid "Jacktook [COLOR FFFF6B00]%s[/COLOR]"
3540msgstr "Jacktook [COLOR FFFF6B00]%s[/COLOR]"
3541
3542msgctxt "#90662"
3543msgid "Downloads Viewer"
3544msgstr "Просмотр загрузок"
3545
3546msgctxt "#90663"
3547msgid "WebDAV Text Viewer"
3548msgstr "Просмотр текста WebDAV"
3549
3550msgctxt "#90664"
3551msgid "Selected Item"
3552msgstr "Выбранный элемент"
3553
3554msgctxt "#90665"
3555msgid "Subtitle No. %d"
3556msgstr "Субтитр № %d"
3557
3558msgctxt "#90666"
3559msgid "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?"
3560msgstr "Доступно бесплатное использование DeepL.\nОсталось символов в бесплатной квоте: %s\nСимволов для перевода: %s\n\nХотите продолжить перевод?"
3561
3562msgctxt "#90667"
3563msgid "Free DeepL Usage"
3564msgstr "Бесплатное использование DeepL"
3565
3566msgctxt "#90668"
3567msgid "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?"
3568msgstr "Всего символов для перевода: %s\nОценочная стоимость: $%.2f USD\n\nХотите продолжить перевод?"
3569
3570msgctxt "#90669"
3571msgid "Translation Cost"
3572msgstr "Стоимость перевода"
3573
3574msgctxt "#90672"
3575msgid "Play Trailer"
3576msgstr "Смотреть трейлер"
3577
3578msgctxt "#90673"
3579msgid "Trailer unavailable"
3580msgstr "Трейлер недоступен"
3581
3582msgctxt "#90690"
3583msgid "Clear All Items"
3584msgstr "Clear All Items"
3585
3586msgctxt "#90691"
3587msgid "Remove all items from this library?"
3588msgstr "Remove all items from this library?"
3589
3590msgctxt "#90692"
3591msgid "Library cleared"
3592msgstr "Library cleared"
3593
3594msgctxt "#90693"
3595msgid "No items to remove"
3596msgstr "No items to remove"
3597
3598msgctxt "#90694"
3599msgid "Added to %s cloud but still downloading. Please wait before playing."
3600msgstr "Добавлено в облако %s, но все еще загружается. Пожалуйста, подождите перед воспроизведением."
3601
3602msgctxt "#90695"
3603msgid "Torrent Not Ready"
3604msgstr "Торрент Не Готов"
3605
3606msgctxt "#90696"
3607msgid "This torrent is still downloading in %s and is not ready yet."
3608msgstr "Этот торрент все еще загружается в %s и еще не готов."
3609
3610msgctxt "#90697"
3611msgid "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?"
3612msgstr "Хотите ли вы воспроизвести с помощью торрент-клиента (может быть медленнее) или отменить и дождаться завершения debrid?"
3613
3614msgctxt "#90698"
3615msgid "Play with Torrent"
3616msgstr "Воспроизвести через Торрент"
3617
3618msgctxt "#90699"
3619msgid "Cancel & Wait"
3620msgstr "Отменить и Подождать"
3621
3622msgctxt "#90742"
3623msgid "Addon Settings"
3624msgstr "Настройки аддона"
3625
3626msgctxt "#90743"
3627msgid "Set Views"
3628msgstr "Настроить виды"
3629
3630msgctxt "#90744"
3631msgid "Upload Subtitle"
3632msgstr "Загрузить субтитры"
3633
3634msgctxt "#90745"
3635msgid "Select Subtitle File"
3636msgstr "Выбрать файл субтитров"
3637
3638msgctxt "#90746"
3639msgid "Local File"
3640msgstr "Локальный файл"
3641
3642msgctxt "#90747"
3643msgid "Upload from Browser"
3644msgstr "Загрузить из браузера"
3645
3646msgctxt "#90748"
3647msgid "Upload subtitle from your browser"
3648msgstr "Загрузите субтитры из браузера"
3649
3650msgctxt "#90749"
3651msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file"
3652msgstr "Отсканируйте QR-код или откройте URL (%s) в браузере, чтобы загрузить файл субтитров"
3653
3654msgctxt "#90750"
3655msgid "Search Title Language"
3656msgstr "Язык заголовка поиска"
3657
3658msgctxt "#90751"
3659msgid "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches."
3660msgstr "Управляет приоритетом запросов по названию в Easynews/Jackett/Prowlarr для контекстного поиска."
3661
3662msgctxt "#90752"
3663msgid "English first"
3664msgstr "Сначала английский"
3665
3666msgctxt "#90753"
3667msgid "English only"
3668msgstr "Только английский"
3669
3670msgctxt "#90754"
3671msgid "Localized first"
3672msgstr "Сначала локализованный"
plugin.video.jacktook/resources/language/Spanish/strings.po +3725 -0
……@@ -0,0 +1,3725 @@
1# Kodi Media Center language file
2# Addon Name: Jacktook
3# Addon id: plugin.video.jacktook
4# Addon Provider: Sam-Max
5#
6msgid ""
7msgstr ""
8"Report-Msgid-Bugs-To: \n"
9"POT-Creation-Date: 2023-09-19 19:53+0200\n"
10"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
11"Language: es\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
18msgctxt "#30000"
19msgid "Players"
20msgstr "Players"
21
22msgctxt "#30001"
23msgid "Clients"
24msgstr "Clientes"
25
26msgctxt "#30002"
27msgid "Torrent Client"
28msgstr "Cliente Torrent"
29
30msgctxt "#30020"
31msgid "Indexers"
32msgstr "Indexadores"
33
34msgctxt "#30021"
35msgid "Indexer"
36msgstr "Indexador"
37
38msgctxt "#30022"
39msgid "Jackett Configuration:"
40msgstr "Configuracion de Jackett"
41
42msgctxt "#30023"
43msgid "Client Key"
44msgstr "Clave de Cliente"
45
46msgctxt "#30025"
47msgid "Host"
48msgstr "Servidor"
49
50msgctxt "#30026"
51msgid "Description length"
52msgstr "Longitud de la Descripcion"
53
54msgctxt "#30027"
55msgid "Insecured connection"
56msgstr "Conexion insegura"
57
58msgctxt "#30028"
59msgid "Sort by"
60msgstr "Ordenar por"
61
62msgctxt "#30029"
63msgid "Total Results"
64msgstr "Total de resultados"
65
66msgctxt "#30030"
67msgid "Timeout"
68msgstr "Tiempo de espera"
69
70msgctxt "#30031"
71msgid "Timeout"
72msgstr "Tiempo de espera"
73
74msgctxt "#30050"
75msgid "Prowlarr Configuration:"
76msgstr "Configuracion de Prowlarr"
77
78msgctxt "#30051"
79msgid "Client Key"
80msgstr "Clave de Cliente"
81
82msgctxt "#30052"
83msgid "Host"
84msgstr "Servidor"
85
86msgctxt "#30053"
87msgid "Indexers-Ids"
88msgstr "Ids de los Indexadores"
89
90msgctxt "#30054"
91msgid "Anime IndexersIds"
92msgstr "Ids Indexadores de Anime"
93
94msgctxt "#30100"
95msgid "TMDB"
96msgstr "TMDB"
97
98msgctxt "#30102"
99msgid "TMDB Configuration"
100msgstr "Configuracion de TMDB"
101
102msgctxt "#30103"
103msgid "Client Key"
104msgstr "Clave de Cliente"
105
106msgctxt "#30104"
107msgid "Clear tmdb cache"
108msgstr "Borrar la cache de TMDB"
109
110msgctxt "#30105"
111msgid "Cache expiration hours"
112msgstr "Expiracion de Cache"
113
114msgctxt "#30106"
115msgid "Clear Cache"
116msgstr "Borrar la Cache"
117
118msgctxt "#30107"
119msgid "Enable Cache"
120msgstr "Habilitar Cache"
121
122msgctxt "#30140"
123msgid "AniList"
124msgstr "AniList"
125
126msgctxt "#30142"
127msgid "AniList Configuration:"
128msgstr "Configuracion de AniList"
129
130msgctxt "#30143"
131msgid "Client ID"
132msgstr "Client ID"
133
134msgctxt "#30144"
135msgid "Client Secret"
136msgstr "Client Secret"
137
138msgctxt "#30150"
139msgid "Services"
140msgstr "Servicios"
141
142msgctxt "#30152"
143msgid "Real Debrid Configuration"
144msgstr "Configuracion de Real Debrid"
145
146msgctxt "#30154"
147msgid "Token"
148msgstr "Token"
149
150msgctxt "#30200"
151msgid "Fanart.TV"
152msgstr "Fanart.TV"
153
154msgctxt "#30202"
155msgid "Fanart.TV Configuration:"
156msgstr "Configuracion de Fanart.TV:"
157
158msgctxt "#30203"
159msgid "Client ID"
160msgstr "Client ID"
161
162msgctxt "#30221"
163msgid "No Client Key provided for"
164msgstr "Clave de Cliente no provista para"
165
166msgctxt "#30223"
167msgid "You need to configure"
168msgstr "Necesitas configurar"
169
170msgctxt "#30228"
171msgid "The request to Torrentio failed."
172msgstr "La peticion a Torrentio ha fallado"
173
174msgctxt "#30229"
175msgid "The request to Jackett failed."
176msgstr "La peticion a Jackett ha fallado"
177
178msgctxt "#30230"
179msgid "The request to Prowlarr failed."
180msgstr "La peticion a Prowlarr ha fallado"
181
182msgctxt "#30231"
183msgid "The request to Elhosted failed."
184msgstr "La peticion a Elhosted ha fallado"
185
186msgctxt "#30232"
187msgid "The request to Jackgram failed."
188msgstr "La peticion a Jackgram ha fallado"
189
190msgctxt "#30233"
191msgid "The request to Mediafusion failed."
192msgstr "La peticion a Mediafusion ha fallado"
193
194msgctxt "#30234"
195msgid "The request to Peerflix failed."
196msgstr "La peticion a Peerflix ha fallado"
197
198msgctxt "#30235"
199msgid "Stremio"
200msgstr "Stremio"
201
202msgctxt "#30236"
203msgid "Stremio add-ons"
204msgstr "Addons de Stremio"
205
206msgctxt "#30237"
207msgid "Import your config"
208msgstr "Importar tu configuración"
209
210msgctxt "#30238"
211msgid "Manage add-ons"
212msgstr "Gestionar add-ons"
213
214msgctxt "#30240"
215msgid "TMDB cache cleared"
216msgstr "Cache de TMDB limpiada"
217
218msgctxt "#30241"
219msgid "Search on TMDB:"
220msgstr "Buscar en TMDB"
221
222msgctxt "#30242"
223msgid "Search on AniList:"
224msgstr "Buscar en AniList"
225
226msgctxt "#30243"
227msgid "Search for a keyword:"
228msgstr "Buscar por una palabra-clave:"
229
230msgctxt "#30244"
231msgid "Cache cleared"
232msgstr "Cache limpiada"
233
234msgctxt "#30249"
235msgid "Addon Jacktook Burst not found - (script.jacktook.burst)"
236msgstr "Addon Jacktook Burst no encontrado - (script.jacktook.burst)"
237
238msgctxt "#30250"
239msgid "Addon Torrest not found - (plugin.video.torrest)"
240msgstr "El Addon de Torrest no esta instalado - (plugin.video.torrest)"
241
242msgctxt "#30251"
243msgid "No sources found to play"
244msgstr "No hay fuentes disponibles para reproducir"
245
246msgctxt "#30252"
247msgid "Addon Elementum not found - (plugin.video.elementum)"
248msgstr "El Addon de Elementum no esta instalado - (plugin.video.elementum)"
249
250msgctxt "#30253"
251msgid "Addon Jacktorr not found - (plugin.video.jacktorr)"
252msgstr "El Addon Jacktorr no esta instalado - (plugin.video.jacktorr)"
253
254msgctxt "#30254"
255msgid "Do you want to install Elementum?"
256msgstr "¿Quieres instalar Elementum?"
257
258msgctxt "#30255"
259msgid "Do you want to install Jacktorr?"
260msgstr "¿Quieres instalar Jacktorr?"
261
262msgctxt "#30256"
263msgid "Do you want to install Torrest?"
264msgstr "¿Quieres instalar Torrest?"
265
266msgctxt "#30257"
267msgid "Do you want to install Jacktook Burst?"
268msgstr "¿Quieres instalar Jacktook Burst?"
269
270msgctxt "#30650"
271msgid "Queued"
272msgstr "Encolado"
273
274msgctxt "#30651"
275msgid "Checking"
276msgstr "Comprobando"
277
278msgctxt "#30652"
279msgid "Finding"
280msgstr "Buscando"
281
282msgctxt "#30653"
283msgid "Downloading"
284msgstr "Descargando"
285
286msgctxt "#30654"
287msgid "Finished"
288msgstr "Terminado"
289
290msgctxt "#30655"
291msgid "Seeding"
292msgstr "Seeding"
293
294msgctxt "#30656"
295msgid "Allocating"
296msgstr "Asignando"
297
298msgctxt "#30657"
299msgid "Checking resume data"
300msgstr "Comprobando datos para resumir"
301
302msgctxt "#30658"
303msgid "Paused"
304msgstr "Pausado"
305
306msgctxt "#30659"
307msgid "Buffering"
308msgstr "Buffering"
309
310msgctxt "#30660"
311msgid "Unknown"
312msgstr "Desconocido"
313
314msgctxt "#30700"
315msgid "Buffer and play"
316msgstr "Buffer y reproducir"
317
318msgctxt "#30701"
319msgid "Stop downloading"
320msgstr "Detener descarga"
321
322msgctxt "#30702"
323msgid "Downloads"
324msgstr "Descargas"
325
326msgctxt "#30703"
327msgid "Resume"
328msgstr "Resumir"
329
330msgctxt "#30704"
331msgid "Pause"
332msgstr "Pausar"
333
334msgctxt "#30705"
335msgid "Remove torrent"
336msgstr "Borrar torrent"
337
338msgctxt "#30706"
339msgid "Remove torrent and files"
340msgstr "Borrar torrents y archivos"
341
342msgctxt "#30707"
343msgid "Torrent status"
344msgstr "Estado del torrent"
345
346msgctxt "#30708"
347msgid "Stop"
348msgstr "Detener"
349
350msgctxt "#30709"
351msgid "Drop torrent"
352msgstr "Drop torrent"
353
354msgctxt "#30710"
355msgid "Include Season Packs"
356msgstr "Incluir Packs de Temporadas"
357
358msgctxt "#30711"
359msgid "Torrent Settings"
360msgstr "Configuración de Torrents"
361
362msgctxt "#30712"
363msgid "General"
364msgstr "General"
365
366msgctxt "#30713"
367msgid "Enable"
368msgstr "Habilitar"
369
370msgctxt "#30714"
371msgid "Jacktorr"
372msgstr "Jacktorr"
373
374msgctxt "#30715"
375msgid "Torrest"
376msgstr "Torrest"
377
378msgctxt "#30716"
379msgid "Elementum"
380msgstr "Elementum"
381
382msgctxt "#30717"
383msgid "General"
384msgstr "General"
385
386msgctxt "#30718"
387msgid "Auto-Play"
388msgstr "Reproducción automática"
389
390msgctxt "#30719"
391msgid "Enable Next Episode Dialog"
392msgstr "Próximo episodio"
393
394msgctxt "#30720"
395msgid "Seconds left before show Next Episode Dialog"
396msgstr "Segundos para siguiente episodio"
397
398msgctxt "#30721"
399msgid "Cache Enabled"
400msgstr "Caché habilitada"
401
402msgctxt "#30722"
403msgid "Cache Expiration (hours)"
404msgstr "Expiración de caché (horas)"
405
406msgctxt "#30723"
407msgid "Cache"
408msgstr "Caché"
409
410msgctxt "#30724"
411msgid "Update"
412msgstr "Actualizar"
413
414msgctxt "#30725"
415msgid "Indexers Configuration"
416msgstr "Configuración de indexadores"
417
418msgctxt "#30726"
419msgid "Sort"
420msgstr "Ordenar"
421
422msgctxt "#30727"
423msgid "Quality"
424msgstr "Calidad"
425
426msgctxt "#30938"
427msgid "Silent Resume"
428msgstr "Reanudar Silenciosamente"
429
430msgctxt "#30939"
431msgid "Auto-resume from last position without asking"
432msgstr "Reanudar automáticamente desde la última posición sin preguntar"
433
434msgctxt "#30728"
435msgid "Seeds"
436msgstr "Seeds"
437
438msgctxt "#30729"
439msgid "Size"
440msgstr "Tamaño"
441
442msgctxt "#30730"
443msgid "Cached"
444msgstr "En caché"
445
446msgctxt "#30731"
447msgid "Date"
448msgstr "Fecha"
449
450msgctxt "#30732"
451msgid "None"
452msgstr "Ninguno"
453
454msgctxt "#30733"
455msgid "Torrentio Configuration"
456msgstr "Configuración de Torrentio"
457
458msgctxt "#30734"
459msgid "Enable"
460msgstr "Habilitar"
461
462msgctxt "#30736"
463msgid "Subtitle Language"
464msgstr "Idioma prioritario"
465
466msgctxt "#30737"
467msgid "GB"
468msgstr "GB"
469
470msgctxt "#30738"
471msgid "JP"
472msgstr "JP"
473
474msgctxt "#30739"
475msgid "RU"
476msgstr "RU"
477
478msgctxt "#30740"
479msgid "IT"
480msgstr "IT"
481
482msgctxt "#30741"
483msgid "PT"
484msgstr "PT"
485
486msgctxt "#30742"
487msgid "ES"
488msgstr "ES"
489
490msgctxt "#30743"
491msgid "MX"
492msgstr "MX"
493
494msgctxt "#30744"
495msgid "KR"
496msgstr "KR"
497
498msgctxt "#30745"
499msgid "CN"
500msgstr "CN"
501
502msgctxt "#30746"
503msgid "TW"
504msgstr "TW"
505
506msgctxt "#30747"
507msgid "FR"
508msgstr "FR"
509
510msgctxt "#30748"
511msgid "DE"
512msgstr "DE"
513
514msgctxt "#30749"
515msgid "NL"
516msgstr "NL"
517
518msgctxt "#30750"
519msgid "IN"
520msgstr "IN"
521
522msgctxt "#30751"
523msgid "PL"
524msgstr "PL"
525
526msgctxt "#30752"
527msgid "LT"
528msgstr "LT"
529
530msgctxt "#30753"
531msgid "CZ"
532msgstr "CZ"
533
534msgctxt "#30754"
535msgid "SK"
536msgstr "SK"
537
538msgctxt "#30755"
539msgid "SI"
540msgstr "SI"
541
542msgctxt "#30756"
543msgid "HU"
544msgstr "HU"
545
546msgctxt "#30757"
547msgid "RO"
548msgstr "RO"
549
550msgctxt "#30758"
551msgid "HR"
552msgstr "HR"
553
554msgctxt "#30759"
555msgid "UA"
556msgstr "UA"
557
558msgctxt "#30760"
559msgid "GR"
560msgstr "GR"
561
562msgctxt "#30761"
563msgid "DK"
564msgstr "DK"
565
566msgctxt "#30762"
567msgid "FI"
568msgstr "FI"
569
570msgctxt "#30763"
571msgid "SE"
572msgstr "SE"
573
574msgctxt "#30764"
575msgid "NO"
576msgstr "NO"
577
578msgctxt "#30765"
579msgid "TR"
580msgstr "TR"
581
582msgctxt "#30766"
583msgid "Torrentio Providers"
584msgstr "Proveedores de Torrentio"
585
586msgctxt "#30767"
587msgid "Jackgram Configuration"
588msgstr "Configuración de Jackgram"
589
590msgctxt "#30768"
591msgid "Host"
592msgstr "Host"
593
594msgctxt "#30769"
595msgid "Elfhosted/KnightCrawler Configuration"
596msgstr "Configuración de Elfhosted/KnightCrawler"
597
598msgctxt "#30770"
599msgid "Language"
600msgstr "Idioma"
601
602msgctxt "#30771"
603msgid "Zilean Configuration"
604msgstr "Configuración de Zilean"
605
606msgctxt "#30772"
607msgid "Zilean Host"
608msgstr "Host de Zilean"
609
610msgctxt "#30774"
611msgid "Zilean Timeout"
612msgstr "Tiempo de espera de Zilean"
613
614msgctxt "#30775"
615msgid "Jackett Configuration"
616msgstr "Configuración de Jackett"
617
618msgctxt "#30776"
619msgid "Prowlarr Configuration"
620msgstr "Configuración de Prowlarr"
621
622msgctxt "#30777"
623msgid "MediaFusion configuration"
624msgstr "Configuración de MediaFusion"
625
626msgctxt "#30779"
627msgid "Manifest Url"
628msgstr "URL del manifiesto"
629
630msgctxt "#30780"
631msgid "Jacktook Burst Configuration"
632msgstr "Configuración de Jacktook Burst"
633
634msgctxt "#30781"
635msgid "Burst Configuration"
636msgstr "Configuración de Burst"
637
638msgctxt "#30782"
639msgid "User"
640msgstr "Usuario"
641
642msgctxt "#30783"
643msgid "Authorize"
644msgstr "Autorizar"
645
646msgctxt "#30784"
647msgid "Remove Authorization"
648msgstr "Eliminar autorización"
649
650msgctxt "#30785"
651msgid "Premiumize Configuration"
652msgstr "Configuración de Premiumize"
653
654msgctxt "#30786"
655msgid "Enabled"
656msgstr "Habilitar"
657
658msgctxt "#30787"
659msgid "Token"
660msgstr "Token"
661
662msgctxt "#30788"
663msgid "TorBox Configuration"
664msgstr "Configuración de TorBox"
665
666msgctxt "#30789"
667msgid "Debrider Configuration"
668msgstr "Configuración de Debrider"
669
670msgctxt "#30790"
671msgid "Uncached"
672msgstr "No cacheado"
673
674msgctxt "#30791"
675msgid "Show uncached torrents"
676msgstr "Mostrar torrents no cacheados"
677
678msgctxt "#30792"
679msgid "Use Kodi Language"
680msgstr "Usar idioma de Kodi"
681
682msgctxt "#30793"
683msgid "Trakt"
684msgstr "Trakt"
685
686msgctxt "#30794"
687msgid "Trakt Configuration"
688msgstr "Configuración de Trakt"
689
690msgctxt "#30795"
691msgid "Expires"
692msgstr "Expira"
693
694msgctxt "#30796"
695msgid "Refresh"
696msgstr "Actualizar"
697
698msgctxt "#30797"
699msgid "Token"
700msgstr "Token"
701
702msgctxt "#30798"
703msgid "Trakt Client"
704msgstr "Cliente de Trakt"
705
706msgctxt "#30800"
707msgid "Trakt Secret"
708msgstr "Secreto de Trakt"
709
710msgctxt "#30802"
711msgid "Log out"
712msgstr "Cerrar sesión"
713
714msgctxt "#30804"
715msgid "Menus"
716msgstr "Menús"
717
718msgctxt "#30805"
719msgid "Show Telegram Menu"
720msgstr "Mostrar menú de Telegram"
721
722msgctxt "#30806"
723msgid "Clear cache when update"
724msgstr "Limpiar cache al actualizar"
725
726msgctxt "#30807"
727msgid "Peerfix Configuration"
728msgstr "Configuración de Peerfix"
729
730msgctxt "#30894"
731msgid "Show Changelog"
732msgstr "Mostrar cambios"
733
734msgctxt "#30895"
735msgid "View the latest changes and updates to the addon."
736msgstr "Ver los últimos cambios y actualizaciones del complemento."
737
738msgctxt "#30896"
739msgid "Calendar"
740msgstr "Calendario"
741
742msgctxt "#30897"
743msgid "Use broadcast day adjustment"
744msgstr "Usar ajuste de día de emisión"
745
746msgctxt "#30898"
747msgid "Adjust broadcast day based on manual offset"
748msgstr "Ajustar día de emisión según desplazamiento manual"
749
750msgctxt "#30899"
751msgid "Broadcast Offset (Hours)"
752msgstr "Desplazamiento de emisión (Horas)"
753
754msgctxt "#30900"
755msgid "Manual hour adjustment for broadcast day simulation"
756msgstr "Ajuste manual de horas para simulación de emisión"
757
758msgctxt "#30905"
759msgid "Trakt Calendar Previous Days"
760msgstr "Días anteriores del calendario Trakt"
761
762msgctxt "#30906"
763msgid "Number of days in the past to show in the Trakt calendar."
764msgstr "Número de días pasados a mostrar en el calendario de Trakt."
765
766msgctxt "#30907"
767msgid "Trakt Calendar Future Days"
768msgstr "Días futuros del calendario Trakt"
769
770msgctxt "#30908"
771msgid "Number of days in the future to show in the Trakt calendar."
772msgstr "Número de días futuros a mostrar en el calendario de Trakt."
773
774msgctxt "#30909"
775msgid "Show All Shows in Calendar"
776msgstr "Mostrar todas las series en el calendario"
777
778msgctxt "#30918"
779msgid "{provider} cannot directly play packed releases (.rar/.zip). Choose a source with video files instead."
780msgstr "{provider} no puede reproducir directamente releases empaquetados (.rar/.zip). Elige una fuente con archivos de video."
781
782msgctxt "#30910"
783msgid "Show all airing shows instead of just your watchlist/collection."
784msgstr "Muestra todas las series en emisión en lugar de solo las de tu lista/colección."
785
786msgctxt "#30911"
787msgid "Your Torbox account username/email."
788msgstr "Tu nombre de usuario/email de cuenta Torbox."
789
790msgctxt "#30912"
791msgid "Authorize add-on to access Torbox account."
792msgstr "Autorizar complemento para acceder a la cuenta de Torbox."
793
794msgctxt "#30913"
795msgid "Revoke Torbox account access."
796msgstr "Revocar acceso a la cuenta de Torbox."
797
798msgctxt "#30914"
799msgid "Trakt Sync Interval (minutes)"
800msgstr "Intervalo de sincronizacion de Trakt (minutos)"
801
802msgctxt "#30915"
803msgid "How often Jacktook checks Trakt for remote activity changes while Kodi is running."
804msgstr "Con que frecuencia Jacktook comprueba cambios remotos de actividad en Trakt mientras Kodi esta en ejecucion."
805
806msgctxt "#30916"
807msgid "Account Info"
808msgstr "Informacion de cuenta de Trakt"
809
810msgctxt "#30917"
811msgid "View your Trakt account profile and library stats."
812msgstr "Ver el perfil de tu cuenta de Trakt y las estadisticas de tu biblioteca."
813
814msgctxt "#30924"
815msgid "Trakt List Name"
816msgstr "Nombre de la lista de Trakt"
817
818msgctxt "#30925"
819msgid "Trakt List Description"
820msgstr "Descripcion de la lista de Trakt"
821
822msgctxt "#30926"
823msgid "Create Trakt List"
824msgstr "Crear lista de Trakt"
825
826msgctxt "#30927"
827msgid "Choose Trakt List"
828msgstr "Elegir lista de Trakt"
829
830msgctxt "#30936"
831msgid "Enable or disable Trakt scrobbling and resume playback from the last tracked position."
832msgstr "Activar o desactivar el scrobbling de Trakt y reanudar la reproduccion desde la ultima posicion registrada."
833
834msgctxt "#30937"
835msgid "Clear cached Trakt data and force Jacktook to fetch fresh Trakt results the next time you open a Trakt view."
836msgstr "Borrar los datos en cache de Trakt y forzar a Jacktook a obtener resultados nuevos la proxima vez que abras una vista de Trakt."
837
838msgctxt "#40000"
839msgid "Arabic (United Arab Emirates)"
840msgstr "Árabe (Emiratos Árabes Unidos)"
841
842msgctxt "#40001"
843msgid "Arabic (Saudi Arabia)"
844msgstr "Árabe (Arabia Saudita)"
845
846msgctxt "#40002"
847msgid "Belarusian (Belarus)"
848msgstr "Bielorruso (Bielorrusia)"
849
850msgctxt "#40003"
851msgid "Bulgarian (Bulgaria)"
852msgstr "Búlgaro (Bulgaria)"
853
854msgctxt "#40004"
855msgid "Bengali (Bangladesh)"
856msgstr "Bengalí (Bangladesh)"
857
858msgctxt "#40005"
859msgid "Catalan (Spain)"
860msgstr "Catalán (España)"
861
862msgctxt "#40006"
863msgid "Chamorro (Guam)"
864msgstr "Chamorro (Guam)"
865
866msgctxt "#40007"
867msgid "Czech (Czech Republic)"
868msgstr "Checo (República Checa)"
869
870msgctxt "#40008"
871msgid "Danish (Denmark)"
872msgstr "Danés (Dinamarca)"
873
874msgctxt "#40009"
875msgid "German (Austria)"
876msgstr "Alemán (Austria)"
877
878msgctxt "#40010"
879msgid "German (Switzerland)"
880msgstr "Alemán (Suiza)"
881
882msgctxt "#40011"
883msgid "German (Germany)"
884msgstr "Alemán (Alemania)"
885
886msgctxt "#40012"
887msgid "Greek (Greece)"
888msgstr "Griego (Grecia)"
889
890msgctxt "#40013"
891msgid "English (Australia)"
892msgstr "Inglés (Australia)"
893
894msgctxt "#40014"
895msgid "English (Canada)"
896msgstr "Inglés (Canadá)"
897
898msgctxt "#40015"
899msgid "English (United Kingdom)"
900msgstr "Inglés (Reino Unido)"
901
902msgctxt "#40016"
903msgid "English (Ireland)"
904msgstr "Inglés (Irlanda)"
905
906msgctxt "#40017"
907msgid "English (New Zealand)"
908msgstr "Inglés (Nueva Zelanda)"
909
910msgctxt "#40018"
911msgid "English (United States of America)"
912msgstr "Inglés (Estados Unidos de América)"
913
914msgctxt "#40019"
915msgid "Esperanto (None)"
916msgstr "Esperanto (Ninguno)"
917
918msgctxt "#40020"
919msgid "Spanish (Spain)"
920msgstr "Español (España)"
921
922msgctxt "#40021"
923msgid "Spanish (Mexico)"
924msgstr "Español (México)"
925
926msgctxt "#40022"
927msgid "Estonian (Estonia)"
928msgstr "Estonio (Estonia)"
929
930msgctxt "#40023"
931msgid "Basque (Spain)"
932msgstr "Vasco (España)"
933
934msgctxt "#40024"
935msgid "Persian (Iran)"
936msgstr "Persa (Irán)"
937
938msgctxt "#40025"
939msgid "Finnish (Finland)"
940msgstr "Finlandés (Finlandia)"
941
942msgctxt "#40026"
943msgid "French (Canada)"
944msgstr "Francés (Canadá)"
945
946msgctxt "#40027"
947msgid "French (France)"
948msgstr "Francés (Francia)"
949
950msgctxt "#40028"
951msgid "Galician (Spain)"
952msgstr "Gallego (España)"
953
954msgctxt "#40029"
955msgid "Hebrew (Israel)"
956msgstr "Hebreo (Israel)"
957
958msgctxt "#40030"
959msgid "Hindi (India)"
960msgstr "Hindi (India)"
961
962msgctxt "#40031"
963msgid "Hungarian (Hungary)"
964msgstr "Húngaro (Hungría)"
965
966msgctxt "#40032"
967msgid "Indonesian (Indonesia)"
968msgstr "Indonesio (Indonesia)"
969
970msgctxt "#40033"
971msgid "Italian (Italy)"
972msgstr "Italiano (Italia)"
973
974msgctxt "#40034"
975msgid "Japanese (Japan)"
976msgstr "Japonés (Japón)"
977
978msgctxt "#40035"
979msgid "Georgian (Georgia)"
980msgstr "Georgiano (Georgia)"
981
982msgctxt "#40036"
983msgid "Kazakh (Kazakhstan)"
984msgstr "Kazajo (Kazajistán)"
985
986msgctxt "#40037"
987msgid "Kannada (India)"
988msgstr "Kannada (India)"
989
990msgctxt "#40038"
991msgid "Korean (South Korea)"
992msgstr "Coreano (Corea del Sur)"
993
994msgctxt "#40039"
995msgid "Lithuanian (Lithuania)"
996msgstr "Lituano (Lituania)"
997
998msgctxt "#40040"
999msgid "Latvian (Latvia)"
1000msgstr "Letón (Letonia)"
1001
1002msgctxt "#40041"
1003msgid "Malayalam (India)"
1004msgstr "Malayalam (India)"
1005
1006msgctxt "#40042"
1007msgid "Malay (Malaysia)"
1008msgstr "Malayo (Malasia)"
1009
1010msgctxt "#40043"
1011msgid "Malay (Singapore)"
1012msgstr "Malayo (Singapur)"
1013
1014msgctxt "#40044"
1015msgid "Norwegian Bokmål (Norway)"
1016msgstr "Bokmål noruego (Noruega)"
1017
1018msgctxt "#40045"
1019msgid "Dutch (Netherlands)"
1020msgstr "Holandés (Países Bajos)"
1021
1022msgctxt "#40046"
1023msgid "Norwegian (Norway)"
1024msgstr "Noruego (Noruega)"
1025
1026msgctxt "#40047"
1027msgid "Polish (Poland)"
1028msgstr "Polaco (Polonia)"
1029
1030msgctxt "#40048"
1031msgid "Portuguese (Brazil)"
1032msgstr "Portugués (Brasil)"
1033
1034msgctxt "#40049"
1035msgid "Portuguese (Portugal)"
1036msgstr "Portugués (portugal)"
1037
1038msgctxt "#40050"
1039msgid "Romanian (Romania)"
1040msgstr "Rumano (Rumania)"
1041
1042msgctxt "#40051"
1043msgid "Russian (Russia)"
1044msgstr "Ruso (Rusia)"
1045
1046msgctxt "#40052"
1047msgid "Sinhalese (Sri Lanka)"
1048msgstr "Cingales (Sri Lanka)"
1049
1050msgctxt "#40053"
1051msgid "Slovak (Slovakia)"
1052msgstr "Eslovaco (Eslovaquia)"
1053
1054msgctxt "#40054"
1055msgid "Slovenian (Slovenia)"
1056msgstr "Esloveno (Eslovenia)"
1057
1058msgctxt "#40055"
1059msgid "Serbian (Serbia)"
1060msgstr "Serbio (Serbia)"
1061
1062msgctxt "#40056"
1063msgid "Swedish (Sweden)"
1064msgstr "Sueco (Suecia)"
1065
1066msgctxt "#40057"
1067msgid "Tamil (India)"
1068msgstr "Tamil (India)"
1069
1070msgctxt "#40058"
1071msgid "Telugu (India)"
1072msgstr "Telugu (India)"
1073
1074msgctxt "#40059"
1075msgid "Thai (Thailand)"
1076msgstr "Tailandés (Tailandia)"
1077
1078msgctxt "#40060"
1079msgid "Tagalog (Philippines)"
1080msgstr "Tagalo (Philippines)"
1081
1082msgctxt "#40061"
1083msgid "Turkish (Turkey)"
1084msgstr "Turco (Turquía"
1085
1086msgctxt "#40062"
1087msgid "Ukrainian (Ukraine)"
1088msgstr "Ucraniano (Ucrania)"
1089
1090msgctxt "#40063"
1091msgid "Vietnamese (Vietnam)"
1092msgstr "Vietnamita (Vietnam)"
1093
1094msgctxt "#40064"
1095msgid "Mandarin (China)"
1096msgstr "Mandarin (China)"
1097
1098msgctxt "#40065"
1099msgid "Mandarin (Hong Kong)"
1100msgstr "Mandarin (Hong Kong)"
1101
1102msgctxt "#40066"
1103msgid "Mandarin (Taiwan)"
1104msgstr "Mandarin (Taiwan)"
1105
1106msgctxt "#40067"
1107msgid "Zulu (South Africa)"
1108msgstr "Zulú (Sudáfrica)"
1109
1110msgctxt "#42418"
1111msgid "Language and country"
1112msgstr "Idioma y país"
1113
1114msgctxt "#42419"
1115msgid "The language and country settings used when looking up data from TMDb"
1116msgstr "La configuración de idioma y país utilizada al buscar datos de TMDB"
1117
1118msgctxt "#42420"
1119msgid "EN"
1120msgstr "EN"
1121
1122msgctxt "#30801"
1123msgid "Zilean server address (include port if required)."
1124msgstr "Dirección del servidor Zilean (incluye el puerto si es necesario)."
1125
1126msgctxt "#30803"
1127msgid "Display torrents not yet cached on your debrid service."
1128msgstr "Mostrar torrents que aún no están en caché en tu servicio debrid."
1129
1130msgctxt "#30808"
1131msgid "Fanart.tv Client Key (for artwork)."
1132msgstr "Clave de Cliente de Fanart.tv (para imágenes)."
1133
1134msgctxt "#30809"
1135msgid "Improve performance by caching metadata."
1136msgstr "Mejorar el rendimiento almacenando metadatos en caché."
1137
1138msgctxt "#30810"
1139msgid "Seconds before Jackett requests time out (e.g., 30)."
1140msgstr "Segundos antes de que las solicitudes a Jackett expiren (ej. 30)."
1141
1142msgctxt "#30811"
1143msgid "Trakt client secret from OAuth application settings."
1144msgstr "Clave secreta de cliente de Trakt desde la configuración de la aplicación OAuth."
1145
1146msgctxt "#30812"
1147msgid "Limit TV show results to specific seasons/episodes."
1148msgstr "Limitar los resultados de series a temporadas/episodios específicos."
1149
1150msgctxt "#30813"
1151msgid "Hours before cached data is automatically refreshed (e.g., 24)."
1152msgstr "Horas antes de que los datos en caché se actualicen automáticamente (ej. 24)."
1153
1154msgctxt "#30814"
1155msgid "Premiumize Client Token (get from account page)."
1156msgstr "Clave de Cliente de Premiumize (obtener en la página de la cuenta)."
1157
1158msgctxt "#30815"
1159msgid "Jackett server URL (e.g., http://localhost)."
1160msgstr "URL del servidor Jackett (ej. http://localhost)."
1161
1162msgctxt "#30816"
1163msgid "Display Telegram integration options in UI."
1164msgstr "Mostrar opciones de integración de Telegram en la interfaz."
1165
1166msgctxt "#30817"
1167msgid "Your Real-Debrid account username/email."
1168msgstr "Nombre de usuario/correo electrónico de tu cuenta Real-Debrid."
1169
1170msgctxt "#30818"
1171msgid "Trakt client ID from https://trakt.tv/oauth/apps."
1172msgstr "ID de cliente de Trakt desde https://trakt.tv/oauth/apps."
1173
1174msgctxt "#30819"
1175msgid "Connect Trakt.tv account to sync watch progress."
1176msgstr "Conectar cuenta de Trakt.tv para sincronizar el progreso de visualización."
1177
1178msgctxt "#30820"
1179msgid "Seconds before Zilean requests time out (e.g., 30)."
1180msgstr "Segundos antes de que las solicitudes a Zilean expiren (ej. 30)."
1181
1182msgctxt "#30821"
1183msgid "Your Jackett Client Key (found in dashboard)."
1184msgstr "Tu Clave de Cliente de Jackett (se encuentra en el panel)."
1185
1186msgctxt "#30822"
1187msgid "Sort results by criteria (e.g., 'date', 'title', 'size')."
1188msgstr "Ordenar resultados por criterios (ej. 'fecha', 'título', 'tamaño')."
1189
1190msgctxt "#30823"
1191msgid "Configure Burst mode settings and providers."
1192msgstr "Configurar ajustes y proveedores del modo Burst."
1193
1194msgctxt "#30824"
1195msgid "Enable Premiumize.me cloud service."
1196msgstr "Habilitar el servicio en la nube Premiumize.me."
1197
1198msgctxt "#30825"
1199msgid "Truncate descriptions after [X] characters (0 for full text)."
1200msgstr "Truncar descripciones después de [X] caracteres (0 para texto completo)."
1201
1202msgctxt "#30826"
1203msgid "Enable Prowlarr indexer management."
1204msgstr "Habilitar la gestión de indexadores de Prowlarr."
1205
1206msgctxt "#30827"
1207msgid "Jackgram server URL (e.g., http://localhost:9117)."
1208msgstr "URL del servidor Jackgram (ej. http://localhost:9117)."
1209
1210msgctxt "#30828"
1211msgid "Debrider Client Key for authentication."
1212msgstr "Clave de Cliente de Debrider para autenticación."
1213
1214msgctxt "#30829"
1215msgid "Delay (in seconds) before next episode dialog appears (e.g., 10)."
1216msgstr "Retraso (en segundos) antes de que aparezca el diálogo del próximo episodio (ej. 10)."
1217
1218msgctxt "#30830"
1219msgid "Specify the torrent client."
1220msgstr "Especificar el ejecutable del cliente de torrent."
1221
1222msgctxt "#30831"
1223msgid "Enable Debrider cached torrent support."
1224msgstr "Habilitar soporte para torrents en caché de EasyDebrid."
1225
1226msgctxt "#30832"
1227msgid "Torbox Client Token (found in account settings)."
1228msgstr "Clave de Cliente de Torbox (se encuentra en la configuración de la cuenta)."
1229
1230msgctxt "#30833"
1231msgid "Trakt refresh token (auto-managed)."
1232msgstr "Token de actualización de Trakt (gestionado automáticamente)."
1233
1234msgctxt "#30834"
1235msgid "Show confirmation dialog before playing next episode."
1236msgstr "Mostrar diálogo de confirmación antes de reproducir el próximo episodio."
1237
1238msgctxt "#30835"
1239msgid "Authorize add-on to access Real-Debrid account."
1240msgstr "Autorizar el complemento para acceder a la cuenta Real-Debrid."
1241
1242msgctxt "#30836"
1243msgid "Enable torrent-based media content"
1244msgstr "Habilita descargas de contenido multimedia mediante torrents."
1245
1246msgctxt "#30837"
1247msgid "Indicates if Real-Debrid account is linked."
1248msgstr "Indica si la cuenta Real-Debrid está vinculada."
1249
1250msgctxt "#30838"
1251msgid "Automatically play next media item when current playback ends."
1252msgstr "Reproducir automáticamente el siguiente elemento al terminar la reproducción actual."
1253
1254msgctxt "#30839"
1255msgid "Check for and install the latest add-on updates."
1256msgstr "Buscar e instalar las últimas actualizaciones del complemento."
1257
1258msgctxt "#30840"
1259msgid "Comma-separated Prowlarr indexer IDs to use (e.g., 1,5,7)."
1260msgstr "IDs de indexadores de Prowlarr a usar, separados por comas (ej. 1,5,7)."
1261
1262msgctxt "#30841"
1263msgid "Timeout (seconds) for Prowlarr requests (e.g., 20)."
1264msgstr "Tiempo de espera (segundos) para solicitudes de Prowlarr (ej. 20)."
1265
1266msgctxt "#30842"
1267msgid "Trakt authorization expiration date (read-only)."
1268msgstr "Fecha de expiración de la autorización de Trakt (solo lectura)."
1269
1270msgctxt "#30843"
1271msgid "Enable Torbox.de cloud torrent integration."
1272msgstr "Habilitar la integración de torrents en la nube de Torbox.de."
1273
1274msgctxt "#30844"
1275msgid "Trakt Access Token (auto-filled after auth)."
1276msgstr "Token de Acceso de Trakt (se autocompleta después de la autorización)."
1277
1278msgctxt "#30845"
1279msgid "Link Premiumize.me account to add-on."
1280msgstr "Vincular cuenta de Premiumize.me al complemento."
1281
1282msgctxt "#30846"
1283msgid "Clear cached data automatically after updating the add-on."
1284msgstr "Borrar datos en caché automáticamente tras actualizar el complemento."
1285
1286msgctxt "#30847"
1287msgid "Click to delete all cached data immediately."
1288msgstr "Haz clic para eliminar todos los datos en caché inmediatamente."
1289
1290msgctxt "#30848"
1291msgid "Real-Debrid Client Token (auto-filled after auth)."
1292msgstr "Token de Acceso de Real-Debrid (se autocompleta después de la autorización)."
1293
1294msgctxt "#30849"
1295msgid "Enable Jackett torrent proxy support."
1296msgstr "Habilitar soporte de proxy de torrents de Jackett."
1297
1298msgctxt "#30850"
1299msgid "Enable Jackgram integration for enhanced search."
1300msgstr "Habilitar la integración de Jackgram para búsquedas mejoradas."
1301
1302msgctxt "#30851"
1303msgid "Prowlarr instance URL (e.g., http://localhost)."
1304msgstr "URL de la instancia de Prowlarr (ej. http://localhost)."
1305
1306msgctxt "#30852"
1307msgid "Maximum number of search results to display (e.g., 50)."
1308msgstr "Número máximo de resultados de búsqueda a mostrar (ej. 50)."
1309
1310msgctxt "#30853"
1311msgid "Revoke Real-Debrid account access."
1312msgstr "Revocar el acceso a la cuenta Real-Debrid."
1313
1314msgctxt "#30854"
1315msgid "Prowlarr Client Key (found in Settings > General)."
1316msgstr "Clave de Cliente de Prowlarr (se encuentra en Configuración > General)."
1317
1318msgctxt "#30855"
1319msgid "Trakt.tv username (auto-populated after auth)."
1320msgstr "Nombre de usuario de Trakt.tv (se autocompleta después de la autorización)."
1321
1322msgctxt "#30856"
1323msgid "Disconnect Trakt.tv account from add-on."
1324msgstr "Desconectar la cuenta de Trakt.tv del complemento."
1325
1326msgctxt "#30857"
1327msgid "Enable Zilean integration for metadata fetching."
1328msgstr "Habilitar la integración de Zilean para obtener metadatos."
1329
1330msgctxt "#30858"
1331msgid "Your Client Key (required for metadata)"
1332msgstr "Tu Clave de Cliente (necesaria para metadatos)."
1333
1334msgctxt "#30859"
1335msgid "Enable Burst mode for accelerated searches."
1336msgstr "Habilitar modo Burst para búsquedas aceleradas."
1337
1338msgctxt "#30860"
1339msgid "Others"
1340msgstr "Otros"
1341
1342msgctxt "#30861"
1343msgid "Filters"
1344msgstr "Filtros"
1345
1346msgctxt "#30862"
1347msgid "Torrent (P2P) Configuration"
1348msgstr "Configuracion de Torrents (P2P)"
1349
1350msgctxt "#30863"
1351msgid "Specify the torrent client"
1352msgstr "Especificar el cliente de torrent"
1353
1354msgctxt "#30864"
1355msgid "Sources"
1356msgstr "Fuentes"
1357
1358msgctxt "#30865"
1359msgid "General Filters"
1360msgstr "Filtros Generales"
1361
1362msgctxt "#30866"
1363msgid "Sign in to your Stremio account to access add-ons and personalized features."
1364msgstr "Inicia sesión en tu cuenta de Stremio para acceder a complementos y funciones personalizadas."
1365
1366msgctxt "#30867"
1367msgid "Sign out of your Stremio account. This will disconnect your account but won't delete your data."
1368msgstr "Cierra sesión en tu cuenta de Stremio. Esto desconectará tu cuenta, pero no eliminará tus datos."
1369
1370msgctxt "#30868"
1371msgid "Log Out of Stremio"
1372msgstr "Cerrar sesión en Stremio"
1373
1374msgctxt "#30869"
1375msgid "Enable Stremio add-ons integration."
1376msgstr "Habilita la integración de complementos de Stremio."
1377
1378msgctxt "#30870"
1379msgid "Select Stream Addons"
1380msgstr "Seleccionar complementos de Stream"
1381
1382msgctxt "#30871"
1383msgid "Log In to Stremio"
1384msgstr "Iniciar sesión en Stremio"
1385
1386msgctxt "#30872"
1387msgid "Enable or disable Stremio add-ons."
1388msgstr "Habilita o deshabilita los complementos de Stremio."
1389
1390msgctxt "#30873"
1391msgid "Select language for player subtitle selection"
1392msgstr "Seleccionar el idioma del subtitlulo al reproducir"
1393
1394msgctxt "#30874"
1395msgid "Update Addons"
1396msgstr "Actualizar Addons"
1397
1398msgctxt "#30875"
1399msgid "Select Catalogs Addons"
1400msgstr "Seleccionar complementos de catalogos"
1401
1402msgctxt "#30876"
1403msgid "Download directory"
1404msgstr "Directorio de Descarga"
1405
1406msgctxt "#30877"
1407msgid "Enable Trakt.tv Scrobbling"
1408msgstr "Habilitar Trakt.tv Scrobbling"
1409
1410msgctxt "#30878"
1411msgid "Auto select subtitle when playing"
1412msgstr "Auto Seleccionar subtitulo al reproducir"
1413
1414msgctxt "#30879"
1415msgid "Auto-play quality"
1416msgstr "Calidad de reproducción automática"
1417
1418msgctxt "#30880"
1419msgid "1080p"
1420msgstr "1080p"
1421
1422msgctxt "#30881"
1423msgid "720p"
1424msgstr "1080p"
1425
1426msgctxt "#30882"
1427msgid "4k"
1428msgstr "4k"
1429
1430msgctxt "#30883"
1431msgid "480p"
1432msgstr "480p"
1433
1434msgctxt "#30884"
1435msgid "Subtitles Configuration"
1436msgstr "Configuracion de Subtitulos"
1437
1438msgctxt "#30885"
1439msgid "Addon Host"
1440msgstr "Host del Addon"
1441
1442msgctxt "#30886"
1443msgid "Subtitles"
1444msgstr "Subtitulos"
1445
1446msgctxt "#30887"
1447msgid "Primary Language"
1448msgstr "Idioma primario"
1449
1450msgctxt "#30888"
1451msgid "Secondary Language"
1452msgstr "Idioma secundario"
1453
1454msgctxt "#30889"
1455msgid "Deepl Enabled"
1456msgstr "Deepl habilitado"
1457
1458msgctxt "#30890"
1459msgid "Deepl Client Key"
1460msgstr "Clave de Cliente de Deepl"
1461
1462msgctxt "#30891"
1463msgid "Translator Configuration"
1464msgstr "Configuración del traductor"
1465
1466msgctxt "#30892"
1467msgid "Deepl Target Language"
1468msgstr "Idioma de destino de Deepl"
1469
1470msgctxt "#30893"
1471msgid "Stremio Subtitle Configuration"
1472msgstr "Configuración de subtítulos de Stremio"
1473
1474msgctxt "#90001"
1475msgid "Add Custom Addon"
1476msgstr "Agregar Complemento Personalizado"
1477
1478msgctxt "#90002"
1479msgid "Add a custom Stremio addon by entering its URL"
1480msgstr "Agrega un complemento personalizado de Stremio ingresando su URL"
1481
1482msgctxt "#90003"
1483msgid "Auto-select audio when playing"
1484msgstr "Auto-seleccionar audio"
1485
1486msgctxt "#90004"
1487msgid "Audio Language"
1488msgstr "Idioma de Audio"
1489
1490msgctxt "#90005"
1491msgid "Select audio language for playing the video"
1492msgstr "Seleccionar el idioma del audio para la reproduccion"
1493
1494msgctxt "#90006"
1495msgid "Search"
1496msgstr "Buscar"
1497
1498msgctxt "#90007"
1499msgid "TV Shows"
1500msgstr "Series"
1501
1502msgctxt "#90008"
1503msgid "Movies"
1504msgstr "Películas"
1505
1506msgctxt "#90009"
1507msgid "Anime"
1508msgstr "Anime"
1509
1510msgctxt "#90010"
1511msgid "Live TV"
1512msgstr "TV en vivo"
1513
1514msgctxt "#90011"
1515msgid "Direct Search"
1516msgstr "Búsqueda Directa"
1517
1518msgctxt "#90012"
1519msgid "Torrents"
1520msgstr "Torrents"
1521
1522msgctxt "#90013"
1523msgid "Telegram"
1524msgstr "Telegram"
1525
1526msgctxt "#90014"
1527msgid "Cloud"
1528msgstr "Nube"
1529
1530msgctxt "#90015"
1531msgid "Downloads"
1532msgstr "Descargas"
1533
1534msgctxt "#90016"
1535msgid "Settings"
1536msgstr "Ajustes"
1537
1538msgctxt "#90017"
1539msgid "History"
1540msgstr "Historial"
1541
1542msgctxt "#90018"
1543msgid "Donate"
1544msgstr "Donar"
1545
1546msgctxt "#90019"
1547msgid "Recently Watched Files"
1548msgstr "Archivos Recientemente Reproducidos"
1549
1550msgctxt "#90020"
1551msgid "Recently Watched Titles"
1552msgstr "Titulos Recientemente Reproducidos"
1553
1554msgctxt "#90021"
1555msgid "TV Calendar"
1556msgstr "Calendario de TV"
1557
1558msgctxt "#90022"
1559msgid "If you enjoy using Jacktook and want to support its development, you can buy me a coffee! Your support helps keep the project active and enables future improvements:"
1560msgstr "Si disfrutas usando Jacktook y quieres apoyar su desarrollo, puedes comprarme un café! Tu apoyo ayuda a mantener el proyecto activo y habilita mejoras futuras:"
1561
1562msgctxt "#90023"
1563msgid "Support Jacktook"
1564msgstr "Apoya a Jacktook"
1565
1566msgctxt "#90024"
1567msgid "Trending Recent"
1568msgstr "Tendencia Reciente"
1569
1570msgctxt "#90025"
1571msgid "Genres"
1572msgstr "Géneros"
1573
1574msgctxt "#90026"
1575msgid "Calendar"
1576msgstr "Calendario"
1577
1578msgctxt "#90027"
1579msgid "Years"
1580msgstr "Años"
1581
1582msgctxt "#90028"
1583msgid "Trending"
1584msgstr "Tendencias"
1585
1586msgctxt "#90029"
1587msgid "Most Watched"
1588msgstr "Más vistos"
1589
1590msgctxt "#90030"
1591msgid "Most Favorited"
1592msgstr "Más favoritos"
1593
1594msgctxt "#90031"
1595msgid "Popular Lists"
1596msgstr "Listas populares"
1597
1598msgctxt "#90032"
1599msgid "Trending Lists"
1600msgstr "Listas en tendencia"
1601
1602msgctxt "#90033"
1603msgid "Recomendations"
1604msgstr "Recomendaciones"
1605
1606msgctxt "#90034"
1607msgid "Watched History"
1608msgstr "Historial de vistos"
1609
1610msgctxt "#90035"
1611msgid "Watchlist"
1612msgstr "Lista para ver"
1613
1614msgctxt "#90036"
1615msgid "Top 10 BoxOffice"
1616msgstr "Top 10 en taquilla"
1617
1618msgctxt "#90037"
1619msgid "Popular"
1620msgstr "Popular"
1621
1622msgctxt "#90038"
1623msgid "Popular Recent"
1624msgstr "Popular Reciente"
1625
1626msgctxt "#90039"
1627msgid "Airing"
1628msgstr "En Emisión"
1629
1630msgctxt "#90040"
1631msgid "Years"
1632msgstr "Años"
1633
1634msgctxt "#90041"
1635msgid "Genres"
1636msgstr "Géneros"
1637
1638msgctxt "#90042"
1639msgid "Top Rated"
1640msgstr "Top Rated"
1641
1642msgctxt "#90043"
1643msgid "Most Watched"
1644msgstr "Más Visto"
1645
1646msgctxt "#90044"
1647msgid "Torrentio Configuration"
1648msgstr "Configuracion de Torrentio "
1649
1650msgctxt "#90045"
1651msgid "Enable Torrentio Settings"
1652msgstr "Habiitar Configuracion de Torrentio"
1653
1654msgctxt "#90046"
1655msgid "Enable or disable the Torrentio Configuration"
1656msgstr "Activa o desactiva la configuracion de Torrentio."
1657
1658msgctxt "#90047"
1659msgid "Select Torrentio Providers"
1660msgstr "Seleccionar proveedores de Torrentio"
1661
1662msgctxt "#90048"
1663msgid "Choose which providers to include in the stream request."
1664msgstr "Elige qué proveedores incluir en las peticiones de reproducción."
1665
1666msgctxt "#90049"
1667msgid "Scrape again"
1668msgstr "Volver a escanear"
1669
1670msgctxt "#90050"
1671msgid "Recommendations"
1672msgstr "Recomendaciones"
1673
1674msgctxt "#90051"
1675msgid "Similar"
1676msgstr "Similares"
1677
1678msgctxt "#90052"
1679msgid "Thread Number"
1680msgstr "Numero de hilos"
1681
1682msgctxt "#90053"
1683msgid "Select the number of threads (1-30)"
1684msgstr "Selecciona el numero de hilos (1-30)"
1685
1686msgctxt "#90054"
1687msgid "Kodi logs"
1688msgstr "Registros de Kodi"
1689
1690msgctxt "#90055"
1691msgid "Kodi old logs"
1692msgstr "Registro antiguos de Kodi"
1693
1694msgctxt "#90056"
1695msgid "Displays Kodi logs to help diagnose issues."
1696msgstr "Muestra los registros de Kodi para ayudar a diagnosticar problemas"
1697
1698msgctxt "#90057"
1699msgid "Logs"
1700msgstr "Registros"
1701
1702msgctxt "#90058"
1703msgid "Filter by Size"
1704msgstr "Filtrar por tamaño"
1705
1706msgctxt "#90059"
1707msgid "Enable Size Filter"
1708msgstr "Habilitar"
1709
1710msgctxt "#90060"
1711msgid "Minimum Size (MB)"
1712msgstr "Tamaño minimo (MB)"
1713
1714msgctxt "#90061"
1715msgid "Maximum Size (MB)"
1716msgstr "Tamaño Maximo (MB)"
1717
1718msgctxt "#90062"
1719msgid "Enable Cached Search"
1720msgstr "Habilitar búsqueda en caché"
1721
1722msgctxt "#90063"
1723msgid "Only show cached streams from external services"
1724msgstr "Mostrar solo streams en caché de servicios externos"
1725
1726msgctxt "#90064"
1727msgid "Popular"
1728msgstr "Popular"
1729
1730msgctxt "#90065"
1731msgid "Languages"
1732msgstr "Idiomas"
1733
1734msgctxt "#90066"
1735msgid "Networks"
1736msgstr "Cadenas"
1737
1738msgctxt "#90067"
1739msgid "Collections"
1740msgstr "Colecciones"
1741
1742msgctxt "#90068"
1743msgid "Search for collections:"
1744msgstr "Buscar colecciones"
1745
1746msgctxt "#90069"
1747msgid "Main Menu"
1748msgstr "Menu Principal"
1749
1750msgctxt "#90070"
1751msgid "Last Titles"
1752msgstr "Ultimos Titulos"
1753
1754msgctxt "#90071"
1755msgid "Last Files"
1756msgstr "Ultimos Videos"
1757
1758msgctxt "#90072"
1759msgid "Trakt Lists"
1760msgstr "Listas de Trakt"
1761
1762msgctxt "#90073"
1763msgid "Metadata"
1764msgstr "Metadatos"
1765
1766msgctxt "#90074"
1767msgid "Top Lists"
1768msgstr "Listas Top"
1769
1770msgctxt "#90075"
1771msgid "User Lists"
1772msgstr "Listas de Usuario"
1773
1774msgctxt "#90076"
1775msgid "Search Lists"
1776msgstr "Buscar Listas"
1777
1778msgctxt "#90077"
1779msgid "MDbList Configuration"
1780msgstr "Configuración de MDbList"
1781
1782msgctxt "#90078"
1783msgid "People"
1784msgstr "Personas"
1785
1786msgctxt "#90079"
1787msgid "Popular People"
1788msgstr "Personas Populares"
1789
1790msgctxt "#90080"
1791msgid "Latest People"
1792msgstr "Ultimas Personas"
1793
1794msgctxt "#90081"
1795msgid "Search People"
1796msgstr "Buscar Personas"
1797
1798msgctxt "#90082"
1799msgid "Download subtitles"
1800msgstr "Descargar Subtitulos"
1801
1802msgctxt "#90083"
1803msgid "Download video"
1804msgstr "Descargar video"
1805
1806msgctxt "#90084"
1807msgid "Browse into"
1808msgstr "Buscar dentro"
1809
1810msgctxt "#90085"
1811msgid "Include TV show Specials"
1812msgstr "Incluir Especiales en shows de TV"
1813
1814msgctxt "#90086"
1815msgid "Airing Today"
1816msgstr "En Emisión Hoy"
1817
1818msgctxt "#90099"
1819msgid "Filter by Source Type"
1820msgstr "Filtrar por tipo de fuente"
1821
1822msgctxt "#90100"
1823msgid "Include BluRay/UHD sources"
1824msgstr "Incluir fuentes BluRay/UHD"
1825
1826msgctxt "#90105"
1827msgid "Includes releases such as: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX"
1828msgstr "Incluye lanzamientos como: BluRay, BluRay REMUX, BRRip, BDRip, UHDRip, REMUX"
1829
1830msgctxt "#90101"
1831msgid "Include WEB/HD sources"
1832msgstr "Incluir fuentes WEB/HD"
1833
1834msgctxt "#90106"
1835msgid "Includes releases such as: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux"
1836msgstr "Incluye lanzamientos como: WEB-DL, WEB-DLRip, WEBRip, HDRip, WEBMux"
1837
1838msgctxt "#90102"
1839msgid "Include DVD/TV/SAT sources"
1840msgstr "Incluir fuentes DVD/TV/SAT"
1841
1842msgctxt "#90107"
1843msgid "Includes releases such as: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV"
1844msgstr "Incluye lanzamientos como: DVD, DVDRip, HDTV, SATRip, TVRip, PPVRip, PDTV"
1845
1846msgctxt "#90103"
1847msgid "Include CAM/Screener sources"
1848msgstr "Incluir fuentes CAM/Screener"
1849
1850msgctxt "#90108"
1851msgid "Includes releases such as: CAM, TeleSync, TeleCine, SCR"
1852msgstr "Incluye lanzamientos como: CAM, TeleSync, TeleCine, SCR"
1853
1854msgctxt "#90104"
1855msgid "Include Unknown sources"
1856msgstr "Incluir fuentes desconocidas"
1857
1858msgctxt "#90109"
1859msgid "Includes uncategorized or unidentified sources"
1860msgstr "Incluye fuentes no categorizadas o no identificadas"
1861
1862msgctxt "#90110"
1863msgid "Do you want to clear your watched history?."
1864msgstr "¿Quieres borrar tu historial de vistos?"
1865
1866msgctxt "#90111"
1867msgid "Do you want to clear your history?."
1868msgstr "¿Quieres borrar tu historial?"
1869
1870msgctxt "#90112"
1871msgid "Clear History"
1872msgstr "Borrar Historial"
1873
1874msgctxt "#90113"
1875msgid "Click to delete all watched history immediately."
1876msgstr "Click para borrar todo tu historial inmediatamente"
1877
1878msgctxt "#90114"
1879msgid "History cleared"
1880msgstr "Historial borrado"
1881
1882msgctxt "#90115"
1883msgid "Source Select"
1884msgstr "Seleccionar Fuentes"
1885
1886msgctxt "#90116"
1887msgid "Open Settings"
1888msgstr "Abrir Configuracion"
1889
1890msgctxt "#90117"
1891msgid "Select Torrentio Providers"
1892msgstr "Seleccionar proveedores de Torrentio"
1893
1894msgctxt "#90118"
1895msgid "Port"
1896msgstr "Puerto"
1897
1898msgctxt "#90119"
1899msgid "Jackett port (default:9117)"
1900msgstr "Puerto de Jackett (por defecto: 9117)"
1901
1902msgctxt "#90120"
1903msgid "Prowlarr port (default:9696)"
1904msgstr "Puerto de Prowlarr (por defecto: 9696)"
1905
1906msgctxt "#90121"
1907msgid "All Debrid Configuration"
1908msgstr "Configuracion de AllDebrid"
1909
1910msgctxt "#90122"
1911msgid "Auto download subtitles"
1912msgstr "Auto Descargar subtitulos"
1913
1914msgctxt "#90123"
1915msgid "Stremio Timeout"
1916msgstr "Tiempo de espera para Stremio"
1917
1918msgctxt "#90124"
1919msgid "Webdav Client Configuration"
1920msgstr "Configuración del Cliente Webdav"
1921
1922msgctxt "#90126"
1923msgid "Host"
1924msgstr "Servidor"
1925
1926msgctxt "#90127"
1927msgid "Username"
1928msgstr "Usuario"
1929
1930msgctxt "#90128"
1931msgid "Password"
1932msgstr "Contraseña"
1933
1934msgctxt "#90129"
1935msgid "Test Connection"
1936msgstr "Probar Conexión"
1937
1938msgctxt "#90131"
1939msgid "Webdav"
1940msgstr "Webdav"
1941
1942msgctxt "#90132"
1943msgid "Remote Path"
1944msgstr "Ruta Remota"
1945
1946msgctxt "#90133"
1947msgid "The hostname or IP address of your WebDAV server (e.g., http://mywebdav.com or https://192.168.1.100)."
1948msgstr "El nombre de host o la dirección IP de tu servidor WebDAV (por ejemplo, http://mywebdav.com o https://192.168.1.100)."
1949
1950msgctxt "#90134"
1951msgid "The username required for authenticating with your WebDAV server"
1952msgstr "El nombre de usuario requerido para autenticarse con tu servidor WebDAV."
1953
1954msgctxt "#90135"
1955msgid "The password for the specified username to authenticate with your WebDAV server"
1956msgstr "La contraseña del nombre de usuario especificado para autenticarse con tu servidor WebDAV."
1957
1958msgctxt "#90136"
1959msgid "The base path or directory on the WebDAV server where your media files are located (e.g., /movies)."
1960msgstr "La ruta o directorio base en el servidor WebDAV donde se encuentran tus archivos multimedia (por ejemplo, /movies)."
1961
1962msgctxt "#90137"
1963msgid "Click to verify if the WebDAV client can successfully connect to the configured server using the provided credentials."
1964msgstr "Haz clic para verificar si el cliente WebDAV puede conectarse correctamente al servidor configurado usando las credenciales proporcionadas."
1965
1966msgctxt "#90138"
1967msgid "Remove Custom Addon"
1968msgstr "Eliminar Complemento Personalizado"
1969
1970msgctxt "#90139"
1971msgid "Playback"
1972msgstr "Reproduccion"
1973
1974msgctxt "#90140"
1975msgid "Super quick play"
1976msgstr "Reproducción súper rápida"
1977
1978msgctxt "#90141"
1979msgid "Re-ask to play last played video"
1980msgstr "Volver a preguntar para reproducir el último video"
1981
1982msgctxt "#90142"
1983msgid "Replay Dialog"
1984msgstr "Diálogo de Repetición"
1985
1986msgctxt "#90143"
1987msgid "Do you want to play last watched?"
1988msgstr "¿Quieres reproducir la última visualización?"
1989
1990msgctxt "#90144"
1991msgid "Could not resolve playback URL"
1992msgstr "No se pudo resolver la URL de reproducción"
1993
1994msgctxt "#90145"
1995msgid "Precaching Enabled"
1996msgstr "Precaching Habilitado"
1997
1998msgctxt "#90146"
1999msgid "Enable or disable precaching of next episodes."
2000msgstr "Habilitar o deshabilitar el precaching de los próximos episodios."
2001
2002msgctxt "#90147"
2003msgid "Precaching Episode Count"
2004msgstr "Cantidad de Episodios a Precargar"
2005
2006msgctxt "#90148"
2007msgid "Number of next episodes to precache."
2008msgstr "Número de próximos episodios a precargar."
2009
2010msgctxt "#30901"
2011msgid "Show Only Stremio Catalogs"
2012msgstr "Mostrar solo catálogos de Stremio"
2013
2014msgctxt "#30902"
2015msgid "Hides embedded items (TMDb, Trakt, etc.) and shows only Stremio catalogs in menus."
2016msgstr "Oculta elementos integrados (TMDb, Trakt, etc.) y muestra solo catálogos de Stremio en los menús."
2017
2018msgctxt "#30903"
2019msgid "Select Live TV Addons"
2020msgstr "Seleccionar complementos de Live TV"
2021
2022msgctxt "#30904"
2023msgid "Choose specific Stremio addons for Live TV content"
2024msgstr "Seleccionar complementos de Stremio para contenido de Live TV"
2025
2026msgctxt "#30920"
2027msgid "Clear Trakt Cache"
2028msgstr "Limpiar caché de Trakt"
2029
2030msgctxt "#30921"
2031msgid "Clear TMDB Cache"
2032msgstr "Limpiar caché de TMDB"
2033
2034msgctxt "#30922"
2035msgid "Clear Providers Cache"
2036msgstr "Limpiar caché de Proveedores"
2037
2038msgctxt "#30923"
2039msgid "Clear Settings Cache"
2040msgstr "Limpiar caché de Configuración"
2041
2042msgctxt "#90149"
2043msgid "Maintenance"
2044msgstr "Mantenimiento"
2045
2046msgctxt "#90150"
2047msgid "Stremio: General"
2048msgstr "Stremio: General"
2049
2050msgctxt "#90151"
2051msgid "Stremio: Account"
2052msgstr "Stremio: Cuenta"
2053
2054msgctxt "#90152"
2055msgid "Stremio: Addons & Catalogs"
2056msgstr "Stremio: Addons y catálogos"
2057
2058msgctxt "#90153"
2059msgid "Stremio: Torrentio"
2060msgstr "Stremio: Torrentio"
2061
2062msgctxt "#90154"
2063msgid "Trending Recent"
2064msgstr "Tendencias Recientes"
2065
2066msgctxt "#90155"
2067msgid "Stremio: Filters"
2068msgstr "Stremio: Filtros"
2069
2070msgctxt "#32300"
2071msgid "Filter by Quality"
2072msgstr "Filtrar por Calidad"
2073
2074msgctxt "#32301"
2075msgid "Filter by Provider"
2076msgstr "Filtrar por Proveedor"
2077
2078msgctxt "#32302"
2079msgid "Filter by Source"
2080msgstr "Filtrar por Fuente"
2081
2082msgctxt "#32303"
2083msgid "Filter by Language"
2084msgstr "Filtrar por Idioma"
2085
2086msgctxt "#32304"
2087msgid "Filter by Torrents"
2088msgstr "Filtrar por Torrents"
2089
2090msgctxt "#32305"
2091msgid "Filter by Debrid"
2092msgstr "Filtrar por Debrid"
2093
2094msgctxt "#32306"
2095msgid "Reset Filter"
2096msgstr "Restablecer Filtro"
2097
2098msgctxt "#30930"
2099msgid "Ping Addons"
2100msgstr "Ping Addons"
2101
2102msgctxt "#30931"
2103msgid "Check availability of addons and show only reachable ones"
2104msgstr "Comprueba la disponibilidad de los addons y muestra solo los accesibles"
2105
2106msgctxt "#90164"
2107msgid "Skip Intro / Recap"
2108msgstr "Saltar Intro / Recap"
2109
2110msgctxt "#90165"
2111msgid "Enable Skip Intro"
2112msgstr "Habilitar Skip Intro"
2113
2114msgctxt "#90166"
2115msgid "Detect intros and recaps using IntroDB and offer to skip them during playback"
2116msgstr "Detectar intros y recaps usando IntroDB y ofrecer la opción de saltarlos durante la reproducción"
2117
2118msgctxt "#90167"
2119msgid "Auto-skip"
2120msgstr "Auto-saltar"
2121
2122msgctxt "#90168"
2123msgid "Automatically skip intros and recaps without showing the button"
2124msgstr "Saltar automáticamente intros y recaps sin mostrar el botón"
2125
2126msgctxt "#90160"
2127msgid "Skip Intro"
2128msgstr "Saltar Intro"
2129
2130msgctxt "#90161"
2131msgid "Skip Recap"
2132msgstr "Saltar Recap"
2133
2134msgctxt "#90162"
2135msgid "Manage from Phone"
2136msgstr "Gestionar desde el teléfono"
2137
2138msgctxt "#90163"
2139msgid "Start a local web server and show a QR code to manage Stremio sources from your phone"
2140msgstr "Iniciar un servidor web local y mostrar un código QR para gestionar las fuentes de Stremio desde tu teléfono"
2141
2142msgctxt "#90200"
2143msgid "Continue Watching"
2144msgstr "Continuar Viendo"
2145
2146msgctxt "#90201"
2147msgid "Library"
2148msgstr "Biblioteca"
2149
2150msgctxt "#90202"
2151msgid "My Shows"
2152msgstr "Mis Series"
2153
2154msgctxt "#90203"
2155msgid "My Movies"
2156msgstr "Mis Películas"
2157
2158msgctxt "#90204"
2159msgid "Remove from Library"
2160msgstr "Quitar de la Biblioteca"
2161
2162msgctxt "#90205"
2163msgid "Add to Library"
2164msgstr "Añadir a la biblioteca"
2165
2166msgctxt "#90206"
2167msgid "Remove"
2168msgstr "Eliminar"
2169
2170msgctxt "#90207"
2171msgid "Search Movies & TV"
2172msgstr "Buscar Películas y Series"
2173
2174msgctxt "#90208"
2175msgid "Recent Searches"
2176msgstr "Búsquedas Recientes"
2177
2178msgctxt "#90209"
2179msgid "Recent Direct Searches"
2180msgstr "Búsquedas Directas Recientes"
2181
2182msgctxt "#90210"
2183msgid "Clear All Search History"
2184msgstr "Borrar Todo el Historial de Búsqueda"
2185
2186msgctxt "#90211"
2187msgid "Bypass Stremio Addons Native Sorting"
2188msgstr "Omitir Filtros de Addons para Ordenado Nativo"
2189
2190msgctxt "#90212"
2191msgid "If enabled, addons specified won't have their results re-ordered or filtered by Jacktook's native quality and size thresholds."
2192msgstr "Si se habilita, los addons especificados en BYPASS_FILTER_ADDONS no verán sus resultados re-ordenados ni filtrados por los umbrales de tamaño y calidad nativos de Jacktook."
2193
2194msgctxt "#90213"
2195msgid "Select Bypassed Addons"
2196msgstr "Seleccionar Addons para Omitir Filtros"
2197
2198msgctxt "#90214"
2199msgid "Select which Stremio addons bypass Jacktook's native size and quality filtering."
2200msgstr "Selecciona qué addons de Stremio omitirán el filtrado nativo de tamaño y calidad de Jacktook."
2201
2202msgctxt "#90215"
2203msgid "Downgrade"
2204msgstr "Revertir"
2205
2206msgctxt "#90671"
2207msgid "Install an older published version of Jacktook. The current version is backed up and restored automatically if the downgrade fails."
2208msgstr "Instala una version publicada anterior de Jacktook. La version actual se respalda y se restaura automaticamente si la reversion falla."
2209
2210msgctxt "#30932"
2211msgid "Update Action"
2212msgstr "Acción de Actualización"
2213
2214msgctxt "#30933"
2215msgid "Choose how to handle background update checks."
2216msgstr "Elige cómo manejar las comprobaciones de actualización en segundo plano."
2217
2218msgctxt "#30934"
2219msgid "Ask"
2220msgstr "Preguntar"
2221
2222msgctxt "#30935"
2223msgid "Notify"
2224msgstr "Notificar"
2225
2226msgctxt "#90216"
2227msgid "Volume Check Enabled"
2228msgstr "Control de Volumen"
2229
2230msgctxt "#90217"
2231msgid "Automatically limits volume if it is above the threshold before playback."
2232msgstr "Limita automáticamente el volumen si está por encima del umbral antes de la reproducción."
2233
2234msgctxt "#90218"
2235msgid "Volume Threshold"
2236msgstr "Umbral de Volumen"
2237
2238msgctxt "#90219"
2239msgid "The volume percentage above which the warning triggers."
2240msgstr "El porcentaje de volumen por encima del cual se activa el ajuste."
2241
2242msgctxt "#90220"
2243msgid "High Volume Warning"
2244msgstr "Aviso de Volumen Alto"
2245
2246msgctxt "#90221"
2247msgid "High volume detected. Adjusting to %s%% for safety."
2248msgstr "Volumen alto detectado. Ajustando al %s%% por seguridad."
2249
2250msgctxt "#90222"
2251msgid "Fanart.tv Configuration"
2252msgstr "Configuración de Fanart.tv"
2253
2254msgctxt "#90223"
2255msgid "Client Key"
2256msgstr "Clave de Cliente"
2257
2258msgctxt "#90224"
2259msgid "Your personal Fanart.tv client key for higher quality assets."
2260msgstr "Introduce tu Clave de Cliente personal de Fanart.tv para obtener mejores resultados y mayor velocidad (opcional)."
2261
2262msgctxt "#90225"
2263msgid "Debrid Notifications"
2264msgstr "Notificaciones de Debrid"
2265
2266msgctxt "#90226"
2267msgid "Debrid Expiration Notification"
2268msgstr "Notificación de Expiración de Debrid"
2269
2270msgctxt "#90227"
2271msgid "Enable or disable notifications when your Debrid subscription is about to expire."
2272msgstr "Activa o desactiva las notificaciones cuando tu suscripción a Debrid esté a punto de expirar."
2273
2274msgctxt "#90228"
2275msgid "Expiration Threshold (Days)"
2276msgstr "Umbral de Expiración (Días)"
2277
2278msgctxt "#90229"
2279msgid "How many days before expiration to start notifying."
2280msgstr "Cuántos días antes de la expiración se debe empezar a notificar."
2281
2282msgctxt "#90230"
2283msgid "%s expires in %d days."
2284msgstr "%s caduca en %d días."
2285
2286msgctxt "#90231"
2287msgid "%s expires today!"
2288msgstr "¡%s caduca hoy!"
2289
2290msgctxt "#90232"
2291msgid "Easynews"
2292msgstr "Easynews"
2293
2294msgctxt "#90233"
2295msgid "Enable EasyNews as a direct stream provider."
2296msgstr "Habilitar EasyNews como proveedor de streaming directo."
2297
2298msgctxt "#90234"
2299msgid "Your EasyNews username."
2300msgstr "Tu nombre de usuario de EasyNews."
2301
2302msgctxt "#90235"
2303msgid "Your EasyNews password."
2304msgstr "Tu contraseña de EasyNews."
2305
2306msgctxt "#90236"
2307msgid "Request timeout in seconds."
2308msgstr "Tiempo de espera de la solicitud en segundos."
2309
2310msgctxt "#90237"
2311msgid "Account Information"
2312msgstr "Información de la Cuenta"
2313
2314msgctxt "#90238"
2315msgid "Search Dialog Style"
2316msgstr "Estilo de Diálogo de Búsqueda"
2317
2318msgctxt "#90239"
2319msgid "Background"
2320msgstr "Segundo Plano"
2321
2322msgctxt "#90240"
2323msgid "Detailed Status"
2324msgstr "Estado Detallado"
2325
2326msgctxt "#90241"
2327msgid "Search Status"
2328msgstr "Estado de Búsqueda"
2329
2330msgctxt "#90242"
2331msgid "Cancel"
2332msgstr "Cancelar"
2333
2334msgctxt "#90243"
2335msgid "Results found: "
2336msgstr "Resultados encontrados: "
2337
2338msgctxt "#90244"
2339msgid "Pending"
2340msgstr "Pendiente"
2341
2342msgctxt "#90245"
2343msgid "In Progress"
2344msgstr "En progreso"
2345
2346msgctxt "#90246"
2347msgid "Completed"
2348msgstr "Completado"
2349
2350msgctxt "#90247"
2351msgid "Failed"
2352msgstr "Fallido"
2353
2354msgctxt "#90248"
2355msgid "Cancelled"
2356msgstr "Cancelado"
2357
2358msgctxt "#90249"
2359msgid "Your personal Jackgram API token for authentication."
2360msgstr "Tu token de API personal de Jackgram para autenticación."
2361
2362msgctxt "#90250"
2363msgid "Subtitles Found"
2364msgstr "Subtitulos encontrados"
2365
2366msgctxt "#90251"
2367msgid "There are already downloaded subtitles for this video.\nDo you want to use the existing subtitles?"
2368msgstr "Ya hay subtitulos descargados para este video.\nQuieres usar los subtitulos existentes?"
2369
2370msgctxt "#90252"
2371msgid "No subtitles found"
2372msgstr "No se encontraron subtitulos"
2373
2374msgctxt "#90253"
2375msgid "No subtitles selected"
2376msgstr "No se seleccionaron subtitulos"
2377
2378msgctxt "#90254"
2379msgid "Translate Subtitles"
2380msgstr "Traducir subtitulos"
2381
2382msgctxt "#90255"
2383msgid "Do you want to also translate the selected subtitles?"
2384msgstr "Quieres traducir tambien los subtitulos seleccionados?"
2385
2386msgctxt "#90256"
2387msgid "Select Subtitle to Download"
2388msgstr "Selecciona subtitulos para descargar"
2389
2390msgctxt "#90257"
2391msgid "Subtitles Loaded"
2392msgstr "Subtitulos cargados"
2393
2394msgctxt "#90258"
2395msgid "Backup"
2396msgstr "Copia de seguridad"
2397
2398msgctxt "#90259"
2399msgid "Export Backup"
2400msgstr "Exportar copia de seguridad"
2401
2402msgctxt "#90260"
2403msgid "Export addon settings and custom Stremio addons to a JSON backup file."
2404msgstr "Exporta la configuracion del addon y los addons personalizados de Stremio a un archivo JSON de copia de seguridad."
2405
2406msgctxt "#90261"
2407msgid "Restore Backup"
2408msgstr "Restaurar copia de seguridad"
2409
2410msgctxt "#90262"
2411msgid "Restore addon settings and replace custom Stremio addons from a JSON backup file."
2412msgstr "Restaura la configuracion del addon y reemplaza los addons personalizados de Stremio desde un archivo JSON de copia de seguridad."
2413
2414msgctxt "#90263"
2415msgid "Remove Sensitive Data"
2416msgstr "Eliminar datos sensibles"
2417
2418msgctxt "#90264"
2419msgid "Remove API keys, passwords, usernames, emails, auth state, and custom Stremio addon URLs from exported backups."
2420msgstr "Elimina claves API, contrasenas, nombres de usuario, correos, estado de autenticacion y URLs de addons personalizados de Stremio de las copias exportadas."
2421
2422msgctxt "#90265"
2423msgid "Settings backup exported"
2424msgstr "Copia de seguridad exportada"
2425
2426msgctxt "#90266"
2427msgid "Settings backup restored"
2428msgstr "Copia de seguridad restaurada"
2429
2430msgctxt "#90267"
2431msgid "Settings Backup Failed"
2432msgstr "Error en la copia de seguridad"
2433
2434msgctxt "#90268"
2435msgid "Select export folder"
2436msgstr "Selecciona la carpeta de exportacion"
2437
2438msgctxt "#90269"
2439msgid "Select backup file"
2440msgstr "Selecciona el archivo de copia"
2441
2442msgctxt "#90270"
2443msgid "Backup file is invalid."
2444msgstr "El archivo de copia no es valido."
2445
2446msgctxt "#90271"
2447msgid "No export folder selected."
2448msgstr "No se selecciono ninguna carpeta de exportacion."
2449
2450msgctxt "#90272"
2451msgid "No backup file selected."
2452msgstr "No se selecciono ningun archivo de copia."
2453
2454msgctxt "#90273"
2455msgid "Unable to export settings backup."
2456msgstr "No se pudo exportar la copia de seguridad."
2457
2458msgctxt "#90274"
2459msgid "Unable to restore settings backup."
2460msgstr "No se pudo restaurar la copia de seguridad."
2461
2462msgctxt "#90275"
2463msgid "Restore Backup"
2464msgstr "Restaurar copia de seguridad"
2465
2466msgctxt "#90276"
2467msgid "This will overwrite your current addon settings and replace your custom Stremio addons with the backup. Continue?"
2468msgstr "Esto sobrescribira la configuracion actual del addon y reemplazara tus addons personalizados de Stremio con la copia. Quieres continuar?"
2469
2470msgctxt "#90277"
2471msgid "Restore cancelled"
2472msgstr "Restauracion cancelada"
2473
2474msgctxt "#90278"
2475msgid "Reset All Settings"
2476msgstr "Restablecer toda la configuracion"
2477
2478msgctxt "#90279"
2479msgid "This will reset all addon settings to their defaults and remove your custom Stremio addons. Continue?"
2480msgstr "Esto restablecera toda la configuracion del addon a sus valores predeterminados y eliminara tus addons personalizados de Stremio. Quieres continuar?"
2481
2482msgctxt "#90280"
2483msgid "Reset cancelled"
2484msgstr "Restablecimiento cancelado"
2485
2486msgctxt "#90281"
2487msgid "All addon settings reset"
2488msgstr "Toda la configuracion del addon fue restablecida"
2489
2490msgctxt "#90282"
2491msgid "Reset Failed"
2492msgstr "Error al restablecer"
2493
2494msgctxt "#90283"
2495msgid "Unable to reset addon settings."
2496msgstr "No se pudo restablecer la configuracion del addon."
2497
2498msgctxt "#90284"
2499msgid "Reset every addon setting back to its default value and clear custom Stremio addons."
2500msgstr "Restablece cada ajuste del addon a su valor predeterminado y borra los addons personalizados de Stremio."
2501
2502msgctxt "#90285"
2503msgid "Factory Reset"
2504msgstr "Restablecimiento de fabrica"
2505
2506msgctxt "#90286"
2507msgid "This will reset settings, clear caches, remove custom Stremio addons, and wipe local addon history/database data. Continue?"
2508msgstr "Esto restablecera la configuracion, limpiara las cache, eliminara los addons personalizados de Stremio y borrara el historial y los datos locales del addon. Quieres continuar?"
2509
2510msgctxt "#90287"
2511msgid "Factory reset cancelled"
2512msgstr "Restablecimiento de fabrica cancelado"
2513
2514msgctxt "#90288"
2515msgid "Factory reset completed"
2516msgstr "Restablecimiento de fabrica completado"
2517
2518msgctxt "#90289"
2519msgid "Factory Reset Failed"
2520msgstr "Error en el restablecimiento de fabrica"
2521
2522msgctxt "#90290"
2523msgid "Unable to complete factory reset."
2524msgstr "No se pudo completar el restablecimiento de fabrica."
2525
2526msgctxt "#90291"
2527msgid "Reset settings to defaults, clear caches, and wipe local addon history/database data."
2528msgstr "Restablece la configuracion a los valores predeterminados, limpia las cache y borra el historial y los datos locales del addon."
2529
2530msgctxt "#90292"
2531msgid "Trakt Library"
2532msgstr "Biblioteca de Trakt"
2533
2534msgctxt "#90293"
2535msgid "Trakt Discovery"
2536msgstr "Descubrimiento de Trakt"
2537
2538msgctxt "#90294"
2539msgid "Collection"
2540msgstr "Coleccion"
2541
2542msgctxt "#90295"
2543msgid "Favorites"
2544msgstr "Favoritos"
2545
2546msgctxt "#90296"
2547msgid "Up Next"
2548msgstr "A continuacion"
2549
2550msgctxt "#90297"
2551msgid "My Lists"
2552msgstr "Mis listas"
2553
2554msgctxt "#90298"
2555msgid "Liked Lists"
2556msgstr "Listas con Me gusta"
2557
2558msgctxt "#90331"
2559msgid "Stremio Addons"
2560msgstr "Complementos de Stremio"
2561
2562msgctxt "#90332"
2563msgid "No addons available yet. Use Log In to Stremio or Add Custom Addon first."
2564msgstr "Todavia no hay complementos disponibles. Usa Iniciar sesion en Stremio o Agregar Complemento Personalizado primero."
2565
2566msgctxt "#90333"
2567msgid "Stremio Stream Addons"
2568msgstr "Complementos de streams de Stremio"
2569
2570msgctxt "#90334"
2571msgid "Stremio Catalog Addons"
2572msgstr "Complementos de catalogo de Stremio"
2573
2574msgctxt "#90335"
2575msgid "Stremio Live TV Addons"
2576msgstr "Complementos de TV en vivo de Stremio"
2577
2578msgctxt "#90336"
2579msgid "Refresh addons imported from your Stremio account. Custom addons remain unchanged."
2580msgstr "Actualiza los complementos importados desde tu cuenta de Stremio. Los complementos personalizados no se modifican."
2581
2582msgctxt "#90337"
2583msgid "Choose which imported or custom Stremio stream addons to use. If none are available yet, log in or add a custom addon first."
2584msgstr "Elige que complementos de streams de Stremio importados o personalizados quieres usar. Si aun no hay disponibles, inicia sesion o agrega un complemento personalizado primero."
2585
2586msgctxt "#90338"
2587msgid "Choose which imported or custom Stremio catalog addons to use. If none are available yet, log in or add a custom addon first."
2588msgstr "Elige que complementos de catalogo de Stremio importados o personalizados quieres usar. Si aun no hay disponibles, inicia sesion o agrega un complemento personalizado primero."
2589
2590msgctxt "#90339"
2591msgid "Choose which imported or custom Stremio Live TV addons to use. If none are available yet, log in or add a custom addon first."
2592msgstr "Elige que complementos de TV en vivo de Stremio importados o personalizados quieres usar. Si aun no hay disponibles, inicia sesion o agrega un complemento personalizado primero."
2593
2594msgctxt "#90340"
2595msgid "Remove custom Stremio addons you previously added manually."
2596msgstr "Elimina los complementos personalizados de Stremio que agregaste manualmente."
2597
2598msgctxt "#90341"
2599msgid "Choose Torrent Client"
2600msgstr "Cliente Torrent"
2601
2602msgctxt "#90342"
2603msgid "Clear Database Cache"
2604msgstr "Limpiar cache de la base de datos"
2605
2606msgctxt "#90343"
2607msgid "Clear Stremio Cache"
2608msgstr "Limpiar cache de Stremio"
2609
2610msgctxt "#90344"
2611msgid "Clear Debrid Cache"
2612msgstr "Limpiar cache de Debrid"
2613
2614msgctxt "#90345"
2615msgid "Clear MDBList Cache"
2616msgstr "Limpiar cache de MDBList"
2617
2618msgctxt "#90346"
2619msgid "Restore backup from"
2620msgstr "Restaurar copia desde"
2621
2622msgctxt "#90347"
2623msgid "File"
2624msgstr "Archivo"
2625
2626msgctxt "#90348"
2627msgid "URL"
2628msgstr "URL"
2629
2630msgctxt "#90349"
2631msgid "Enter backup URL"
2632msgstr "Introduce la URL de la copia"
2633
2634msgctxt "#90350"
2635msgid "No backup URL provided."
2636msgstr "No se proporciono ninguna URL de copia."
2637
2638msgctxt "#90351"
2639msgid "Unable to download settings backup."
2640msgstr "No se pudo descargar la copia de seguridad."
2641
2642msgctxt "#90352"
2643msgid "Source Select Style"
2644msgstr "Estilo de selección de fuente"
2645
2646msgctxt "#90353"
2647msgid "Modern"
2648msgstr "Moderno"
2649
2650msgctxt "#90354"
2651msgid "OLED"
2652msgstr "OLED"
2653
2654msgctxt "#90355"
2655msgid "Cyberpunk"
2656msgstr "Cyberpunk"
2657
2658msgctxt "#90356"
2659msgid "Forest"
2660msgstr "Bosque"
2661
2662msgctxt "#90357"
2663msgid "Gold"
2664msgstr "Oro"
2665
2666msgctxt "#90358"
2667msgid "No playable results found"
2668msgstr "No se encontraron resultados reproducibles"
2669
2670msgctxt "#90359"
2671msgid "Add to TorrServer"
2672msgstr "Agregar a TorrServer"
2673
2674msgctxt "#90360"
2675msgid "Added to TorrServer"
2676msgstr "Agregado a TorrServer"
2677
2678msgctxt "#90361"
2679msgid "Could not resolve torrent details"
2680msgstr "No se pudieron resolver los detalles del torrent"
2681
2682msgctxt "#90362"
2683msgid "Added to %s cloud"
2684msgstr "Agregado a la nube de %s"
2685
2686msgctxt "#90363"
2687msgid "Choose Debrid Service"
2688msgstr "Elegir servicio Debrid"
2689
2690msgctxt "#90364"
2691msgid "No supported debrid service is enabled"
2692msgstr "No hay ningun servicio Debrid compatible habilitado"
2693
2694msgctxt "#90365"
2695msgid "Download to Debrid"
2696msgstr "Descargar a Debrid"
2697
2698msgctxt "#90366"
2699msgid "Include Unknown quality"
2700msgstr "Incluir calidad desconocida"
2701
2702msgctxt "#90367"
2703msgid "Includes releases where video quality could not be identified"
2704msgstr "Incluye releases donde no se pudo identificar la calidad de video"
2705
2706msgctxt "#90368"
2707msgid "Search by Keyword"
2708msgstr "Buscar por palabra clave"
2709
2710msgctxt "#90369"
2711msgid "Latest Movies"
2712msgstr "Últimas películas"
2713
2714msgctxt "#90370"
2715msgid "Latest Series"
2716msgstr "Últimas series"
2717
2718msgctxt "#90371"
2719msgid "Raw Files"
2720msgstr "Archivos originales"
2721
2722msgctxt "#90372"
2723msgid "MDblist"
2724msgstr "MDblist"
2725
2726msgctxt "#90373"
2727msgid "Active Downloads"
2728msgstr "Descargas activas"
2729
2730msgctxt "#90374"
2731msgid "Cancel Download"
2732msgstr "Cancelar descarga"
2733
2734msgctxt "#90375"
2735msgid "Delete File"
2736msgstr "Eliminar archivo"
2737
2738msgctxt "#90376"
2739msgid "Search Collections"
2740msgstr "Buscar colecciones"
2741
2742msgctxt "#90377"
2743msgid "Popular Collections"
2744msgstr "Colecciones populares"
2745
2746msgctxt "#90378"
2747msgid "Top Rated Collections"
2748msgstr "Colecciones mejor valoradas"
2749
2750msgctxt "#90379"
2751msgid "Recommendations"
2752msgstr "Recomendaciones"
2753
2754msgctxt "#90380"
2755msgid "Similar"
2756msgstr "Similares"
2757
2758msgctxt "#90381"
2759msgid "Keyword: %s"
2760msgstr "Palabra clave: %s"
2761
2762msgctxt "#90410"
2763msgid "Error modifying Trakt Watchlist"
2764msgstr "Error al modificar la lista de seguimiento de Trakt"
2765
2766msgctxt "#90411"
2767msgid "Marked as Watched on Trakt"
2768msgstr "Marcado como visto en Trakt"
2769
2770msgctxt "#90412"
2771msgid "Error modifying Trakt Watched status"
2772msgstr "Error al modificar el estado de visto en Trakt"
2773
2774msgctxt "#90413"
2775msgid "Play"
2776msgstr "Reproducir"
2777
2778msgctxt "#90414"
2779msgid "Trailer"
2780msgstr "Tráiler"
2781
2782msgctxt "#90415"
2783msgid "Watched"
2784msgstr "Visto"
2785
2786msgctxt "#90416"
2787msgid "Refresh"
2788msgstr "Refrescar"
2789
2790msgctxt "#90418"
2791msgid "No credits found for this ID"
2792msgstr "No se encontraron créditos para este ID"
2793
2794msgctxt "#90419"
2795msgid "Credited People"
2796msgstr "Personas Acreditadas"
2797
2798msgctxt "#90420"
2799msgid "Person Details"
2800msgstr "Detalles de la Persona"
2801
2802msgctxt "#90421"
2803msgid "Person not found"
2804msgstr "Persona no encontrada"
2805
2806msgctxt "#90422"
2807msgid "Jackgram not activated"
2808msgstr "Jackgram no activado."
2809
2810msgctxt "#90423"
2811msgid "Not yet implemented"
2812msgstr "Aún no implementado"
2813
2814msgctxt "#90424"
2815msgid "Unsupported debrid type"
2816msgstr "Tipo de debrid no compatible"
2817
2818msgctxt "#90425"
2819msgid "Easynews credentials required"
2820msgstr "Se requieren credenciales de Easynews"
2821
2822msgctxt "#90426"
2823msgid "No Corrupt or Missing Databases"
2824msgstr "No hay bases de datos corruptas o faltantes"
2825
2826msgctxt "#90427"
2827msgid "Otaku (script.otaku.mappings) not found"
2828msgstr "No se encontró Otaku (script.otaku.mappings)"
2829
2830msgctxt "#90431"
2831msgid "No items found in this list"
2832msgstr "No se encontraron elementos en esta lista"
2833
2834msgctxt "#90432"
2835msgid "Databases Rebuilt"
2836msgstr "Bases de datos reconstruidas"
2837
2838msgctxt "#90433"
2839msgid "[B]Following Databases Rebuilt:[/B][CR][CR]%s"
2840msgstr "[B]Se reconstruyeron las siguientes bases de datos:[/B][CR][CR]%s"
2841
2842msgctxt "#90434"
2843msgid "Added to Trakt watchlist"
2844msgstr "Añadido a la lista de seguimiento de Trakt"
2845
2846msgctxt "#90435"
2847msgid "Failed to add to Trakt watchlist"
2848msgstr "Error al añadir a la lista de seguimiento de Trakt"
2849
2850msgctxt "#90436"
2851msgid "Removed from Trakt watchlist"
2852msgstr "Eliminado de la lista de seguimiento de Trakt"
2853
2854msgctxt "#90437"
2855msgid "Failed to remove from Trakt watchlist"
2856msgstr "Error al eliminar de la lista de seguimiento de Trakt"
2857
2858msgctxt "#90438"
2859msgid "Failed to mark as watched on Trakt"
2860msgstr "Error al marcar como visto en Trakt"
2861
2862msgctxt "#90439"
2863msgid "Marked as unwatched on Trakt"
2864msgstr "Marcado como no visto en Trakt"
2865
2866msgctxt "#90440"
2867msgid "Failed to mark as unwatched on Trakt"
2868msgstr "Error al marcar como no visto en Trakt"
2869
2870msgctxt "#90441"
2871msgid "Added to Trakt collection"
2872msgstr "Añadido a la colección de Trakt"
2873
2874msgctxt "#90442"
2875msgid "Removed from Trakt collection"
2876msgstr "Eliminado de la colección de Trakt"
2877
2878msgctxt "#90443"
2879msgid "Failed to add to Trakt collection"
2880msgstr "Error al añadir a la colección de Trakt"
2881
2882msgctxt "#90444"
2883msgid "Failed to remove from Trakt collection"
2884msgstr "Error al eliminar de la colección de Trakt"
2885
2886msgctxt "#90445"
2887msgid "Added to Trakt favorites"
2888msgstr "Añadido a favoritos de Trakt"
2889
2890msgctxt "#90446"
2891msgid "Removed from Trakt favorites"
2892msgstr "Eliminado de favoritos de Trakt"
2893
2894msgctxt "#90447"
2895msgid "Failed to add to Trakt favorites"
2896msgstr "Error al añadir a favoritos de Trakt"
2897
2898msgctxt "#90448"
2899msgid "Failed to remove from Trakt favorites"
2900msgstr "Error al eliminar de favoritos de Trakt"
2901
2902msgctxt "#90449"
2903msgid "Created Trakt list"
2904msgstr "Lista de Trakt creada"
2905
2906msgctxt "#90450"
2907msgid "Failed to create Trakt list"
2908msgstr "Error al crear la lista de Trakt"
2909
2910msgctxt "#90451"
2911msgid "Deleted Trakt list"
2912msgstr "Lista de Trakt eliminada"
2913
2914msgctxt "#90452"
2915msgid "Failed to delete Trakt list"
2916msgstr "Error al eliminar la lista de Trakt"
2917
2918msgctxt "#90453"
2919msgid "Liked Trakt list"
2920msgstr "Te gusta la lista de Trakt"
2921
2922msgctxt "#90454"
2923msgid "Failed to like Trakt list"
2924msgstr "Error al marcar como me gusta la lista de Trakt"
2925
2926msgctxt "#90455"
2927msgid "Unliked Trakt list"
2928msgstr "Ya no te gusta la lista de Trakt"
2929
2930msgctxt "#90456"
2931msgid "Failed to unlike Trakt list"
2932msgstr "Error al quitar me gusta de la lista de Trakt"
2933
2934msgctxt "#90457"
2935msgid "Added to Trakt list"
2936msgstr "Añadido a la lista de Trakt"
2937
2938msgctxt "#90459"
2939msgid "Failed to add to Trakt list"
2940msgstr "Error al añadir a la lista de Trakt"
2941
2942msgctxt "#90461"
2943msgid "No Trakt lists found"
2944msgstr "No se encontraron listas de Trakt"
2945
2946msgctxt "#90462"
2947msgid "Item not found in Trakt collection"
2948msgstr "Elemento no encontrado en la colección de Trakt"
2949
2950msgctxt "#90463"
2951msgid "Trakt could not match this item for collection"
2952msgstr "Trakt no pudo asociar este elemento a la colección"
2953
2954msgctxt "#90464"
2955msgid "No Trakt collection changes were applied"
2956msgstr "No se aplicaron cambios en la colección de Trakt"
2957
2958msgctxt "#90465"
2959msgid "Could not build Trakt collection payload for show"
2960msgstr "No se pudo construir la carga de colección de Trakt para la serie"
2961
2962msgctxt "#90466"
2963msgid "Delete this Trakt list?"
2964msgstr "¿Eliminar esta lista de Trakt?"
2965
2966
2967msgctxt "#90510"
2968msgid "No meta available"
2969msgstr "Sin metadatos"
2970
2971msgctxt "#90511"
2972msgid "No content available for"
2973msgstr "Sin contenido para"
2974
2975msgctxt "#90512"
2976msgid "Season"
2977msgstr "Temporada"
2978
2979msgctxt "#90513"
2980msgid "No episodes available"
2981msgstr "Sin episodios"
2982
2983msgctxt "#90514"
2984msgid "No videos available"
2985msgstr "Sin videos"
2986
2987msgctxt "#90515"
2988msgid "Next Page"
2989msgstr "Página siguiente"
2990
2991msgctxt "#90516"
2992msgid "No metas available"
2993msgstr "No hay metadatos disponibles"
2994
2995msgctxt "#90517"
2996msgid "No seasons available"
2997msgstr "No hay temporadas disponibles"
2998
2999msgctxt "#90518"
3000msgid "Invalid query"
3001msgstr "Consulta no válida"
3002
3003msgctxt "#90519"
3004msgid "No lists found"
3005msgstr "No se encontraron listas"
3006
3007msgctxt "#90520"
3008msgid "No user lists found"
3009msgstr "No se encontraron listas de usuario"
3010
3011msgctxt "#90521"
3012msgid "No top lists found"
3013msgstr "No se encontraron listas destacadas"
3014
3015msgctxt "#90522"
3016msgid "Custom Addon"
3017msgstr "Addon personalizado"
3018
3019msgctxt "#90523"
3020msgid "Enter the custom Stremio addon URL"
3021msgstr "Introduce la URL del addon personalizado de Stremio"
3022
3023msgctxt "#90524"
3024msgid "No URL provided."
3025msgstr "No se proporcionó ninguna URL."
3026
3027msgctxt "#90525"
3028msgid "Failed to fetch manifest: %s"
3029msgstr "Error al obtener el manifiesto: %s"
3030
3031msgctxt "#90526"
3032msgid "Manifest missing 'id' or 'name'."
3033msgstr "Al manifiesto le falta 'id' o 'name'."
3034
3035msgctxt "#90527"
3036msgid "Custom Stremio addon added successfully!"
3037msgstr "Addon personalizado de Stremio añadido correctamente"
3038
3039msgctxt "#90528"
3040msgid "Addon does not provide 'stream' or 'catalog' resources."
3041msgstr "El addon no proporciona recursos 'stream' o 'catalog'."
3042
3043msgctxt "#90529"
3044msgid "This addon is already added to your list."
3045msgstr "Este addon ya está añadido a tu lista."
3046
3047msgctxt "#90530"
3048msgid "Failed to add custom addon: %s"
3049msgstr "Error al añadir el addon personalizado: %s"
3050
3051msgctxt "#90531"
3052msgid "Remove Addon"
3053msgstr "Eliminar addon"
3054
3055msgctxt "#90532"
3056msgid "No custom Stremio addons to remove."
3057msgstr "No hay addons personalizados de Stremio para eliminar."
3058
3059msgctxt "#90533"
3060msgid "Remove Stremio Addons"
3061msgstr "Eliminar addons de Stremio"
3062
3063msgctxt "#90534"
3064msgid "Selected addon(s) removed."
3065msgstr "Addon(s) seleccionado(s) eliminado(s)."
3066
3067msgctxt "#90535"
3068msgid "Untitled List"
3069msgstr "Lista sin titulo"
3070
3071msgctxt "#90536"
3072msgid "Removed from Trakt list"
3073msgstr "Eliminado de la lista de Trakt"
3074
3075msgctxt "#90537"
3076msgid "Failed to remove from Trakt list"
3077msgstr "Error al eliminar de la lista de Trakt"
3078
3079msgctxt "#90538"
3080msgid "Resume from %.1f%%"
3081msgstr "Reanudar desde %.1f%%"
3082
3083msgctxt "#90539"
3084msgid "Start from Beginning"
3085msgstr "Empezar desde el principio"
3086
3087msgctxt "#90540"
3088msgid "Authorize Debrid Services"
3089msgstr "Autorizar servicios Debrid"
3090
3091msgctxt "#90541"
3092msgid "Navigate to: [B]%s[/B]"
3093msgstr "Ve a: [B]%s[/B]"
3094
3095msgctxt "#90542"
3096msgid "Enter the following code: [COLOR orangered][B]%s[/B][/COLOR]"
3097msgstr "Introduce el siguiente codigo: [COLOR orangered][B]%s[/B][/COLOR]"
3098
3099msgctxt "#90543"
3100msgid "Premiumize Auth"
3101msgstr "Autenticacion de Premiumize"
3102
3103msgctxt "#90544"
3104msgid "Success"
3105msgstr "Exito"
3106
3107msgctxt "#90545"
3108msgid "Authentication completed."
3109msgstr "Autenticacion completada."
3110
3111msgctxt "#90546"
3112msgid "Error:"
3113msgstr "Error:"
3114
3115msgctxt "#90547"
3116msgid "Error: %s"
3117msgstr "Error: %s"
3118
3119msgctxt "#90548"
3120msgid "Auth Error"
3121msgstr "Error de autenticacion"
3122
3123msgctxt "#90549"
3124msgid "Torbox authentication successful."
3125msgstr "Autenticacion de Torbox completada correctamente."
3126
3127msgctxt "#90554"
3128msgid "Please wait..."
3129msgstr "Por favor, espera..."
3130
3131msgctxt "#90555"
3132msgid "Go to:\n[COLOR cyan]%s[/COLOR]\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3133msgstr "Ve a:\n[COLOR cyan]%s[/COLOR]\nIntroduce el codigo: [COLOR seagreen][B]%s[/B][/COLOR]"
3134
3135msgctxt "#90556"
3136msgid "Go to:\n[COLOR cyan]%s[/COLOR]\n\nEnter code: [COLOR seagreen][B]%s[/B][/COLOR]"
3137msgstr "Ve a:\n[COLOR cyan]%s[/COLOR]\n\nIntroduce el codigo: [COLOR seagreen][B]%s[/B][/COLOR]"
3138
3139msgctxt "#90557"
3140msgid "Pastebin Link:\n[COLOR cyan]%s[/COLOR]"
3141msgstr "Enlace Pastebin:\n[COLOR cyan]%s[/COLOR]"
3142
3143msgctxt "#90670"
3144msgid "Local URL:\n[COLOR cyan]%s[/COLOR]"
3145msgstr "URL local:\n[COLOR cyan]%s[/COLOR]"
3146
3147msgctxt "#90558"
3148msgid "QR Code Authentication"
3149msgstr "Autenticacion por codigo QR"
3150
3151msgctxt "#90559"
3152msgid "Kodi 20 or above required. Please update Kodi to use this addon."
3153msgstr "Se requiere Kodi 20 o superior. Actualiza Kodi para usar este addon."
3154
3155msgctxt "#90560"
3156msgid "Failed to start web server. Port may be in use."
3157msgstr "Error al iniciar el servidor web. Puede que el puerto este en uso."
3158
3159msgctxt "#90561"
3160msgid "Authentification Removed."
3161msgstr "Autenticacion eliminada."
3162
3163msgctxt "#90562"
3164msgid "WebDAV Error"
3165msgstr "Error de WebDAV"
3166
3167msgctxt "#90563"
3168msgid "Trakt Authorization"
3169msgstr "Autorizacion de Trakt"
3170
3171msgctxt "#90564"
3172msgid "Do you want to continue transfer in background?"
3173msgstr "¿Quieres continuar la transferencia en segundo plano?"
3174
3175msgctxt "#90565"
3176msgid "Cloud Transfer"
3177msgstr "Transferencia en la nube"
3178
3179msgctxt "#90566"
3180msgid "Kodi Logs"
3181msgstr "Registros de Kodi"
3182
3183msgctxt "#90567"
3184msgid "Show Logs"
3185msgstr "Mostrar registros"
3186
3187msgctxt "#90568"
3188msgid "Export to paste.kodi.tv"
3189msgstr "Exportar a paste.kodi.tv"
3190
3191msgctxt "#90569"
3192msgid "Kodi Logs Exported"
3193msgstr "Registros de Kodi exportados"
3194
3195msgctxt "#90570"
3196msgid "Providers Selection Dialog"
3197msgstr "Dialogo de seleccion de proveedores"
3198
3199msgctxt "#90571"
3200msgid "Your current Providers are: \n%s\n\nDo you want to change?"
3201msgstr "Tus proveedores actuales son: \n%s\n\n¿Quieres cambiarlos?"
3202
3203msgctxt "#90572"
3204msgid "Select Providers"
3205msgstr "Seleccionar proveedores"
3206
3207msgctxt "#90573"
3208msgid "Selection Dialog"
3209msgstr "Dialogo de seleccion"
3210
3211msgctxt "#90574"
3212msgid "Successfully selected: %s"
3213msgstr "Seleccionado correctamente: %s"
3214
3215msgctxt "#90575"
3216msgid "Selection"
3217msgstr "Seleccion"
3218
3219msgctxt "#90576"
3220msgid "No providers selected"
3221msgstr "No se seleccionaron proveedores"
3222
3223msgctxt "#90577"
3224msgid "Changelog"
3225msgstr "Registro de cambios"
3226
3227msgctxt "#90578"
3228msgid "[B]Unable to check for updates[/B]"
3229msgstr "[B]No se pudieron comprobar las actualizaciones[/B]"
3230
3231msgctxt "#90579"
3232msgid "[B]No update available[/B]"
3233msgstr "[B]No hay actualizaciones disponibles[/B]"
3234
3235msgctxt "#90580"
3236msgid "Installed: [B]%s[/B][CR]Online: [B]%s[/B][CR][CR]"
3237msgstr "Instalado: [B]%s[/B][CR]En linea: [B]%s[/B][CR][CR]"
3238
3239msgctxt "#90581"
3240msgid "[B]Update available. Do you want to update?[/B]"
3241msgstr "[B]Hay una actualizacion disponible. ¿Quieres actualizar?[/B]"
3242
3243msgctxt "#90582"
3244msgid "[B]Update Available (v%s).[/B] [I]Check settings to update.[/I]"
3245msgstr "[B]Actualizacion disponible (v%s).[/B] [I]Revisa la configuracion para actualizar.[/I]"
3246
3247msgctxt "#90583"
3248msgid "Error fetching versions: %s"
3249msgstr "Error al obtener las versiones: %s"
3250
3251msgctxt "#90584"
3252msgid "No older/different versions found."
3253msgstr "No se encontraron versiones anteriores o distintas."
3254
3255msgctxt "#90585"
3256msgid "Select Version to Downgrade"
3257msgstr "Selecciona la version a la que bajar"
3258
3259msgctxt "#90586"
3260msgid "Are you sure you want to downgrade/reinstall to version [B]%s[/B]?"
3261msgstr "¿Seguro que quieres bajar de version o reinstalar la version [B]%s[/B]?"
3262
3263msgctxt "#90587"
3264msgid "[B]Downloading update...[/B]"
3265msgstr "[B]Descargando actualizacion...[/B]"
3266
3267msgctxt "#90588"
3268msgid "Error: Unable to download update."
3269msgstr "Error: no se puede descargar la actualizacion."
3270
3271msgctxt "#90589"
3272msgid "Error saving update file: %s"
3273msgstr "Error al guardar el archivo de actualizacion: %s"
3274
3275msgctxt "#90590"
3276msgid "Error extracting update. Please install manually."
3277msgstr "Error al extraer la actualizacion. Instalala manualmente."
3278
3279msgctxt "#90591"
3280msgid "Do you want to view the changelog for the new version?"
3281msgstr "¿Quieres ver el registro de cambios de la nueva version?"
3282
3283msgctxt "#90592"
3284msgid "New Release v%s - Changelog"
3285msgstr "Nueva version v%s - Registro de cambios"
3286
3287msgctxt "#90593"
3288msgid "[B]Update complete.[/B]"
3289msgstr "[B]Actualizacion completada.[/B]"
3290
3291msgctxt "#90594"
3292msgid "Real Debrid Auth"
3293msgstr "Autenticacion de Real Debrid"
3294
3295msgctxt "#90595"
3296msgid "Torbox Auth"
3297msgstr "Autenticacion de Torbox"
3298
3299msgctxt "#90596"
3300msgid "Debrider Auth"
3301msgstr "Autenticacion de Debrider"
3302
3303msgctxt "#90597"
3304msgid "Download Error"
3305msgstr "Error de descarga"
3306
3307msgctxt "#90598"
3308msgid "Failed to download text: %s"
3309msgstr "Error al descargar el texto: %s"
3310
3311msgctxt "#90599"
3312msgid "General Error"
3313msgstr "Error general"
3314
3315msgctxt "#90600"
3316msgid "Could not display text file."
3317msgstr "No se pudo mostrar el archivo de texto."
3318
3319msgctxt "#90601"
3320msgid "Failed to export logs."
3321msgstr "Error al exportar los registros."
3322
3323msgctxt "#90602"
3324msgid "Error reading log: %s"
3325msgstr "Error al leer el registro: %s"
3326
3327msgctxt "#90603"
3328msgid "Kodi log file not found."
3329msgstr "No se encontro el archivo de registro de Kodi."
3330
3331msgctxt "#90604"
3332msgid "%s Auth"
3333msgstr "Autenticacion de %s"
3334
3335msgctxt "#90605"
3336msgid "Ok"
3337msgstr "Aceptar"
3338
3339msgctxt "#90606"
3340msgid "No"
3341msgstr "No"
3342
3343msgctxt "#90607"
3344msgid "Pinging addons for availability..."
3345msgstr "Comprobando disponibilidad de addons..."
3346
3347msgctxt "#90608"
3348msgid "Pinging addons... %d/%d"
3349msgstr "Comprobando addons... %d/%d"
3350
3351msgctxt "#90609"
3352msgid "Ping Results"
3353msgstr "Resultados del ping"
3354
3355msgctxt "#90610"
3356msgid "Reachable: %d\nUnreachable: %d"
3357msgstr "Accesibles: %d\nNo accesibles: %d"
3358
3359msgctxt "#90611"
3360msgid "Select Category to Filter"
3361msgstr "Selecciona la categoria a filtrar"
3362
3363msgctxt "#90612"
3364msgid "Stremio Add-ons Import"
3365msgstr "Importacion de addons de Stremio"
3366
3367msgctxt "#90613"
3368msgid "To import your add-ons, please log in with your Stremio email and password."
3369msgstr "Para importar tus addons, inicia sesion con tu correo y contrasena de Stremio."
3370
3371msgctxt "#90614"
3372msgid "Enter your Email"
3373msgstr "Introduce tu correo"
3374
3375msgctxt "#90615"
3376msgid "Enter your Password"
3377msgstr "Introduce tu contrasena"
3378
3379msgctxt "#90616"
3380msgid "Login Failed"
3381msgstr "Error de inicio de sesion"
3382
3383msgctxt "#90617"
3384msgid "Failed to login: %s"
3385msgstr "Error al iniciar sesion: %s"
3386
3387msgctxt "#90618"
3388msgid "Add-ons Import Failed"
3389msgstr "Error al importar addons"
3390
3391msgctxt "#90619"
3392msgid "Please try again later and report the issue if the problem persists. For more details, check the log file."
3393msgstr "Vuelve a intentarlo mas tarde y reporta el problema si persiste. Para mas detalles, revisa el archivo de log."
3394
3395msgctxt "#90620"
3396msgid "Addons Imported"
3397msgstr "Addons importados"
3398
3399msgctxt "#90621"
3400msgid "Successfully imported addons from your account."
3401msgstr "Se han importado correctamente los addons de tu cuenta."
3402
3403msgctxt "#90622"
3404msgid "Update Stremio Addons"
3405msgstr "Actualizar addons de Stremio"
3406
3407msgctxt "#90623"
3408msgid "Do you want to update the Addons from your account?"
3409msgstr "¿Quieres actualizar los addons de tu cuenta?"
3410
3411msgctxt "#90624"
3412msgid "Log Out from Stremio"
3413msgstr "Cerrar sesion en Stremio"
3414
3415msgctxt "#90625"
3416msgid "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."
3417msgstr "¿Seguro que quieres cerrar sesion? Puedes seguir usando Stremio sin iniciar sesion, pero tu configuracion se restablecera a los valores por defecto."
3418
3419msgctxt "#90626"
3420msgid "Log Out"
3421msgstr "Cerrar sesion"
3422
3423msgctxt "#90627"
3424msgid "Use Existing"
3425msgstr "Usar existentes"
3426
3427msgctxt "#90628"
3428msgid "Download New"
3429msgstr "Descargar nuevos"
3430
3431msgctxt "#90629"
3432msgid "Playback Failed"
3433msgstr "Error de reproduccion"
3434
3435msgctxt "#90630"
3436msgid "The video could not be played.\nDo you want to retry?"
3437msgstr "No se pudo reproducir el video.\n¿Quieres reintentar?"
3438
3439msgctxt "#90631"
3440msgid "Retry"
3441msgstr "Reintentar"
3442
3443msgctxt "#90632"
3444msgid "Information"
3445msgstr "Informacion"
3446
3447msgctxt "#90633"
3448msgid "Review"
3449msgstr "Resena"
3450
3451msgctxt "#90634"
3452msgid "Comment"
3453msgstr "Comentario"
3454
3455msgctxt "#90635"
3456msgid "Trivia"
3457msgstr "Trivia"
3458
3459msgctxt "#90636"
3460msgid "Goof"
3461msgstr "Error"
3462
3463msgctxt "#90637"
3464msgid "Parental Guide"
3465msgstr "Guia parental"
3466
3467msgctxt "#90638"
3468msgid "Converting Magnet...\n\n%s"
3469msgstr "Convirtiendo magnet...\n\n%s"
3470
3471msgctxt "#90639"
3472msgid "Real Debrid Error."
3473msgstr "Error de Real Debrid."
3474
3475msgctxt "#90640"
3476msgid "File already cached"
3477msgstr "Archivo ya almacenado en cache"
3478
3479msgctxt "#90641"
3480msgid "Saving file to the Real Debrid Cloud"
3481msgstr "Guardando archivo en la nube de Real Debrid"
3482
3483msgctxt "#90642"
3484msgid "File cached"
3485msgstr "Archivo almacenado en cache"
3486
3487msgctxt "#90643"
3488msgid "Saving File to the Real Debrid Cloud...\n%s\n\n"
3489msgstr "Guardando archivo en la nube de Real Debrid...\n%s\n\n"
3490
3491msgctxt "#90644"
3492msgid "Downloading %s GB @ %s mbps from %s peers, %s %% completed"
3493msgstr "Descargando %s GB a %s mbps desde %s pares, %s %% completado"
3494
3495msgctxt "#90645"
3496msgid "Trakt"
3497msgstr "Trakt"
3498
3499msgctxt "#90651"
3500msgid "Easynews Account Info"
3501msgstr "Informacion de cuenta de Easynews"
3502
3503msgctxt "#90652"
3504msgid "DeepL API Error"
3505msgstr "Error de la API de DeepL"
3506
3507msgctxt "#90653"
3508msgid "Download"
3509msgstr "Descarga"
3510
3511msgctxt "#90654"
3512msgid "Failed to start download: %s"
3513msgstr "Error al iniciar la descarga: %s"
3514
3515msgctxt "#90655"
3516msgid "Modify Search"
3517msgstr "Modificar busqueda"
3518
3519msgctxt "#90656"
3520msgid "Torbox"
3521msgstr "Torbox"
3522
3523msgctxt "#90657"
3524msgid "Easy-Debrid"
3525msgstr "Easy-Debrid"
3526
3527msgctxt "#90658"
3528msgid "All-Debrid"
3529msgstr "All-Debrid"
3530
3531msgctxt "#90659"
3532msgid "Real-Debrid"
3533msgstr "Real-Debrid"
3534
3535msgctxt "#90660"
3536msgid "Getting results from providers..."
3537msgstr "Obteniendo resultados de los proveedores..."
3538
3539msgctxt "#90661"
3540msgid "Jacktook [COLOR FFFF6B00]%s[/COLOR]"
3541msgstr "Jacktook [COLOR FFFF6B00]%s[/COLOR]"
3542
3543msgctxt "#90662"
3544msgid "Downloads Viewer"
3545msgstr "Visor de descargas"
3546
3547msgctxt "#90663"
3548msgid "WebDAV Text Viewer"
3549msgstr "Visor de texto WebDAV"
3550
3551msgctxt "#90664"
3552msgid "Selected Item"
3553msgstr "Elemento seleccionado"
3554
3555msgctxt "#90665"
3556msgid "Subtitle No. %d"
3557msgstr "Subtitulo num. %d"
3558
3559msgctxt "#90666"
3560msgid "Free DeepL usage available!\nCharacters left in free quota: %s\nCharacters to translate: %s\n\nDo you want to proceed with the translation?"
3561msgstr "Hay uso gratuito de DeepL disponible.\nCaracteres restantes en la cuota gratuita: %s\nCaracteres a traducir: %s\n\n¿Quieres continuar con la traduccion?"
3562
3563msgctxt "#90667"
3564msgid "Free DeepL Usage"
3565msgstr "Uso gratuito de DeepL"
3566
3567msgctxt "#90668"
3568msgid "Total characters to translate: %s\nEstimated cost: $%.2f USD\n\nDo you want to proceed with the translation?"
3569msgstr "Caracteres totales a traducir: %s\nCoste estimado: $%.2f USD\n\n¿Quieres continuar con la traduccion?"
3570
3571msgctxt "#90669"
3572msgid "Translation Cost"
3573msgstr "Coste de traduccion"
3574
3575msgctxt "#90672"
3576msgid "Play Trailer"
3577msgstr "Reproducir trailer"
3578
3579msgctxt "#90673"
3580msgid "Trailer unavailable"
3581msgstr "Trailer no disponible"
3582
3583msgctxt "#90690"
3584msgid "Clear All Items"
3585msgstr "Eliminar todos los elementos"
3586
3587msgctxt "#90691"
3588msgid "Remove all items from this library?"
3589msgstr "¿Eliminar todos los elementos de esta biblioteca?"
3590
3591msgctxt "#90692"
3592msgid "Library cleared"
3593msgstr "Biblioteca vaciada"
3594
3595msgctxt "#90693"
3596msgid "No items to remove"
3597msgstr "No hay elementos para eliminar"
3598
3599msgctxt "#90694"
3600msgid "Added to %s cloud but still downloading. Please wait before playing."
3601msgstr "Agregado a la nube de %s pero aún descargando. Por favor espere antes de reproducir."
3602
3603msgctxt "#90695"
3604msgid "Torrent Not Ready"
3605msgstr "Torrent No Listo"
3606
3607msgctxt "#90696"
3608msgid "This torrent is still downloading in %s and is not ready yet."
3609msgstr "Este torrent aún se está descargando en %s y no está listo todavía."
3610
3611msgctxt "#90697"
3612msgid "Would you like to play using torrent client (may be slower) or cancel and wait for debrid to finish?"
3613msgstr "¿Desea reproducir usando el cliente torrent (puede ser más lento) o cancelar y esperar a que el debrid termine?"
3614
3615msgctxt "#90698"
3616msgid "Play with Torrent"
3617msgstr "Reproducir con Torrent"
3618
3619msgctxt "#90699"
3620msgid "Cancel & Wait"
3621msgstr "Cancelar y Esperar"
3622
3623msgctxt "#90730"
3624msgid "Search with title + year"
3625msgstr "Buscar con título + año"
3626
3627msgctxt "#90731"
3628msgid "Search with original title"
3629msgstr "Buscar con título original"
3630
3631msgctxt "#90732"
3632msgid "Search with original title + year"
3633msgstr "Buscar con título original + año"
3634
3635msgctxt "#90733"
3636msgid "Search modes"
3637msgstr "Modos de búsqueda"
3638
3639msgctxt "#90734"
3640msgid "Enter year"
3641msgstr "Introduce el año"
3642
3643msgctxt "#90735"
3644msgid "Invalid year"
3645msgstr "Año no válido"
3646
3647msgctxt "#90736"
3648msgid "Edit title"
3649msgstr "Editar título"
3650
3651msgctxt "#90512"
3652msgid "Season"
3653msgstr "Temporada"
3654
3655msgctxt "#90737"
3656msgid "Invalid season"
3657msgstr "Temporada no válida"
3658
3659msgctxt "#90738"
3660msgid "Episode"
3661msgstr "Episodio"
3662
3663msgctxt "#90739"
3664msgid "Invalid episode"
3665msgstr "Episodio no válido"
3666
3667msgctxt "#90740"
3668msgid "Edit current title"
3669msgstr "Editar título actual"
3670
3671msgctxt "#90741"
3672msgid "Use original title"
3673msgstr "Usar título original"
3674
3675msgctxt "#90742"
3676msgid "Addon Settings"
3677msgstr "Ajustes del addon"
3678
3679msgctxt "#90743"
3680msgid "Set Views"
3681msgstr "Configurar vistas"
3682
3683msgctxt "#90744"
3684msgid "Upload Subtitle"
3685msgstr "Subir subtítulo"
3686
3687msgctxt "#90745"
3688msgid "Select Subtitle File"
3689msgstr "Seleccionar archivo de subtítulo"
3690
3691msgctxt "#90746"
3692msgid "Local File"
3693msgstr "Archivo local"
3694
3695msgctxt "#90747"
3696msgid "Upload from Browser"
3697msgstr "Subir desde el navegador"
3698
3699msgctxt "#90748"
3700msgid "Upload subtitle from your browser"
3701msgstr "Subir subtítulo desde tu navegador"
3702
3703msgctxt "#90749"
3704msgid "Scan the QR code or open the URL (%s) in your browser to upload a subtitle file"
3705msgstr "Escanea el código QR o abre la URL (%s) en tu navegador para subir un archivo de subtítulo"
3706
3707msgctxt "#90750"
3708msgid "Search Title Language"
3709msgstr "Idioma del título de búsqueda"
3710
3711msgctxt "#90751"
3712msgid "Controls how Easynews/Jackett/Prowlarr title queries are prioritized for context searches."
3713msgstr "Controla cómo se priorizan las búsquedas por título en Easynews/Jackett/Prowlarr en búsquedas de contexto."
3714
3715msgctxt "#90752"
3716msgid "English first"
3717msgstr "Inglés primero"
3718
3719msgctxt "#90753"
3720msgid "English only"
3721msgstr "Solo inglés"
3722
3723msgctxt "#90754"
3724msgid "Localized first"
3725msgstr "Localizado primero"
plugin.video.jacktook/resources/screenshots/extras.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/screenshots/home.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/screenshots/settings.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/screenshots/tv.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/screenshots/webserver.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/settings.xml +1794 -0
……@@ -0,0 +1,1794 @@
1<?xml version='1.0' encoding='utf-8'?>
2<settings version="1">
3 <section id="plugin.video.jacktook">
4 <category id="general_category" label="30717">
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">
21 <setting id="search_dialog_style" type="string" label="90238" help="">
22 <default>0</default>
23 <constraints>
24 <options>
25 <option label="90239">0</option>
26 <option label="90240">1</option>
27 </options>
28 </constraints>
29 <control type="spinner" format="string" />
30 </setting>
31 </group>
32 <group id="update" label="30724">
33 <setting id="addon_update" type="action" label="30724" help="30839">
34 <data>RunPlugin(plugin://plugin.video.jacktook/?action=addon_update)</data>
35 <control type="button" format="action">
36 <close>false</close>
37 </control>
38 </setting>
39 <setting id="update_action" type="string" label="30932" help="30933">
40 <default>0</default>
41 <constraints>
42 <options>
43 <option label="30934">0</option>
44 <option label="30935">1</option>
45 <option label="30732">2</option>
46 </options>
47 </constraints>
48 <control type="spinner" format="string" />
49 </setting>
50 <setting id="downgrade_addon" type="action" label="90215" help="90671">
51 <data>RunPlugin(plugin://plugin.video.jacktook/?action=downgrade_addon)</data>
52 <control type="button" format="action">
53 <close>false</close>
54 </control>
55 </setting>
56 <setting id="show_changelog" type="action" label="30894" help="30895">
57 <data>RunPlugin(plugin://plugin.video.jacktook/?action=show_changelog)</data>
58 <control type="button" format="action">
59 <close>false</close>
60 </control>
61 </setting>
62 <setting id="donate" type="action" label="90018" help="">
63 <data>RunPlugin(plugin://plugin.video.jacktook/?action=donate)</data>
64 <control type="button" format="action">
65 <close>false</close>
66 </control>
67 </setting>
68 </group>
69 <group id="cache" label="30723">
70 <setting id="cache_enabled" type="boolean" label="30721" help="30809">
71 <default>true</default>
72 <control type="toggle" />
73 </setting>
74 <setting id="cache_expiration" type="integer" label="30722" help="30813">
75 <default>24</default>
76 <constraints>
77 <minimum>1</minimum>
78 <step>1</step>
79 <maximum>30</maximum>
80 </constraints>
81 <control type="slider" format="integer">
82 <popup>false</popup>
83 </control>
84 <dependencies>
85 <dependency type="visible">
86 <condition operator="is" setting="cache_enabled">true</condition>
87 </dependency>
88 </dependencies>
89 </setting>
90 <setting id="clear_cache_update" type="boolean" label="30806" help="30846">
91 <default>true</default>
92 <control type="toggle" />
93 <dependencies>
94 <dependency type="visible">
95 <condition operator="is" setting="cache_enabled">true</condition>
96 </dependency>
97 </dependencies>
98 </setting>
99 </group>
100 </category>
101 <category id="services_category" label="30150">
102 <group id="torrents" label="30862">
103 <setting id="torrent_enable" type="boolean" label="30713" help="30836">
104 <default>false</default>
105 <control type="toggle" />
106 </setting>
107 <setting id="torrent_client" type="string" label="30002" help="30863">
108 <default>Jacktorr</default>
109 <constraints>
110 <options>
111 <option label="30714">Jacktorr</option>
112 <option label="30715">Torrest</option>
113 <option label="30716">Elementum</option>
114 </options>
115 </constraints>
116 <dependencies>
117 <dependency type="visible">
118 <condition operator="is" setting="torrent_enable">true</condition>
119 </dependency>
120 </dependencies>
121 <control type="spinner" format="string" />
122 </setting>
123 </group>
124 <group id="real_debrid" label="30152">
125 <setting id="real_debrid_enabled" type="boolean" label="30734" help="30837">
126 <default>false</default>
127 <control type="toggle" />
128 </setting>
129 <setting id="real_debid_authorized" type="boolean" label="" help="">
130 <visible>false</visible>
131 <default>false</default>
132 <control type="toggle" />
133 <dependencies>
134 <dependency type="visible">
135 <condition operator="is" setting="real_debrid_enabled">true</condition>
136 </dependency>
137 </dependencies>
138 </setting>
139 <setting id="real_debrid_user" type="string" label="30782" help="30817">
140 <default />
141 <constraints>
142 <allowempty>true</allowempty>
143 </constraints>
144 <control type="edit" format="string" />
145 <dependencies>
146 <dependency type="visible">
147 <condition operator="is" setting="real_debrid_enabled">true</condition>
148 </dependency>
149 </dependencies>
150 </setting>
151 <setting id="real_debrid_token" type="string" label="30787" help="30848">
152 <default />
153 <visible>false</visible>
154 <constraints>
155 <allowempty>true</allowempty>
156 </constraints>
157 <control type="edit" format="string" />
158 <dependencies>
159 <dependency type="visible">
160 <condition operator="is" setting="real_debrid_enabled">true</condition>
161 </dependency>
162 </dependencies>
163 </setting>
164 <setting id="real_debrid_auth" type="action" label="30783" help="30835">
165 <data>RunPlugin(plugin://plugin.video.jacktook/?action=rd_auth)</data>
166 <control type="button" format="action">
167 <close>false</close>
168 </control>
169 <dependencies>
170 <dependency type="visible">
171 <condition operator="is" setting="real_debrid_enabled">true</condition>
172 </dependency>
173 </dependencies>
174 </setting>
175 <setting id="real_debrid_remove_auth" type="action" label="30784" help="30853">
176 <data>RunPlugin(plugin://plugin.video.jacktook/?action=rd_remove_auth)</data>
177 <control type="button" format="action">
178 <close>false</close>
179 </control>
180 <dependencies>
181 <dependency type="visible">
182 <condition operator="is" setting="real_debrid_enabled">true</condition>
183 </dependency>
184 </dependencies>
185 </setting>
186 </group>
187 <group id="alldebrid" label="90121">
188 <setting id="alldebrid_enabled" type="boolean" label="30734" help="30824">
189 <default>false</default>
190 <control type="toggle" />
191 </setting>
192 <setting id="alldebrid_authorized" type="boolean" label="" help="">
193 <visible>false</visible>
194 <default>false</default>
195 <control type="toggle" />
196 <dependencies>
197 <dependency type="visible">
198 <condition operator="is" setting="alldebrid_enabled">true</condition>
199 </dependency>
200 </dependencies>
201 </setting>
202 <setting id="alldebrid_user" type="string" label="30782" help="30817">
203 <default />
204 <constraints>
205 <allowempty>true</allowempty>
206 </constraints>
207 <control type="edit" format="string" />
208 <dependencies>
209 <dependency type="visible">
210 <condition operator="is" setting="alldebrid_enabled">true</condition>
211 </dependency>
212 </dependencies>
213 </setting>
214 <setting id="alldebrid_token" type="string" label="30787" help="30814">
215 <default />
216 <visible>false</visible>
217 <constraints>
218 <allowempty>true</allowempty>
219 </constraints>
220 <control type="edit" format="string" />
221 <dependencies>
222 <dependency type="visible">
223 <condition operator="is" setting="alldebrid_enabled">true</condition>
224 </dependency>
225 </dependencies>
226 </setting>
227 <setting id="alldebrid_auth" type="action" label="30783" help="30845">
228 <data>RunPlugin(plugin://plugin.video.jacktook/?action=ad_auth)</data>
229 <control type="button" format="action">
230 <close>false</close>
231 </control>
232 <dependencies>
233 <dependency type="visible">
234 <condition operator="is" setting="alldebrid_enabled">true</condition>
235 </dependency>
236 </dependencies>
237 </setting>
238 <setting id="alldebrid_remove_auth" type="action" label="30784" help="30853">
239 <data>RunPlugin(plugin://plugin.video.jacktook/?action=ad_remove_auth)</data>
240 <control type="button" format="action">
241 <close>false</close>
242 </control>
243 <dependencies>
244 <dependency type="visible">
245 <condition operator="is" setting="alldebrid_enabled">true</condition>
246 </dependency>
247 </dependencies>
248 </setting>
249 </group>
250 <!-- <group id="debrider" label="30789">
251 <setting id="debrider_enabled" type="boolean" label="30734" help="">
252 <default>false</default>
253 <control type="toggle" />
254 </setting>
255 <setting id="debrider_token" type="string" label="30787" help="">
256 <default />
257 <visible>false</visible>
258 <constraints>
259 <allowempty>true</allowempty>
260 </constraints>
261 <control type="edit" format="string" />
262 <dependencies>
263 <dependency type="visible">
264 <condition operator="is" setting="debrider_enabled">true</condition>
265 </dependency>
266 </dependencies>
267 </setting>
268 <setting id="debrider_auth" type="action" label="30783" help="">
269 <data>RunPlugin(plugin://plugin.video.jacktook/?action=debrider_auth)</data>
270 <control type="button" format="action">
271 <close>false</close>
272 </control>
273 <dependencies>
274 <dependency type="visible">
275 <condition operator="is" setting="debrider_enabled">true</condition>
276 </dependency>
277 </dependencies>
278 </setting>
279 <setting id="debrider_remove_auth" type="action" label="30784" help="">
280 <data>RunPlugin(plugin://plugin.video.jacktook/?action=debrider_remove_auth)</data>
281 <control type="button" format="action">
282 <close>false</close>
283 </control>
284 <dependencies>
285 <dependency type="visible">
286 <condition operator="is" setting="debrider_enabled">true</condition>
287 </dependency>
288 </dependencies>
289 </setting>
290 </group> -->
291 <group id="torbox" label="30788">
292 <setting id="torbox_enabled" type="boolean" label="30734" help="30843">
293 <default>false</default>
294 <control type="toggle" />
295 </setting>
296 <setting id="torbox_user" type="string" label="30782" help="30911">
297 <default />
298 <constraints>
299 <allowempty>true</allowempty>
300 </constraints>
301 <control type="edit" format="string" />
302 <dependencies>
303 <dependency type="visible">
304 <condition operator="is" setting="torbox_enabled">true</condition>
305 </dependency>
306 </dependencies>
307 </setting>
308 <setting id="torbox_token" type="string" label="30787" help="30832">
309 <default />
310 <visible>false</visible>
311 <constraints>
312 <allowempty>true</allowempty>
313 </constraints>
314 <control type="edit" format="string" />
315 <dependencies>
316 <dependency type="visible">
317 <condition operator="is" setting="torbox_enabled">true</condition>
318 </dependency>
319 </dependencies>
320 </setting>
321 <setting id="tb_auth" type="action" label="30783" help="30912">
322 <data>RunPlugin(plugin://plugin.video.jacktook/?action=tb_auth)</data>
323 <control type="button" format="action">
324 <close>false</close>
325 </control>
326 <dependencies>
327 <dependency type="visible">
328 <condition operator="is" setting="torbox_enabled">true</condition>
329 </dependency>
330 </dependencies>
331 </setting>
332 <setting id="tb_remove_auth" type="action" label="30784" help="30913">
333 <data>RunPlugin(plugin://plugin.video.jacktook/?action=tb_remove_auth)</data>
334 <control type="button" format="action">
335 <close>false</close>
336 </control>
337 <dependencies>
338 <dependency type="visible">
339 <condition operator="is" setting="torbox_enabled">true</condition>
340 </dependency>
341 </dependencies>
342 </setting>
343 </group>
344 <group id="premiumize" label="30785">
345 <setting id="premiumize_enabled" type="boolean" label="30734" help="30824">
346 <default>false</default>
347 <control type="toggle" />
348 </setting>
349 <setting id="premiumize_token" type="string" label="30787" help="30814">
350 <default />
351 <visible>false</visible>
352 <constraints>
353 <allowempty>true</allowempty>
354 </constraints>
355 <control type="edit" format="string" />
356 <dependencies>
357 <dependency type="visible">
358 <condition operator="is" setting="premiumize_enabled">true</condition>
359 </dependency>
360 </dependencies>
361 </setting>
362 <setting id="premiumize_authorized" type="boolean" label="">
363 <default>false</default>
364 <visible>false</visible>
365 <control type="toggle" />
366 </setting>
367 <setting id="premiumize_user" type="string" label="30782" help="30817">
368 <default />
369 <constraints>
370 <allowempty>true</allowempty>
371 </constraints>
372 <control type="edit" format="string" />
373 <dependencies>
374 <dependency type="visible">
375 <condition operator="is" setting="premiumize_enabled">true</condition>
376 </dependency>
377 </dependencies>
378 </setting>
379 <setting id="premiumize_auth" type="action" label="30783" help="30845">
380 <data>RunPlugin(plugin://plugin.video.jacktook/?action=pm_auth)</data>
381 <control type="button" format="action">
382 <close>false</close>
383 </control>
384 <dependencies>
385 <dependency type="visible">
386 <condition operator="is" setting="premiumize_enabled">true</condition>
387 </dependency>
388 </dependencies>
389 </setting>
390 <setting id="premiumize_remove_auth" type="action" label="30784" help="30853">
391 <data>RunPlugin(plugin://plugin.video.jacktook/?action=pm_remove_auth)</data>
392 <control type="button" format="action">
393 <close>false</close>
394 </control>
395 <dependencies>
396 <dependency type="visible">
397 <condition operator="is" setting="premiumize_enabled">true</condition>
398 </dependency>
399 </dependencies>
400 </setting>
401 </group>
402 <group id="easynews" label="90232">
403 <setting id="easynews_enabled" type="boolean" label="30734" help="90233">
404 <default>false</default>
405 <control type="toggle" />
406 </setting>
407 <setting id="easynews_user" type="string" label="90127" help="90234">
408 <default />
409 <constraints>
410 <allowempty>true</allowempty>
411 </constraints>
412 <control type="edit" format="string" />
413 <dependencies>
414 <dependency type="visible">
415 <condition operator="is" setting="easynews_enabled">true</condition>
416 </dependency>
417 </dependencies>
418 </setting>
419 <setting id="easynews_password" type="string" label="90128" help="90235">
420 <default />
421 <constraints>
422 <allowempty>true</allowempty>
423 </constraints>
424 <control type="edit" format="string" />
425 <dependencies>
426 <dependency type="visible">
427 <condition operator="is" setting="easynews_enabled">true</condition>
428 </dependency>
429 </dependencies>
430 </setting>
431 <setting id="easynews_timeout" type="integer" label="30031" help="90236">
432 <default>25</default>
433 <constraints>
434 <minimum>1</minimum>
435 <maximum>60</maximum>
436 <step>1</step>
437 </constraints>
438 <control type="slider" format="integer">
439 <popup>false</popup>
440 </control>
441 <dependencies>
442 <dependency type="visible">
443 <condition operator="is" setting="easynews_enabled">true</condition>
444 </dependency>
445 </dependencies>
446 </setting>
447 <setting id="easynews_info" type="action" label="90237" help="">
448 <data>RunPlugin(plugin://plugin.video.jacktook/?action=easynews_info)</data>
449 <control type="button" format="action">
450 <close>false</close>
451 </control>
452 <dependencies>
453 <dependency type="visible">
454 <condition operator="is" setting="easynews_enabled">true</condition>
455 </dependency>
456 </dependencies>
457 </setting>
458 </group>
459 <group id="debrid_cleanup" label="30106">
460 <setting id="clear_debrid_cache" type="action" label="90344">
461 <data>RunPlugin(plugin://plugin.video.jacktook/?action=clear_debrid_cache)</data>
462 <control type="button" format="action">
463 <close>true</close>
464 </control>
465 <dependencies>
466 <dependency type="visible">
467 <condition operator="is" setting="cache_enabled">true</condition>
468 </dependency>
469 </dependencies>
470 </setting>
471 </group>
472 <group id="uncached" label="30790">
473 <setting id="show_uncached" type="boolean" label="30791" help="30803">
474 <default>true</default>
475 <control type="toggle" />
476 </setting>
477 </group>
478 <group id="service_notifications" label="90225">
479 <setting id="debrid_expiration_enabled" type="boolean" label="90226" help="90227">
480 <default>true</default>
481 <control type="toggle" />
482 </setting>
483 <setting id="debrid_expiration_threshold" type="integer" label="90228" help="90229">
484 <default>3</default>
485 <constraints>
486 <minimum>1</minimum>
487 <maximum>30</maximum>
488 <step>1</step>
489 </constraints>
490 <control type="slider" format="integer">
491 <popup>false</popup>
492 </control>
493 <dependencies>
494 <dependency type="visible">
495 <condition operator="is" setting="debrid_expiration_enabled">true</condition>
496 </dependency>
497 </dependencies>
498 </setting>
499 </group>
500 </category>
501 <category id="sources_category" label="30864">
502 <group id="stremio_general" label="90150">
503 <setting id="stremio_enabled" type="boolean" label="30734" help="30869">
504 <default>false</default>
505 <control type="toggle" />
506 </setting>
507 <setting id="stremio_loggedin" type="string">
508 <default>false</default>
509 <control type="edit" format="string" />
510 <visible>false</visible>
511 </setting>
512 <setting id="stremio_email" type="string">
513 <constraints>
514 <allowempty>true</allowempty>
515 </constraints>
516 <control type="edit" format="string" />
517 <visible>false</visible>
518 </setting>
519 <setting id="stremio_pass" type="string">
520 <constraints>
521 <allowempty>true</allowempty>
522 </constraints>
523 <control type="edit" format="string" />
524 <visible>false</visible>
525 </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 <setting id="stremio_login" type="action" label="30871" help="30866">
577 <dependencies>
578 <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>
583 </dependency>
584 </dependencies>
585 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_login)</data>
586 <control type="button" format="action">
587 <close>false</close>
588 </control>
589 </setting>
590 <setting id="stremio_logout" type="action" label="30868" help="30867">
591 <dependencies>
592 <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>
597 </dependency>
598 </dependencies>
599 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_logout)</data>
600 <control type="button" format="action">
601 <close>false</close>
602 </control>
603 </setting>
604 <setting id="stremio_update" type="action" label="30874" help="90336">
605 <dependencies>
606 <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>
611 </dependency>
612 </dependencies>
613 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_update)</data>
614 <control type="button" format="action">
615 <close>false</close>
616 </control>
617 </setting>
618 <setting id="stremio_toggle_addons" type="action" label="30870" help="90337">
619 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_toggle_addons)</data>
620 <control type="button" format="action">
621 <close>false</close>
622 </control>
623 <dependencies>
624 <dependency type="visible">
625 <condition operator="is" setting="stremio_enabled">true</condition>
626 </dependency>
627 </dependencies>
628 </setting>
629 <setting id="stremio_toggle_catalogs" type="action" label="30875" help="90338">
630 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_toggle_catalogs)</data>
631 <control type="button" format="action">
632 <close>false</close>
633 </control>
634 <dependencies>
635 <dependency type="visible">
636 <condition operator="is" setting="stremio_enabled">true</condition>
637 </dependency>
638 </dependencies>
639 </setting>
640 <setting id="stremio_toggle_tv_addons" type="action" label="30903" help="90339">
641 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_toggle_tv_addons)</data>
642 <control type="button" format="action">
643 <close>false</close>
644 </control>
645 <dependencies>
646 <dependency type="visible">
647 <condition operator="is" setting="stremio_enabled">true</condition>
648 </dependency>
649 </dependencies>
650 </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>
653 <control type="button" format="action">
654 <close>false</close>
655 </control>
656 <dependencies>
657 <dependency type="visible">
658 <condition operator="is" setting="stremio_enabled">true</condition>
659 </dependency>
660 </dependencies>
661 </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>
664 <control type="button" format="action">
665 <close>false</close>
666 </control>
667 <dependencies>
668 <dependency type="visible">
669 <condition operator="is" setting="stremio_enabled">true</condition>
670 </dependency>
671 </dependencies>
672 </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>
675 <control type="button" format="action">
676 <close>false</close>
677 </control>
678 <dependencies>
679 <dependency type="visible">
680 <condition operator="is" setting="stremio_enabled">true</condition>
681 </dependency>
682 </dependencies>
683 </setting>
684 </group>
685 <group id="jackett" label="30775">
686 <setting id="jackett_enabled" type="boolean" label="30734" help="30849">
687 <default>false</default>
688 <control type="toggle" />
689 </setting>
690 <setting id="jackett_host" type="string" label="30052" help="30815">
691 <default/>
692 <constraints>
693 <allowempty>true</allowempty>
694 </constraints>
695 <control type="edit" format="string" />
696 <dependencies>
697 <dependency type="visible">
698 <condition operator="is" setting="jackett_enabled">true</condition>
699 </dependency>
700 </dependencies>
701 </setting>
702 <setting id="jackett_port" type="string" label="90118" help="90119">
703 <default>9117</default>
704 <constraints>
705 <allowempty>true</allowempty>
706 </constraints>
707 <control type="edit" format="string" />
708 <dependencies>
709 <dependency type="visible">
710 <condition operator="is" setting="jackett_enabled">true</condition>
711 </dependency>
712 </dependencies>
713 </setting>
714 <setting id="jackett_apikey" type="string" label="30051" help="30821">
715 <default/>
716 <constraints>
717 <allowempty>true</allowempty>
718 </constraints>
719 <control type="edit" format="string" />
720 <dependencies>
721 <dependency type="visible">
722 <condition operator="is" setting="jackett_enabled">true</condition>
723 </dependency>
724 </dependencies>
725 </setting>
726 <setting id="jackett_timeout" type="integer" label="30031" help="30810">
727 <default>30</default>
728 <constraints>
729 <minimum>1</minimum>
730 <maximum>300</maximum>
731 <step>1</step>
732 </constraints>
733 <control type="slider" format="integer">
734 <popup>false</popup>
735 </control>
736 <dependencies>
737 <dependency type="visible">
738 <condition operator="is" setting="jackett_enabled">true</condition>
739 </dependency>
740 </dependencies>
741 </setting>
742 </group>
743 <group id="prowlarr" label="30776">
744 <setting id="prowlarr_enabled" type="boolean" label="30734" help="30826">
745 <default>false</default>
746 <control type="toggle" />
747 </setting>
748 <setting id="prowlarr_host" type="string" label="30052" help="30851">
749 <default />
750 <constraints>
751 <allowempty>true</allowempty>
752 </constraints>
753 <control type="edit" format="string"/>
754 <dependencies>
755 <dependency type="visible">
756 <condition operator="is" setting="prowlarr_enabled">true</condition>
757 </dependency>
758 </dependencies>
759 </setting>
760 <setting id="prowlarr_port" type="string" label="90118" help="90120">
761 <default>9696</default>
762 <constraints>
763 <allowempty>true</allowempty>
764 </constraints>
765 <control type="edit" format="string"/>
766 <dependencies>
767 <dependency type="visible">
768 <condition operator="is" setting="prowlarr_enabled">true</condition>
769 </dependency>
770 </dependencies>
771 </setting>
772 <setting id="prowlarr_apikey" type="string" label="30051" help="30854">
773 <default />
774 <constraints>
775 <allowempty>true</allowempty>
776 </constraints>
777 <control type="edit" format="string" />
778 <dependencies>
779 <dependency type="visible">
780 <condition operator="is" setting="prowlarr_enabled">true</condition>
781 </dependency>
782 </dependencies>
783 </setting>
784 <setting id="prowlarr_indexer_ids" type="string" label="30053" help="30840">
785 <default />
786 <constraints>
787 <allowempty>true</allowempty>
788 </constraints>
789 <control type="edit" format="string" />
790 <dependencies>
791 <dependency type="visible">
792 <condition operator="is" setting="prowlarr_enabled">true</condition>
793 </dependency>
794 </dependencies>
795 </setting>
796 <setting id="prowlarr_timeout" type="integer" label="30031" help="30841">
797 <default>25</default>
798 <constraints>
799 <minimum>1</minimum>
800 <maximum>30</maximum>
801 <step>1</step>
802 </constraints>
803 <control type="slider" format="integer">
804 <popup>false</popup>
805 </control>
806 <dependencies>
807 <dependency type="visible">
808 <condition operator="is" setting="prowlarr_enabled">true</condition>
809 </dependency>
810 </dependencies>
811 </setting>
812 </group>
813 <group id="jacktookburst" label="30780">
814 <setting id="jacktookburst_enabled" type="boolean" label="30734" help="30859">
815 <default>false</default>
816 <control type="toggle" />
817 </setting>
818 <setting id="burst_config" type="action" label="30781" help="30823">
819 <data>RunPlugin(plugin://plugin.video.jacktook/?action=open_burst_config)</data>
820 <control type="button" format="action">
821 <close>false</close>
822 </control>
823 <dependencies>
824 <dependency type="visible">
825 <condition operator="is" setting="jacktookburst_enabled">true</condition>
826 </dependency>
827 </dependencies>
828 </setting>
829 </group>
830 <group id="jackgram" label="30767">
831 <setting id="jackgram_enabled" type="boolean" label="30734" help="30850">
832 <default>false</default>
833 <control type="toggle"/>
834 </setting>
835 <setting id="jackgram_host" type="string" label="30768" help="30827">
836 <default/>
837 <constraints>
838 <allowempty>true</allowempty>
839 </constraints>
840 <control type="edit" format="string" />
841 <dependencies>
842 <dependency type="visible">
843 <condition operator="is" setting="jackgram_enabled">true</condition>
844 </dependency>
845 </dependencies>
846 </setting>
847 <setting id="jackgram_token" type="string" label="30787" help="90249">
848 <default/>
849 <constraints>
850 <allowempty>true</allowempty>
851 </constraints>
852 <control type="edit" format="string" />
853 <dependencies>
854 <dependency type="visible">
855 <condition operator="is" setting="jackgram_enabled">true</condition>
856 </dependency>
857 </dependencies>
858 </setting>
859 </group>
860 </category>
861 <category id="playback_category" label="90139">
862 <group id="playback_group" label="90139">
863 <setting id="auto_play" type="boolean" label="30718" help="30838">
864 <default>false</default>
865 <control type="toggle" />
866 </setting>
867 <setting id="auto_play_quality" type="string" label="30879" help="">
868 <default>1080p</default>
869 <dependencies>
870 <dependency type="visible">
871 <condition operator="is" setting="auto_play">true</condition>
872 </dependency>
873 </dependencies>
874 <constraints>
875 <options>
876 <option label="30882">4K</option>
877 <option label="30880">1080p</option>
878 <option label="30881">720p</option>
879 <option label="30883">480p</option>
880 </options>
881 </constraints>
882 <control type="spinner" format="string" />
883 </setting>
884 <setting id="super_quick_play" type="boolean" label="90140" help="90141">
885 <default>false</default>
886 <control type="toggle" />
887 </setting>
888 <setting id="silent_resume" type="boolean" label="30938" help="30939">
889 <default>false</default>
890 <control type="toggle" />
891 <dependencies>
892 <dependency type="visible">
893 <condition operator="is" setting="super_quick_play">true</condition>
894 </dependency>
895 </dependencies>
896 </setting>
897 <setting id="precaching_enabled" type="boolean" label="90145" help="90146">
898 <default>false</default>
899 <control type="toggle" />
900 </setting>
901 <setting id="precaching_episode_count" type="integer" label="90147" help="90148">
902 <default>1</default>
903 <constraints>
904 <minimum>1</minimum>
905 <maximum>5</maximum>
906 <step>1</step>
907 </constraints>
908 <control type="slider" format="integer">
909 <popup>false</popup>
910 </control>
911 <dependencies>
912 <dependency type="visible">
913 <condition operator="is" setting="precaching_enabled">true</condition>
914 </dependency>
915 </dependencies>
916 </setting> <setting id="playnext_dialog_enabled" type="boolean" label="30719" help="30834">
917 <default>true</default>
918 <control type="toggle" />
919 </setting>
920 <setting id="playnext_time" type="integer" label="30720" help="30829">
921 <default>50</default>
922 <constraints>
923 <minimum>5</minimum>
924 <maximum>180</maximum>
925 <step>1</step>
926 </constraints>
927 <control type="slider" format="integer">
928 <popup>false</popup>
929 </control>
930 </setting>
931 <setting id="volume_check_enabled" type="boolean" label="90216" help="90217">
932 <default>false</default>
933 <control type="toggle" />
934 </setting>
935 <setting id="volume_check_threshold" type="integer" label="90218" help="90219">
936 <default>50</default>
937 <constraints>
938 <minimum>1</minimum>
939 <maximum>100</maximum>
940 <step>1</step>
941 </constraints>
942 <control type="slider" format="integer">
943 <popup>false</popup>
944 </control>
945 <dependencies>
946 <dependency type="visible">
947 <condition operator="is" setting="volume_check_enabled">true</condition>
948 </dependency>
949 </dependencies>
950 </setting>
951 <setting id="auto_audio" type="boolean" label="90003" help="">
952 <default>false</default>
953 <control type="toggle" />
954 </setting>
955 <setting id="auto_audio_language" type="string" label="90004" help="90005">
956 <default>None</default>
957 <heading>90004</heading>
958 <dependencies>
959 <dependency type="visible">
960 <condition operator="is" setting="auto_audio">true</condition>
961 </dependency>
962 </dependencies>
963 <constraints>
964 <options>
965 <option label="None">None</option>
966 <option label="English">English</option>
967 <option label="Spanish">Spanish</option>
968 <option label="Portuguese">Portuguese</option>
969 <option label="German">German</option>
970 <option label="French">French</option>
971 <option label="Italian">Italian</option>
972 <option label="Dutch">Dutch</option>
973 <option label="Polish">Polish</option>
974 <option label="Russian">Russian</option>
975 <option label="Turkish">Turkish</option>
976 <option label="Greek">Greek</option>
977 <option label="Hindi">Hindi</option>
978 <option label="Indonesian">Indonesia</option>
979 <option label="Japanese">Japanese</option>
980 <option label="Korean">Korean</option>
981 <option label="Ukrainian">Ukrainian</option>
982 <option label="Chinese">Chinese</option>
983 </options>
984 </constraints>
985 <control type="list" format="string" />
986 </setting>
987 </group>
988 <group id="skip_intro" label="90164">
989 <setting id="skip_intro_enabled" type="boolean" label="90165" help="90166">
990 <default>false</default>
991 <control type="toggle" />
992 </setting>
993 <setting id="skip_intro_auto" type="boolean" label="90167" help="90168">
994 <default>false</default>
995 <control type="toggle" />
996 <dependencies>
997 <dependency type="visible">
998 <condition operator="is" setting="skip_intro_enabled">true</condition>
999 </dependency>
1000 </dependencies>
1001 </setting>
1002 </group>
1003 </category>
1004 <category id="filtering_category" label="30861">
1005 <group id="filtering_stremio" label="90155">
1006 <setting id="stremio_bypass_addons" type="boolean" label="90211" help="90212">
1007 <default>false</default>
1008 <control type="toggle" />
1009 <dependencies>
1010 <dependency type="visible">
1011 <condition operator="is" setting="stremio_enabled">true</condition>
1012 </dependency>
1013 </dependencies>
1014 </setting>
1015 <setting id="stremio_bypass_addons_select" type="action" label="90213" help="90214">
1016 <data>RunPlugin(plugin://plugin.video.jacktook/?action=stremio_bypass_addons_select)</data>
1017 <control type="button" format="action">
1018 <close>false</close>
1019 </control>
1020 <dependencies>
1021 <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>
1026 </dependency>
1027 </dependencies>
1028 </setting>
1029 <setting id="stremio_bypass_addon_list" type="string">
1030 <default>aiostreams</default>
1031 <control type="edit" format="string" />
1032 <visible>false</visible>
1033 </setting>
1034 </group>
1035 <group id="filtering_group" label="30865">
1036 <setting id="indexers_total_results" type="integer" label="30029" help="30852">
1037 <default>100</default>
1038 <constraints>
1039 <minimum>10</minimum>
1040 <maximum>300</maximum>
1041 <step>10</step>
1042 </constraints>
1043 <control type="slider" format="integer">
1044 <popup>false</popup>
1045 </control>
1046 </setting>
1047 <setting id="indexers_desc_length" type="integer" label="30026" help="30825">
1048 <default>0</default>
1049 <constraints>
1050 <minimum>0</minimum>
1051 <maximum>500</maximum>
1052 <step>10</step>
1053 </constraints>
1054 <control type="slider" format="integer">
1055 <popup>false</popup>
1056 </control>
1057 </setting>
1058 <setting id="indexers_sort_by" type="string" label="30726" help="30822">
1059 <default>Seeds</default>
1060 <constraints>
1061 <options>
1062 <option label="30728">Seeds</option>
1063 <option label="30729">Size</option>
1064 <option label="30727">Quality</option>
1065 <option label="30730">Cached</option>
1066 <option label="30731">Date</option>
1067 <option label="30732">None</option>
1068 </options>
1069 </constraints>
1070 <control type="spinner" format="string" />
1071 </setting>
1072 <setting id="search_title_language_mode" type="string" label="90750" help="90751">
1073 <default>localized_first</default>
1074 <constraints>
1075 <options>
1076 <option label="90754">localized_first</option>
1077 <option label="90752">english_first</option>
1078 <option label="90753">english_only</option>
1079 </options>
1080 </constraints>
1081 <control type="spinner" format="string" />
1082 </setting>
1083 <setting id="include_season_packs" type="boolean" label="30710" help="">
1084 <default>true</default>
1085 <control type="toggle" />
1086 </setting>
1087 <setting id="include_tvshow_specials" type="boolean" label="90085" help="">
1088 <default>false</default>
1089 <control type="toggle" />
1090 </setting>
1091 </group>
1092 <group id="quality_filter" label="90099">
1093 <setting id="bluray_hd_enabled" type="boolean" label="90100" help="90105">
1094 <default>true</default>
1095 <control type="toggle" />
1096 </setting>
1097 <setting id="web_hd_enabled" type="boolean" label="90101" help="90106">
1098 <default>true</default>
1099 <control type="toggle" />
1100 </setting>
1101 <setting id="dvd_tv_enabled" type="boolean" label="90102" help="90107">
1102 <default>true</default>
1103 <control type="toggle" />
1104 </setting>
1105 <setting id="cam_screener_enabled" type="boolean" label="90103" help="90108">
1106 <default>false</default>
1107 <control type="toggle" />
1108 </setting>
1109 <setting id="unknown_enabled" type="boolean" label="90104" help="90109">
1110 <default>true</default>
1111 <control type="toggle" />
1112 </setting>
1113 <setting id="unknown_quality_enabled" type="boolean" label="90366" help="90367">
1114 <default>true</default>
1115 <control type="toggle" />
1116 </setting>
1117 </group>
1118 <group id="size_filter" label="90058">
1119 <setting id="filter_size_enabled" type="boolean" label="90059" help="">
1120 <default>false</default>
1121 <control type="toggle" />
1122 </setting>
1123 <setting id="minimum_size" type="integer" label="90060" help="">
1124 <default>100</default>
1125 <constraints>
1126 <minimum>100</minimum>
1127 <maximum>100000</maximum>
1128 <step>10</step>
1129 </constraints>
1130 <control type="slider" format="integer">
1131 <popup>false</popup>
1132 </control>
1133 <dependencies>
1134 <dependency type="visible">
1135 <condition operator="is" setting="filter_size_enabled">true</condition>
1136 </dependency>
1137 </dependencies>
1138 </setting>
1139 <setting id="maximum_size" type="integer" label="90061" help="">
1140 <default>20000</default>
1141 <constraints>
1142 <minimum>100</minimum>
1143 <maximum>100000</maximum>
1144 <step>100</step>
1145 </constraints>
1146 <control type="slider" format="integer">
1147 <popup>false</popup>
1148 </control>
1149 <dependencies>
1150 <dependency type="visible">
1151 <condition operator="is" setting="filter_size_enabled">true</condition>
1152 </dependency>
1153 </dependencies>
1154 </setting>
1155 </group>
1156 </category>
1157 <category id="subtitle_category" label="30886">
1158 <group id="general_settings" label="30884">
1159 <setting id="auto_subtitle_selection" type="boolean" label="30878" help="">
1160 <default>false</default>
1161 <control type="toggle" />
1162 </setting>
1163 <setting id="auto_subtitle_download" type="boolean" label="90122" help="">
1164 <default>false</default>
1165 <control type="toggle" />
1166 </setting>
1167 </group>
1168 <group id="stremio_subtitle" label="30893">
1169 <setting id="stremio_subtitle_enabled" type="boolean" label="30734" help="">
1170 <default>false</default>
1171 <control type="toggle" />
1172 <dependencies>
1173 <dependency type="visible">
1174 <condition operator="is" setting="stremio_enabled">true</condition>
1175 </dependency>
1176 </dependencies>
1177 </setting>
1178 <setting id="stremio_sub_addon_host" type="string" label="30885" help="">
1179 <default>https://opensubtitles-v3.strem.io/</default>
1180 <constraints>
1181 <allowempty>true</allowempty>
1182 </constraints>
1183 <dependencies>
1184 <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>
1189 </dependency>
1190 </dependencies>
1191 <control type="edit" format="string" />
1192 </setting>
1193 <setting id="subtitle_language" type="string" label="30736" help="30873">
1194 <default>None</default>
1195 <heading>30736</heading>
1196 <constraints>
1197 <options>
1198 <option label="None">None</option>
1199 <option label="English">English</option>
1200 <option label="Spanish">Spanish</option>
1201 <option label="Portuguese">Portuguese</option>
1202 <option label="German">German</option>
1203 <option label="French">French</option>
1204 <option label="Italian">Italian</option>
1205 <option label="Dutch">Dutch</option>
1206 <option label="Polish">Polish</option>
1207 <option label="Russian">Russian</option>
1208 <option label="Turkish">Turkish</option>
1209 <option label="Greek">Greek</option>
1210 <option label="Hindi">Hindi</option>
1211 <option label="Indonesian">Indonesia</option>
1212 <option label="Japanese">Japanese</option>
1213 <option label="Korean">Korean</option>
1214 <option label="Ukrainian">Ukrainian</option>
1215 <option label="Chinese">Chinese</option>
1216 </options>
1217 </constraints>
1218 <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 </setting>
1225 </group>
1226 <group id="deepl_translator" label="30891">
1227 <setting id="deepl_enabled" type="boolean" label="30889" help="">
1228 <default>false</default>
1229 <control type="toggle" />
1230 </setting>
1231 <setting id="deepl_api_key" type="string" label="30890" help="">
1232 <default></default>
1233 <constraints>
1234 <allowempty>true</allowempty>
1235 </constraints>
1236 <dependencies>
1237 <dependency type="visible">
1238 <condition operator="is" setting="deepl_enabled">true</condition>
1239 </dependency>
1240 </dependencies>
1241 <control type="edit" format="string" />
1242 </setting>
1243 <setting id="deepl_target_language" type="string" label="30892" help="">
1244 <default>None</default>
1245 <heading>30736</heading>
1246 <constraints>
1247 <options>
1248 <option value="None">None</option>
1249 <option value="ar">العربية</option>
1250 <option value="bg">Български</option>
1251 <option value="zh">中文</option>
1252 <option value="zh-Hant">繁體中文</option>
1253 <option value="zh-Hans">简体中文</option>
1254 <option value="cs">Čeština</option>
1255 <option value="da">Dansk</option>
1256 <option value="nl">Nederlands</option>
1257 <option value="en">English</option>
1258 <option value="en-GB">English (British)</option>
1259 <option value="en-US">English (American)</option>
1260 <option value="et">Eesti</option>
1261 <option value="fi">Suomi</option>
1262 <option value="fr">Français</option>
1263 <option value="de">Deutsch</option>
1264 <option value="el">Ελληνικά</option>
1265 <option value="hu">Magyar</option>
1266 <option value="id">Indonesia</option>
1267 <option value="it">Italiano</option>
1268 <option value="ja">日本語</option>
1269 <option value="ko">한국어</option>
1270 <option value="lv">Latviešu</option>
1271 <option value="lt">Lietuvių</option>
1272 <option value="nb">Norsk (Bokmål)</option>
1273 <option value="pl">Polski</option>
1274 <option value="pt">Português</option>
1275 <option value="pt-BR">Português (Brasil)</option>
1276 <option value="ro">Română</option>
1277 <option value="ru">Русский</option>
1278 <option value="sk">Slovenčina</option>
1279 <option value="sl">Slovenščina</option>
1280 <option value="es">Español</option>
1281 <option value="sv">Svenska</option>
1282 <option value="tr">Türkçe</option>
1283 <option value="uk">Українська</option>
1284 </options>
1285 </constraints>
1286 <dependencies>
1287 <dependency type="visible">
1288 <condition operator="is" setting="deepl_enabled">true</condition>
1289 </dependency>
1290 </dependencies>
1291 <control type="list" format="string" />
1292 </setting>
1293 </group>
1294 </category>
1295 <category id="metadata_category" label="90073">
1296 <group id="fanart_tv" label="90222">
1297 <setting id="fanart_client_key" type="string" label="90223" help="90224">
1298 <default/>
1299 <constraints>
1300 <allowempty>true</allowempty>
1301 </constraints>
1302 <control type="edit" format="string"/>
1303 </setting>
1304 </group>
1305 <group id="tmdb" label="30102">
1306 <setting id="tmdb_api_key" type="string" label="30103" help="30858">
1307 <default/>
1308 <constraints>
1309 <allowempty>true</allowempty>
1310 </constraints>
1311 <control type="edit" format="string"/>
1312 </setting>
1313 <setting id="language" type="integer" label="42418" help="42419">
1314 <default>18</default>
1315 <constraints>
1316 <options>
1317 <option label="40000">0</option>
1318 <option label="40001">1</option>
1319 <option label="40002">2</option>
1320 <option label="40003">3</option>
1321 <option label="40004">4</option>
1322 <option label="40005">5</option>
1323 <option label="40006">6</option>
1324 <option label="40007">7</option>
1325 <option label="40008">8</option>
1326 <option label="40009">9</option>
1327 <option label="40010">10</option>
1328 <option label="40011">11</option>
1329 <option label="40012">12</option>
1330 <option label="40013">13</option>
1331 <option label="40014">14</option>
1332 <option label="40015">15</option>
1333 <option label="40016">16</option>
1334 <option label="40017">17</option>
1335 <option label="40018">18</option>
1336 <option label="40019">19</option>
1337 <option label="40020">20</option>
1338 <option label="40021">21</option>
1339 <option label="40022">22</option>
1340 <option label="40023">23</option>
1341 <option label="40024">24</option>
1342 <option label="40025">25</option>
1343 <option label="40026">26</option>
1344 <option label="40027">27</option>
1345 <option label="40028">28</option>
1346 <option label="40029">29</option>
1347 <option label="40030">30</option>
1348 <option label="40031">31</option>
1349 <option label="40030">32</option>
1350 <option label="40033">33</option>
1351 <option label="40034">34</option>
1352 <option label="40035">35</option>
1353 <option label="40036">36</option>
1354 <option label="40037">37</option>
1355 <option label="40038">38</option>
1356 <option label="40039">39</option>
1357 <option label="40040">40</option>
1358 <option label="40041">41</option>
1359 <option label="40042">42</option>
1360 <option label="40043">43</option>
1361 <option label="40044">44</option>
1362 <option label="40045">45</option>
1363 <option label="40046">46</option>
1364 <option label="40047">47</option>
1365 <option label="40048">48</option>
1366 <option label="40049">49</option>
1367 <option label="40050">50</option>
1368 <option label="40051">51</option>
1369 <option label="40052">52</option>
1370 <option label="40053">53</option>
1371 <option label="40054">54</option>
1372 <option label="40055">55</option>
1373 <option label="40056">56</option>
1374 <option label="40057">57</option>
1375 <option label="40058">58</option>
1376 <option label="40059">59</option>
1377 <option label="40060">60</option>
1378 <option label="40061">61</option>
1379 <option label="40062">62</option>
1380 <option label="40063">63</option>
1381 <option label="40064">64</option>
1382 <option label="40065">65</option>
1383 <option label="40066">66</option>
1384 <option label="40067">67</option>
1385 </options>
1386 </constraints>
1387 <control type="list" format="string">
1388 <heading>42418</heading>
1389 </control>
1390 </setting>
1391 <setting id="clear_tmdb_cache" type="action" label="30921">
1392 <data>RunPlugin(plugin://plugin.video.jacktook/?action=clear_tmdb_cache)</data>
1393 <control type="button" format="action">
1394 <close>true</close>
1395 </control>
1396 <dependencies>
1397 <dependency type="visible">
1398 <condition operator="is" setting="cache_enabled">true</condition>
1399 </dependency>
1400 </dependencies>
1401 </setting>
1402 </group>
1403 <group id="calendar" label="30896">
1404 <setting id="use_broadcast_adjustment" type="boolean" label="30897" help="30898">
1405 <default>false</default>
1406 <control type="toggle" />
1407 </setting>
1408 <setting id="broadcast_offset" type="integer" label="30899" help="30900">
1409 <default>0</default>
1410 <constraints>
1411 <minimum>-24</minimum>
1412 <maximum>24</maximum>
1413 <step>1</step>
1414 </constraints>
1415 <control type="slider" format="integer">
1416 <popup>false</popup>
1417 </control>
1418 <dependencies>
1419 <dependency type="visible">
1420 <condition operator="is" setting="use_broadcast_adjustment">true</condition>
1421 </dependency>
1422 </dependencies>
1423 </setting>
1424 </group>
1425 <group id="mdblist_group" label="90077">
1426 <setting id="mdblist_enabled" type="boolean" label="30786" help="">
1427 <default>false</default>
1428 <control type="toggle" />
1429 </setting>
1430 <setting id="mdblist_api_key" type="string" label="30023" help="">
1431 <default />
1432 <constraints>
1433 <allowempty>true</allowempty>
1434 </constraints>
1435 <control type="edit" format="string" />
1436 <dependencies>
1437 <dependency type="visible">
1438 <condition operator="is" setting="mdblist_enabled">true</condition>
1439 </dependency>
1440 </dependencies>
1441 </setting>
1442 <setting id="clear_mdblist_cache" type="action" label="90345">
1443 <data>RunPlugin(plugin://plugin.video.jacktook/?action=clear_mdblist_cache)</data>
1444 <control type="button" format="action">
1445 <close>true</close>
1446 </control>
1447 <dependencies>
1448 <dependency type="visible">
1449 <condition operator="is" setting="cache_enabled">true</condition>
1450 </dependency>
1451 </dependencies>
1452 </setting>
1453 </group>
1454 <group id="trakt" label="30794">
1455 <setting id="trakt_enabled" type="boolean" label="30786" help="">
1456 <default>false</default>
1457 <control type="toggle" />
1458 </setting>
1459 <setting id="trakt_auth" type="action" label="30783" help="30819">
1460 <data>RunPlugin(plugin://plugin.video.jacktook/?action=trakt_auth)</data>
1461 <control type="button" format="action">
1462 <close>false</close>
1463 </control>
1464 <dependencies>
1465 <dependency type="visible">
1466 <condition operator="is" setting="trakt_enabled">true</condition>
1467 </dependency>
1468 </dependencies>
1469 </setting>
1470 <setting id="is_trakt_auth" type="boolean" label="30783" help="">
1471 <visible>false</visible>
1472 <default>false</default>
1473 <control type="toggle" />
1474 <dependencies>
1475 <dependency type="visible">
1476 <condition operator="is" setting="trakt_enabled">true</condition>
1477 </dependency>
1478 </dependencies>
1479 </setting>
1480 <setting id="trakt_user" type="string" label="30782" help="30855">
1481 <default />
1482 <constraints>
1483 <allowempty>true</allowempty>
1484 </constraints>
1485 <control type="edit" format="string" />
1486 <dependencies>
1487 <dependency type="visible">
1488 <condition operator="is" setting="trakt_enabled">true</condition>
1489 </dependency>
1490 </dependencies>
1491 </setting>
1492 <setting id="trakt_client" type="string" label="30798" help="30818">
1493 <default>687c707dc7073be777a6dd6869ffd9034109d75dcda6599a7273f49a68266ff8</default>
1494 <visible>false</visible>
1495 <control type="edit" format="string" />
1496 </setting>
1497 <setting id="trakt_secret" type="string" label="30800" help="30811">
1498 <default>3191ae63a3cf60d767b1d6fc1e39b1dedc6f3346b826d62901f9f2c3c9925e26</default>
1499 <visible>false</visible>
1500 <control type="edit" format="string" />
1501 </setting>
1502 <setting id="trakt_logout" type="action" label="30802" help="30856">
1503 <data>RunPlugin(plugin://plugin.video.jacktook/?action=trakt_auth_revoke)</data>
1504 <control type="button" format="action">
1505 <close>false</close>
1506 </control>
1507 <dependencies>
1508 <dependency type="visible">
1509 <condition operator="is" setting="trakt_enabled">true</condition>
1510 </dependency>
1511 </dependencies>
1512 </setting>
1513 <setting id="trakt_calendar_previous_days" type="integer" label="30905" help="30906">
1514 <default>0</default>
1515 <constraints>
1516 <minimum>0</minimum>
1517 <maximum>30</maximum>
1518 <step>1</step>
1519 </constraints>
1520 <control type="slider" format="integer">
1521 <popup>false</popup>
1522 </control>
1523 <dependencies>
1524 <dependency type="visible">
1525 <condition operator="is" setting="trakt_enabled">true</condition>
1526 </dependency>
1527 </dependencies>
1528 </setting>
1529 <setting id="trakt_calendar_future_days" type="integer" label="30907" help="30908">
1530 <default>14</default>
1531 <constraints>
1532 <minimum>1</minimum>
1533 <maximum>30</maximum>
1534 <step>1</step>
1535 </constraints>
1536 <control type="slider" format="integer">
1537 <popup>false</popup>
1538 </control>
1539 <dependencies>
1540 <dependency type="visible">
1541 <condition operator="is" setting="trakt_enabled">true</condition>
1542 </dependency>
1543 </dependencies>
1544 </setting>
1545 <setting id="trakt_calendar_show_all" type="boolean" label="30909" help="30910">
1546 <default>false</default>
1547 <control type="toggle" />
1548 <dependencies>
1549 <dependency type="visible">
1550 <condition operator="is" setting="trakt_enabled">true</condition>
1551 </dependency>
1552 </dependencies>
1553 </setting>
1554 <setting id="trakt_scrobbling_enabled" type="boolean" label="30877" help="30936">
1555 <default>true</default>
1556 <control type="toggle" />
1557 <dependencies>
1558 <dependency type="visible">
1559 <condition operator="is" setting="trakt_enabled">true</condition>
1560 </dependency>
1561 </dependencies>
1562 </setting>
1563 <setting id="trakt_sync_interval" type="integer" label="30914" help="30915">
1564 <default>15</default>
1565 <constraints>
1566 <minimum>1</minimum>
1567 <maximum>120</maximum>
1568 <step>1</step>
1569 </constraints>
1570 <control type="slider" format="integer">
1571 <popup>false</popup>
1572 </control>
1573 <dependencies>
1574 <dependency type="visible">
1575 <condition operator="is" setting="trakt_enabled">true</condition>
1576 </dependency>
1577 </dependencies>
1578 </setting>
1579 <setting id="clear_trakt_cache" type="action" label="30920" help="30937">
1580 <data>RunPlugin(plugin://plugin.video.jacktook/?action=clear_trakt_cache)</data>
1581 <control type="button" format="action">
1582 <close>true</close>
1583 </control>
1584 <dependencies>
1585 <dependency type="visible">
1586 <condition operator="is" setting="cache_enabled">true</condition>
1587 </dependency>
1588 </dependencies>
1589 </setting>
1590 </group>
1591 </category>
1592 <category id="webdav" label="90131">
1593 <group id="webdav_provider" label="90124">
1594 <setting id="webdav_hostname" type="string" label="90126" help="90133">
1595 <default />
1596 <constraints>
1597 <allowempty>true</allowempty>
1598 </constraints>
1599 <control type="edit" format="string" />
1600 </setting>
1601 <setting id="webdav_username" type="string" label="90127" help="90134">
1602 <default />
1603 <constraints>
1604 <allowempty>true</allowempty>
1605 </constraints>
1606 <control type="edit" format="string" />
1607 </setting>
1608 <setting id="webdav_password" type="string" label="90128" help="90135">
1609 <default />
1610 <constraints>
1611 <allowempty>true</allowempty>
1612 </constraints>
1613 <control type="edit" format="string" />
1614 </setting>
1615 <setting id="webdav_port" type="string" label="90118" help="">
1616 <default>8080</default>
1617 <control type="edit" format="string"/>
1618 </setting>
1619 <setting id="webdav_remote_path" type="string" label="90132" help="90136">
1620 <default />
1621 <constraints>
1622 <allowempty>true</allowempty>
1623 </constraints>
1624 <control type="edit" format="string" />
1625 </setting>
1626 <setting id="webdav_provider_test" type="action" label="90129" help="90137">
1627 <data>RunPlugin(plugin://plugin.video.jacktook/?action=webdav_provider_test)</data>
1628 <control type="button" format="action">
1629 <close>false</close>
1630 </control>
1631 </setting>
1632 </group>
1633 </category>
1634 <category id="menus" label="30804">
1635 <group id="telegram" label="">
1636 <setting id="show_telegram_menu" type="boolean" label="30805" help="30816">
1637 <default>false</default>
1638 <control type="toggle" />
1639 </setting>
1640 </group>
1641 </category>
1642 <category id="backup_category" label="90258">
1643 <group id="backup" label="90258">
1644 <setting id="settings_backup_strip_sensitive" type="boolean" label="90263" help="90264">
1645 <default>true</default>
1646 <control type="toggle" />
1647 </setting>
1648 <setting id="export_settings_backup" type="action" label="90259" help="90260">
1649 <data>RunPlugin(plugin://plugin.video.jacktook/?action=export_settings_backup)</data>
1650 <control type="button" format="action">
1651 <close>false</close>
1652 </control>
1653 </setting>
1654 <setting id="restore_settings_backup" type="action" label="90261" help="90262">
1655 <data>RunPlugin(plugin://plugin.video.jacktook/?action=restore_settings_backup)</data>
1656 <control type="button" format="action">
1657 <close>true</close>
1658 </control>
1659 </setting>
1660 <setting id="reset_all_settings" type="action" label="90278" help="90284">
1661 <data>RunPlugin(plugin://plugin.video.jacktook/?action=reset_all_settings)</data>
1662 <control type="button" format="action">
1663 <close>true</close>
1664 </control>
1665 </setting>
1666 <setting id="factory_reset" type="action" label="90285" help="90291">
1667 <data>RunPlugin(plugin://plugin.video.jacktook/?action=factory_reset)</data>
1668 <control type="button" format="action">
1669 <close>true</close>
1670 </control>
1671 </setting>
1672 </group>
1673 </category>
1674 <category id="others_category" label="30860">
1675 <group id="others" label="90052">
1676 <setting id="thread_number" type="integer" label="90052" help="90053">
1677 <default>8</default>
1678 <constraints>
1679 <minimum>1</minimum>
1680 <maximum>30</maximum>
1681 <step>1</step>
1682 </constraints>
1683 <control type="slider" format="integer">
1684 <popup>false</popup>
1685 </control>
1686 </setting>
1687 </group>
1688 <group id="downloads" label="30702">
1689 <setting id="download_dir" type="path" label="30876" help="">
1690 <level>0</level>
1691 <default>special://profile/addon_data/plugin.video.jacktook/Downloads/</default>
1692 <constraints>
1693 <allowempty>true</allowempty>
1694 <writable>true</writable>
1695 </constraints>
1696 <control type="button" format="path"/>
1697 </setting>
1698 </group>
1699 <group id="cleanup" label="30106">
1700 <setting id="clear_database_cache" type="action" label="90342">
1701 <data>RunPlugin(plugin://plugin.video.jacktook/?action=clear_database_cache)</data>
1702 <control type="button" format="action">
1703 <close>true</close>
1704 </control>
1705 <dependencies>
1706 <dependency type="visible">
1707 <condition operator="is" setting="cache_enabled">true</condition>
1708 </dependency>
1709 </dependencies>
1710 </setting>
1711 </group>
1712 <group id="maintenance_logs" label="90057">
1713 <setting id="kodi_logs" type="action" label="90054" help="90056">
1714 <data>RunPlugin(plugin://plugin.video.jacktook/?action=kodi_logs&amp;log_file=kodi.log)</data>
1715 <control type="button" format="action">
1716 <close>true</close>
1717 </control>
1718 </setting>
1719 <setting id="kodi_logs_old" type="action" label="90055" help="90056">
1720 <data>RunPlugin(plugin://plugin.video.jacktook/?action=kodi_logs&amp;log_file=kodi.old.log)</data>
1721 <control type="button" format="action">
1722 <close>true</close>
1723 </control>
1724 </setting>
1725 </group>
1726 <group id="views_storage" label="">
1727 <setting id="saved_view_main" type="string" label="" help="">
1728 <visible>false</visible>
1729 <default />
1730 <constraints>
1731 <allowempty>true</allowempty>
1732 </constraints>
1733 <control type="edit" format="string" />
1734 </setting>
1735 <setting id="saved_view_movies" type="string" label="" help="">
1736 <visible>false</visible>
1737 <default />
1738 <constraints>
1739 <allowempty>true</allowempty>
1740 </constraints>
1741 <control type="edit" format="string" />
1742 </setting>
1743 <setting id="saved_view_tvshows" type="string" label="" help="">
1744 <visible>false</visible>
1745 <default />
1746 <constraints>
1747 <allowempty>true</allowempty>
1748 </constraints>
1749 <control type="edit" format="string" />
1750 </setting>
1751 <setting id="saved_view_seasons" type="string" label="" help="">
1752 <visible>false</visible>
1753 <default />
1754 <constraints>
1755 <allowempty>true</allowempty>
1756 </constraints>
1757 <control type="edit" format="string" />
1758 </setting>
1759 <setting id="saved_view_episodes" type="string" label="" help="">
1760 <visible>false</visible>
1761 <default />
1762 <constraints>
1763 <allowempty>true</allowempty>
1764 </constraints>
1765 <control type="edit" format="string" />
1766 </setting>
1767 <setting id="saved_view_library" type="string" label="" help="">
1768 <visible>false</visible>
1769 <default />
1770 <constraints>
1771 <allowempty>true</allowempty>
1772 </constraints>
1773 <control type="edit" format="string" />
1774 </setting>
1775 <setting id="saved_view_history" type="string" label="" help="">
1776 <visible>false</visible>
1777 <default />
1778 <constraints>
1779 <allowempty>true</allowempty>
1780 </constraints>
1781 <control type="edit" format="string" />
1782 </setting>
1783 <setting id="saved_view_downloads" type="string" label="" help="">
1784 <visible>false</visible>
1785 <default />
1786 <constraints>
1787 <allowempty>true</allowempty>
1788 </constraints>
1789 <control type="edit" format="string" />
1790 </setting>
1791 </group>
1792 </category>
1793 </section>
1794</settings>
plugin.video.jacktook/resources/skins/Default/1080i/actor_info.xml +256 -0
……@@ -0,0 +1,256 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<window type="dialog" id="1200">
3 <defaultcontrol always="true">3000</defaultcontrol>
4 <zorder>1</zorder>
5 <animation effect="fade" start="0" end="100" time="300">WindowOpen</animation>
6 <animation effect="fade" start="100" end="0" time="200">WindowClose</animation>
7
8 <controls>
9 <control type="group">
10 <!-- Full-screen solid backdrop to hide previous window -->
11 <control type="image">
12 <left>0</left>
13 <top>0</top>
14 <width>1920</width>
15 <height>1080</height>
16 <texture colordiffuse="FF111111">white.png</texture>
17 </control>
18
19 <!-- Optional fanart background (dimmed) -->
20 <control type="image" id="300">
21 <left>0</left>
22 <top>0</top>
23 <width>1920</width>
24 <height>1080</height>
25 <texture colordiffuse="33FFFFFF">$INFO[Window.Property(fanart)]</texture>
26 <aspectratio>scale</aspectratio>
27 </control>
28
29 <!-- Fade-in animation -->
30 <animation effect="fade" end="100" time="200" delay="400">WindowOpen</animation>
31
32 <!-- ── Top Bar ── -->
33 <control type="group">
34 <left>40</left>
35 <top>15</top>
36 <control type="label">
37 <left>0</left>
38 <width>300</width>
39 <height>30</height>
40 <font>font13</font>
41 <textcolor>FF999999</textcolor>
42 <label>[B]Actor Info[/B]</label>
43 </control>
44 </control>
45
46 <!-- ══════════════════════════════════════════════════════════════════
47 ACTOR HERO: Photo Left + Info Right
48 ══════════════════════════════════════════════════════════════════ -->
49 <control type="group">
50 <left>60</left>
51 <top>65</top>
52
53 <!-- ── Actor Photo ── -->
54 <control type="image" id="200">
55 <width>300</width>
56 <height>450</height>
57 <aspectratio aligny="top">keep</aspectratio>
58 <texture diffuse="poster_w217_h320.png">$INFO[Window.Property(photo)]</texture>
59 </control>
60
61 <!-- ── Info Panel ── -->
62 <control type="group">
63 <left>340</left>
64 <top>10</top>
65
66 <!-- Actor Name -->
67 <control type="label">
68 <width>900</width>
69 <height>50</height>
70 <font>font_title</font>
71 <textcolor>FFFFFFFF</textcolor>
72 <label>[B]$INFO[Window.Property(name)][/B]</label>
73 </control>
74
75 <!-- Known For -->
76 <control type="label">
77 <top>60</top>
78 <width>900</width>
79 <height>30</height>
80 <font>font14</font>
81 <textcolor>FF999999</textcolor>
82 <label>$INFO[Window.Property(known_for)]</label>
83 </control>
84
85 <!-- Birthday + Place of Birth -->
86 <control type="label">
87 <top>100</top>
88 <width>900</width>
89 <height>30</height>
90 <font>font14</font>
91 <textcolor>FFBBBBBB</textcolor>
92 <label>$INFO[Window.Property(birthday_info)]</label>
93 </control>
94
95 <!-- Deathday (if applicable) -->
96 <control type="label">
97 <top>138</top>
98 <width>900</width>
99 <height>30</height>
100 <font>font14</font>
101 <textcolor>FF888888</textcolor>
102 <label>$INFO[Window.Property(deathday_info)]</label>
103 <visible>!String.IsEmpty(Window.Property(deathday_info))</visible>
104 </control>
105
106 <!-- Biography -->
107 <control type="group">
108 <top>180</top>
109 <!-- Card background -->
110 <control type="image">
111 <width>1440</width>
112 <height>280</height>
113 <texture colordiffuse="BB111115" border="20">circle.png</texture>
114 </control>
115 <control type="label">
116 <left>25</left>
117 <top>15</top>
118 <width>200</width>
119 <height>25</height>
120 <font>font12</font>
121 <textcolor>FF666666</textcolor>
122 <label>[B]BIOGRAPHY[/B]</label>
123 </control>
124 <control type="textbox" id="100">
125 <left>25</left>
126 <top>45</top>
127 <width>1390</width>
128 <height>220</height>
129 <font>font12</font>
130 <textcolor>FFCCCCCC</textcolor>
131 <label>$INFO[Window.Property(biography)]</label>
132 <autoscroll delay="8000" time="3000" repeat="5000">true</autoscroll>
133 </control>
134 </control>
135 </control>
136 </control>
137
138 <!-- ══════════════════════════════════════════════════════════════════
139 FILMOGRAPHY — Horizontal carousel
140 ══════════════════════════════════════════════════════════════════ -->
141 <control type="group">
142 <left>60</left>
143 <top>540</top>
144
145 <!-- Section Label -->
146 <control type="label">
147 <width>400</width>
148 <height>30</height>
149 <font>font13</font>
150 <textcolor>FF999999</textcolor>
151 <label>[B]FILMOGRAPHY[/B]</label>
152 </control>
153
154 <!-- Film count -->
155 <control type="label">
156 <left>230</left>
157 <width>200</width>
158 <height>30</height>
159 <font>font12</font>
160 <textcolor>FF555555</textcolor>
161 <label>$INFO[Window.Property(filmography.count)]</label>
162 </control>
163
164 <!-- Filmography List -->
165 <control type="list" id="3000">
166 <top>40</top>
167 <width>1800</width>
168 <height>490</height>
169 <orientation>horizontal</orientation>
170 <scrolltime tween="sine">400</scrolltime>
171 <focusposition>0</focusposition>
172 <movement>3</movement>
173 <itemlayout height="460" width="220">
174 <control type="image">
175 <left>5</left>
176 <width>200</width>
177 <height>300</height>
178 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Art(thumb)]</texture>
179 <aspectratio>scale</aspectratio>
180 </control>
181 <control type="label">
182 <left>5</left>
183 <top>310</top>
184 <width>200</width>
185 <height>25</height>
186 <font>font10</font>
187 <textcolor>FFCCCCCC</textcolor>
188 <label>$INFO[ListItem.Label]</label>
189 </control>
190 <control type="label">
191 <left>5</left>
192 <top>338</top>
193 <width>200</width>
194 <height>20</height>
195 <font>font12</font>
196 <textcolor>FF777777</textcolor>
197 <label>$INFO[ListItem.Property(role)]</label>
198 </control>
199 <control type="label">
200 <left>5</left>
201 <top>362</top>
202 <width>200</width>
203 <height>20</height>
204 <font>font10</font>
205 <textcolor>FF555555</textcolor>
206 <label>$INFO[ListItem.Property(year)]</label>
207 </control>
208 </itemlayout>
209 <focusedlayout height="460" width="220">
210 <control type="image">
211 <left>0</left>
212 <top>-5</top>
213 <width>210</width>
214 <height>310</height>
215 <texture colordiffuse="FFFFFFFF" border="8">panel-256-box.png</texture>
216 </control>
217 <control type="image">
218 <left>5</left>
219 <width>200</width>
220 <height>300</height>
221 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Art(thumb)]</texture>
222 <aspectratio>scale</aspectratio>
223 </control>
224 <control type="label">
225 <left>5</left>
226 <top>310</top>
227 <width>200</width>
228 <height>25</height>
229 <font>font10</font>
230 <textcolor>FFFFFFFF</textcolor>
231 <label>$INFO[ListItem.Label]</label>
232 </control>
233 <control type="label">
234 <left>5</left>
235 <top>338</top>
236 <width>200</width>
237 <height>20</height>
238 <font>font12</font>
239 <textcolor>FFAAAAAA</textcolor>
240 <label>$INFO[ListItem.Property(role)]</label>
241 </control>
242 <control type="label">
243 <left>5</left>
244 <top>362</top>
245 <width>200</width>
246 <height>20</height>
247 <font>font10</font>
248 <textcolor>FF777777</textcolor>
249 <label>$INFO[ListItem.Property(year)]</label>
250 </control>
251 </focusedlayout>
252 </control>
253 </control>
254 </control>
255 </controls>
256</window>
plugin.video.jacktook/resources/skins/Default/1080i/custom_progress_dialog.xml +92 -0
……@@ -0,0 +1,92 @@
1<window type="dialog" id="12000">
2 <coordinates>
3 <left>600</left>
4 <top>400</top>
5 <width>800</width>
6 <height>300</height>
7 </coordinates>
8 <defaultcontrol always="true">12005</defaultcontrol>
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"/>
12 </animation>
13 <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"/>
16 </animation>
17 <depth>0.40</depth>
18 <controls>
19 <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>
28 <control type="label" id="12001">
29 <top>30</top>
30 <width>780</width>
31 <height>40</height>
32 <align>center</align>
33 <font>font14</font>
34 <textcolor>white</textcolor>
35 <shadowcolor>AA000000</shadowcolor>
36 </control>
37 <control type="label" id="12002">
38 <top>80</top>
39 <width>780</width>
40 <height>60</height>
41 <align>center</align>
42 <font>font12</font>
43 <textcolor>white</textcolor>
44 <shadowcolor>AA000000</shadowcolor>
45 <wrapmultiline>true</wrapmultiline>
46 </control>
47 <control type="progress" id="12004">
48 <top>180</top>
49 <left>40</left>
50 <width>720</width>
51 <height>20</height>
52 <info>percentage</info>
53 </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 -->
60 <control type="button" id="12003">
61 <width>auto</width>
62 <height>56</height>
63 <label>Cancel</label>
64 <align>center</align>
65 <textoffsetx>20</textoffsetx>
66 <font>font14</font>
67 <textcolor>DDFFFFFF</textcolor>
68 <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>
73 </control>
74 <!-- Close Button -->
75 <control type="button" id="12005">
76 <width>auto</width>
77 <height>56</height>
78 <label>Close</label>
79 <align>center</align>
80 <textoffsetx>20</textoffsetx>
81 <font>font14</font>
82 <textcolor>DDFFFFFF</textcolor>
83 <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>
88 </control>
89 </control>
90 </control>
91 </controls>
92</window>
plugin.video.jacktook/resources/skins/Default/1080i/customdialog.xml +105 -0
……@@ -0,0 +1,105 @@
1<window type="dialog">
2 <coordinates>
3 <left>460</left>
4 <top>290</top>
5 <width>1000</width>
6 <height>500</height>
7 </coordinates>
8
9 <defaultcontrol always="true">32503</defaultcontrol>
10 <animation type="WindowOpen" reversible="false">
11 <effect type="zoom" start="80" end="100" center="50%,50%" delay="160" tween="back" time="240"/>
12 <effect type="fade" delay="160" end="100" time="240"/>
13 </animation>
14 <animation type="WindowClose" reversible="false">
15 <effect type="zoom" start="100" end="80" center="50%,50%" easing="in" tween="back" time="240"/>
16 <effect type="fade" start="100" end="0" time="240"/>
17 </animation>
18 <depth>0.40</depth>
19
20 <controls>
21 <control type="group">
22 <width>1000</width>
23 <height>500</height>
24
25 <!-- Card with Rounded Corners -->
26 <control type="image">
27 <width>1000</width>
28 <height>500</height>
29 <texture border="30">circle.png</texture>
30 <colordiffuse>CC000000</colordiffuse>
31 </control>
32
33 <!-- Window Title -->
34 <control type="label" id="32500">
35 <top>20</top>
36 <width>1000</width>
37 <height>40</height>
38 <font>font25</font>
39 <align>center</align>
40 <textcolor>white</textcolor>
41 <shadowcolor>AA000000</shadowcolor>
42 </control>
43
44 <!-- QR Code -->
45 <control type="image" id="32504">
46 <top>110</top>
47 <left>60</left>
48 <width>280</width>
49 <height>280</height>
50 <texture>qr_placeholder.png</texture>
51 <aspectratio>keep</aspectratio>
52 </control>
53
54 <!-- Text Body -->
55 <control type="label" id="32501">
56 <font>font14</font>
57 <top>110</top>
58 <left>400</left>
59 <width>550</width>
60 <height>180</height>
61 <align>left</align>
62 <textcolor>white</textcolor>
63 <shadowcolor>AA000000</shadowcolor>
64 <wrapmultiline>true</wrapmultiline>
65 </control>
66
67 <!-- Close button -->
68 <control type="button" id="32503">
69 <left>940</left>
70 <top>10</top>
71 <width>50</width>
72 <height>50</height>
73 <texturefocus colordiffuse="FF12A0C7">Button/close.png</texturefocus>
74 <texturenofocus colordiffuse="EEFFFFFF">Button/close.png</texturenofocus>
75 </control>
76 </control>
77
78 <control type="group">
79 <width>550</width>
80 <height>100</height>
81 <top>320</top>
82 <left>400</left>
83
84 <!-- Card with Rounded Corners -->
85 <control type="image">
86 <right>-6</right>
87 <bottom>-2</bottom>
88 <texture border="30">circle.png</texture>
89 <colordiffuse>CCFF0000</colordiffuse>
90 </control>
91
92 <!-- Text URL -->
93 <control type="label" id="32502">
94 <font>font14</font>
95 <align>center</align>
96 <aligny>center</aligny>
97 <height>100</height>
98 <textcolor>white</textcolor>
99 <shadowcolor>FF000000</shadowcolor>
100 <wrap>true</wrap>
101 </control>
102 </control>
103 </controls>
104</window>
105
plugin.video.jacktook/resources/skins/Default/1080i/extras.xml +1296 -0
……@@ -0,0 +1,1296 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<window>
3 <defaultcontrol always="true">10</defaultcontrol>
4 <controls>
5 <!-- ══════════════════════════════════════════════════════════════════════
6 BACKGROUND
7 ══════════════════════════════════════════════════════════════════════ -->
8 <control type="group">
9 <width>1920</width>
10 <height>1080</height>
11 <control type="image">
12 <texture colordiffuse="FF000000">white.png</texture>
13 </control>
14 <control type="image" id="202">
15 <aspectratio>scale</aspectratio>
16 <texture>$INFO[Window.Property(fanart)]</texture>
17 <animation effect="fade" end="20" time="400">WindowOpen</animation>
18 </control>
19 </control>
20
21 <!-- ══════════════════════════════════════════════════════════════════════
22 MAIN CONTENT
23 ══════════════════════════════════════════════════════════════════════ -->
24 <control type="group">
25 <animation effect="fade" end="100" time="200" delay="400">WindowOpen</animation>
26
27 <!-- ── Top Bar: Addon Icon + Clock ── -->
28 <control type="group">
29 <left>40</left>
30 <top>15</top>
31 <control type="image">
32 <width>45</width>
33 <height>45</height>
34 <aspectratio aligny="top">keep</aspectratio>
35 <texture>$INFO[Window(10000).Property(jacktook.addon_icon_mini)]</texture>
36 </control>
37 <control type="label">
38 <left>1540</left>
39 <width max="300">auto</width>
40 <height>20</height>
41 <font>font10</font>
42 <textcolor>FF555555</textcolor>
43 <align>right</align>
44 <label>[B]$INFO[System.Time][/B] • $INFO[System.Date]</label>
45 </control>
46 </control>
47
48 <!-- ── Clearlogo (right side, centered in empty space) ── -->
49 <control type="image" id="201">
50 <left>950</left>
51 <top>120</top>
52 <width>920</width>
53 <height>250</height>
54 <aspectratio aligny="center" align="center">keep</aspectratio>
55 <texture>$INFO[Window.Property(clearlogo)]</texture>
56 <visible>!String.IsEqual(Window.Property(clearlogo),false)</visible>
57 </control>
58 <!-- ══════════════════════════════════════════════════════════════════
59 HERO: Poster Left + Info Right
60 ══════════════════════════════════════════════════════════════════ -->
61 <control type="group">
62 <left>60</left>
63 <top>75</top>
64
65 <!-- ── Poster ── -->
66 <control type="image" id="200">
67 <width>260</width>
68 <height>390</height>
69 <aspectratio aligny="top">keep</aspectratio>
70 <texture diffuse="poster_w217_h320.png">$INFO[Window.Property(poster)]</texture>
71 </control>
72
73 <!-- ── Info Panel (right of poster) ── -->
74 <control type="group">
75 <left>300</left>
76 <top>15</top>
77
78 <!-- Title (always visible) -->
79 <control type="label">
80 <width>900</width>
81 <height>50</height>
82 <font>font_title</font>
83 <textcolor>FFFFFFFF</textcolor>
84 <label>[B]$INFO[Window.Property(title)][/B]</label>
85 </control>
86
87 <!-- Tagline (italic) -->
88 <control type="label" id="3001">
89 <top>65</top>
90 <width>900</width>
91 <height>30</height>
92 <font>font14</font>
93 <textcolor>FF777777</textcolor>
94 </control>
95
96 <!-- Genre -->
97 <control type="label">
98 <top>110</top>
99 <width>900</width>
100 <height>30</height>
101 <font>font14</font>
102 <textcolor>FFBBBBBB</textcolor>
103 <label>$INFO[Window.Property(genre)]</label>
104 </control>
105 <!-- Year / Runtime / Status -->
106 <control type="label" id="2001">
107 <top>145</top>
108 <width>900</width>
109 <height>30</height>
110 <font>font14</font>
111 <textcolor>FFBBBBBB</textcolor>
112 </control>
113
114 <!-- Rating -->
115 <control type="group">
116 <top>190</top>
117 <visible>String.IsEqual(Window.Property(tmdb_rating),true)</visible>
118 <control type="label" id="4005">
119 <width max="150">auto</width>
120 <height>30</height>
121 <font>font14</font>
122 <textcolor>FFFFCC00</textcolor>
123 <label>★ </label>
124 </control>
125 </control>
126
127 <!-- Action Buttons (minimalist) -->
128 <control type="group">
129 <top>240</top>
130 <control type="grouplist">
131 <width>900</width>
132 <height>50</height>
133 <orientation>horizontal</orientation>
134 <itemgap>5</itemgap>
135
136 <control type="button" id="10">
137 <width min="180">auto</width>
138 <height>48</height>
139 <onright>11</onright>
140 <onleft>17</onleft>
141 <ondown>20</ondown>
142 <label>$INFO[Window.Property(button10.label)]</label>
143 <font>font13</font>
144 <textcolor>FF999999</textcolor>
145 <focusedcolor>FFFFFFFF</focusedcolor>
146 <texturefocus colordiffuse="33FFFFFF" border="8">panel-256-box.png</texturefocus>
147 <texturenofocus>-</texturenofocus>
148 <align>center</align>
149 <aligny>center</aligny>
150 <textoffsetx>30</textoffsetx>
151 <textallowoverflow>false</textallowoverflow>
152 </control>
153 <control type="button" id="11">
154 <width min="180">auto</width>
155 <height>48</height>
156 <onright>15</onright>
157 <onleft>10</onleft>
158 <ondown>20</ondown>
159 <label>$INFO[Window.Property(button11.label)]</label>
160 <font>font13</font>
161 <textcolor>FF999999</textcolor>
162 <focusedcolor>FFFFFFFF</focusedcolor>
163 <texturefocus colordiffuse="33FFFFFF" border="8">panel-256-box.png</texturefocus>
164 <texturenofocus>-</texturenofocus>
165 <align>center</align>
166 <aligny>center</aligny>
167 <textoffsetx>30</textoffsetx>
168 <textallowoverflow>false</textallowoverflow>
169 </control>
170 <control type="button" id="15">
171 <width min="180">auto</width>
172 <height>48</height>
173 <onright>16</onright>
174 <onleft>11</onleft>
175 <ondown>20</ondown>
176 <label>$INFO[Window.Property(button15.label)]</label>
177 <font>font13</font>
178 <textcolor>FF999999</textcolor>
179 <focusedcolor>FFFFFFFF</focusedcolor>
180 <texturefocus colordiffuse="33FFFFFF" border="8">panel-256-box.png</texturefocus>
181 <texturenofocus>-</texturenofocus>
182 <align>center</align>
183 <aligny>center</aligny>
184 <textoffsetx>30</textoffsetx>
185 <textallowoverflow>false</textallowoverflow>
186 </control>
187 <control type="button" id="16">
188 <width min="180">auto</width>
189 <height>48</height>
190 <onright>17</onright>
191 <onleft>15</onleft>
192 <ondown>20</ondown>
193 <label>$INFO[Window.Property(button16.label)]</label>
194 <font>font13</font>
195 <textcolor>FF999999</textcolor>
196 <focusedcolor>FFFFFFFF</focusedcolor>
197 <texturefocus colordiffuse="33FFFFFF" border="8">panel-256-box.png</texturefocus>
198 <texturenofocus>-</texturenofocus>
199 <align>center</align>
200 <aligny>center</aligny>
201 <textoffsetx>30</textoffsetx>
202 <textallowoverflow>false</textallowoverflow>
203 </control>
204 <control type="button" id="17">
205 <width min="180">auto</width>
206 <height>48</height>
207 <onright>10</onright>
208 <onleft>16</onleft>
209 <ondown>20</ondown>
210 <label>$INFO[Window.Property(button17.label)]</label>
211 <font>font13</font>
212 <textcolor>FF999999</textcolor>
213 <focusedcolor>FFFFFFFF</focusedcolor>
214 <texturefocus colordiffuse="33FFFFFF" border="8">panel-256-box.png</texturefocus>
215 <texturenofocus>-</texturenofocus>
216 <align>center</align>
217 <aligny>center</aligny>
218 <textoffsetx>30</textoffsetx>
219 <textallowoverflow>false</textallowoverflow>
220 </control>
221 </control>
222 </control>
223 </control>
224 </control>
225
226 <!-- ══════════════════════════════════════════════════════════════════
227 TAB PILLS ROW
228 ══════════════════════════════════════════════════════════════════ -->
229 <control type="group">
230 <top>460</top>
231 <left>60</left>
232 <control type="grouplist">
233 <width>1800</width>
234 <height>50</height>
235 <orientation>horizontal</orientation>
236 <itemgap>5</itemgap>
237
238 <control type="button" id="20">
239 <width min="160">auto</width>
240 <height>48</height>
241 <onfocus>SetProperty(active_tab,overview)</onfocus>
242 <onright>21</onright>
243 <onleft>26</onleft>
244 <onup>10</onup>
245 <ondown>2000</ondown>
246 <label>Overview</label>
247 <font>font13</font>
248 <textcolor>FF999999</textcolor>
249 <focusedcolor>FFFFFFFF</focusedcolor>
250 <texturefocus colordiffuse="33FFFFFF" border="8">panel-256-box.png</texturefocus>
251 <texturenofocus>-</texturenofocus>
252 <align>center</align>
253 <aligny>center</aligny>
254 <textoffsetx>30</textoffsetx>
255 <textallowoverflow>false</textallowoverflow>
256 </control>
257 <control type="button" id="21">
258 <width min="120">auto</width>
259 <height>48</height>
260 <onfocus>SetProperty(active_tab,cast)</onfocus>
261 <onright>22</onright>
262 <onleft>20</onleft>
263 <onup>10</onup>
264 <ondown>2050</ondown>
265 <label>Cast</label>
266 <font>font13</font>
267 <textcolor>FF999999</textcolor>
268 <focusedcolor>FFFFFFFF</focusedcolor>
269 <texturefocus colordiffuse="33FFFFFF" border="8">panel-256-box.png</texturefocus>
270 <texturenofocus>-</texturenofocus>
271 <align>center</align>
272 <aligny>center</aligny>
273 <textoffsetx>30</textoffsetx>
274 <textallowoverflow>false</textallowoverflow>
275 </control>
276 <control type="button" id="22">
277 <width min="140">auto</width>
278 <height>48</height>
279 <onfocus>SetProperty(active_tab,similar)</onfocus>
280 <onright>23</onright>
281 <onleft>21</onleft>
282 <onup>10</onup>
283 <ondown>2051</ondown>
284 <label>Similar</label>
285 <font>font13</font>
286 <textcolor>FF999999</textcolor>
287 <focusedcolor>FFFFFFFF</focusedcolor>
288 <texturefocus colordiffuse="33FFFFFF" border="8">panel-256-box.png</texturefocus>
289 <texturenofocus>-</texturenofocus>
290 <align>center</align>
291 <aligny>center</aligny>
292 <textoffsetx>30</textoffsetx>
293 <textallowoverflow>false</textallowoverflow>
294 </control>
295 <control type="button" id="23">
296 <width min="160">auto</width>
297 <height>48</height>
298 <onfocus>SetProperty(active_tab,reviews)</onfocus>
299 <onright>24</onright>
300 <onleft>22</onleft>
301 <onup>10</onup>
302 <ondown>2054</ondown>
303 <label>Reviews</label>
304 <font>font13</font>
305 <textcolor>FF999999</textcolor>
306 <focusedcolor>FFFFFFFF</focusedcolor>
307 <texturefocus colordiffuse="33FFFFFF" border="8">panel-256-box.png</texturefocus>
308 <texturenofocus>-</texturenofocus>
309 <align>center</align>
310 <aligny>center</aligny>
311 <textoffsetx>30</textoffsetx>
312 <textallowoverflow>false</textallowoverflow>
313 </control>
314 <control type="button" id="24">
315 <width min="140">auto</width>
316 <height>48</height>
317 <onfocus>SetProperty(active_tab,videos)</onfocus>
318 <onright>25</onright>
319 <onleft>23</onleft>
320 <onup>10</onup>
321 <ondown>2060</ondown>
322 <label>Videos</label>
323 <font>font13</font>
324 <textcolor>FF999999</textcolor>
325 <focusedcolor>FFFFFFFF</focusedcolor>
326 <texturefocus colordiffuse="33FFFFFF" border="8">panel-256-box.png</texturefocus>
327 <texturenofocus>-</texturenofocus>
328 <align>center</align>
329 <aligny>center</aligny>
330 <textoffsetx>30</textoffsetx>
331 <textallowoverflow>false</textallowoverflow>
332 </control>
333 <control type="button" id="25">
334 <width min="140">auto</width>
335 <height>48</height>
336 <onfocus>SetProperty(active_tab,trivia)</onfocus>
337 <onright>26</onright>
338 <onleft>24</onleft>
339 <onup>10</onup>
340 <ondown>2056</ondown>
341 <label>Trivia</label>
342 <font>font13</font>
343 <textcolor>FF999999</textcolor>
344 <focusedcolor>FFFFFFFF</focusedcolor>
345 <texturefocus colordiffuse="33FFFFFF" border="8">panel-256-box.png</texturefocus>
346 <texturenofocus>-</texturenofocus>
347 <align>center</align>
348 <aligny>center</aligny>
349 <textoffsetx>30</textoffsetx>
350 <textallowoverflow>false</textallowoverflow>
351 </control>
352 <control type="button" id="26">
353 <width min="140">auto</width>
354 <height>48</height>
355 <onfocus>SetProperty(active_tab,more)</onfocus>
356 <onright>20</onright>
357 <onleft>25</onleft>
358 <onup>10</onup>
359 <ondown>2061</ondown>
360 <label>More</label>
361 <font>font13</font>
362 <textcolor>FF999999</textcolor>
363 <focusedcolor>FFFFFFFF</focusedcolor>
364 <texturefocus colordiffuse="33FFFFFF" border="8">panel-256-box.png</texturefocus>
365 <texturenofocus>-</texturenofocus>
366 <align>center</align>
367 <aligny>center</aligny>
368 <textoffsetx>30</textoffsetx>
369 <textallowoverflow>false</textallowoverflow>
370 </control>
371 </control>
372 <!-- Separator line -->
373 <control type="image">
374 <top>52</top>
375 <width>1800</width>
376 <height>1</height>
377 <texture colordiffuse="22FFFFFF">white.png</texture>
378 </control>
379 </control>
380
381 <!-- ══════════════════════════════════════════════════════════════════
382 CONTENT AREA — 510px tall (y:510 to y:1020)
383 ══════════════════════════════════════════════════════════════════ -->
384 <control type="group">
385 <top>510</top>
386 <left>60</left>
387 <width>1800</width>
388 <height>510</height>
389
390 <!-- ═══════════════════════════════════════
391 TAB: OVERVIEW — Full plot
392 ═══════════════════════════════════════ -->
393 <control type="group">
394 <visible>String.IsEqual(Window.Property(active_tab),overview)</visible>
395 <!-- Card background -->
396 <control type="image">
397 <left>80</left>
398 <top>15</top>
399 <width>1640</width>
400 <height>480</height>
401 <texture colordiffuse="BB111115" border="20">circle.png</texture>
402 </control>
403 <!-- Synopsis label -->
404 <control type="label">
405 <left>120</left>
406 <top>35</top>
407 <width>200</width>
408 <height>25</height>
409 <font>font12</font>
410 <textcolor>FF666666</textcolor>
411 <label>[B]SYNOPSIS[/B]</label>
412 </control>
413 <!-- Plot text -->
414 <control type="textbox" id="2000">
415 <left>120</left>
416 <top>70</top>
417 <width>1560</width>
418 <height>400</height>
419 <font>font14</font>
420 <align>left</align>
421 <textcolor>FFCCCCCC</textcolor>
422 <label>$INFO[Window.Property(plot)]</label>
423 <autoscroll delay="10000" time="3000" repeat="5000">true</autoscroll>
424 <onup>20</onup>
425 </control>
426 </control>
427
428 <!-- ═══════════════════════════════════════
429 TAB: CAST
430 ═══════════════════════════════════════ -->
431 <control type="group">
432 <visible>String.IsEqual(Window.Property(active_tab),cast)</visible>
433 <control type="label">
434 <width>800</width>
435 <height>25</height>
436 <font>font12</font>
437 <textcolor>FF888888</textcolor>
438 <label>Cast $INFO[Window.Property(cast.number)] | $INFO[Container(2050).ListItem.Property(name)]$INFO[Container(2050).ListItem.Property(role), • ]</label>
439 </control>
440 <control type="fixedlist" id="2050">
441 <top>35</top>
442 <width>1800</width>
443 <height>460</height>
444 <onup>21</onup>
445 <orientation>horizontal</orientation>
446 <scrolltime tween="sine">400</scrolltime>
447 <focusposition>0</focusposition>
448 <movement>3</movement>
449 <itemlayout height="420" width="210">
450 <control type="image">
451 <left>10</left>
452 <top>10</top>
453 <height>310</height>
454 <width>190</width>
455 <aspectratio>scale</aspectratio>
456 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
457 </control>
458 <control type="label">
459 <top>330</top>
460 <left>10</left>
461 <width>190</width>
462 <height>22</height>
463 <font>font12</font>
464 <textcolor>FFAAAAAA</textcolor>
465 <align>center</align>
466 <label>$INFO[ListItem.Property(name)]</label>
467 </control>
468 <control type="label">
469 <top>355</top>
470 <left>10</left>
471 <width>190</width>
472 <height>20</height>
473 <font>font10</font>
474 <textcolor>FF666666</textcolor>
475 <align>center</align>
476 <label>$INFO[ListItem.Property(role)]</label>
477 </control>
478 </itemlayout>
479 <focusedlayout height="420" width="210">
480 <control type="image">
481 <left>6</left>
482 <top>6</top>
483 <height>318</height>
484 <width>198</width>
485 <texture colordiffuse="FFFFFFFF" border="10">panel-256-box.png</texture>
486 <visible>Control.HasFocus(2050)</visible>
487 </control>
488 <control type="image">
489 <left>10</left>
490 <top>10</top>
491 <height>310</height>
492 <width>190</width>
493 <aspectratio>scale</aspectratio>
494 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
495 </control>
496 <control type="label">
497 <top>330</top>
498 <left>10</left>
499 <width>190</width>
500 <height>22</height>
501 <font>font12</font>
502 <textcolor>FFFFFFFF</textcolor>
503 <align>center</align>
504 <label>$INFO[ListItem.Property(name)]</label>
505 </control>
506 <control type="label">
507 <top>355</top>
508 <left>10</left>
509 <width>190</width>
510 <height>20</height>
511 <font>font10</font>
512 <textcolor>FFAAAAAA</textcolor>
513 <align>center</align>
514 <label>$INFO[ListItem.Property(role)]</label>
515 </control>
516 </focusedlayout>
517 </control>
518 </control>
519
520 <!-- ═══════════════════════════════════════
521 TAB: SIMILAR — Recommended, More Like This, AI Similar
522 ═══════════════════════════════════════ -->
523 <control type="group">
524 <visible>String.IsEqual(Window.Property(active_tab),similar)</visible>
525 <control type="grouplist">
526 <width>1800</width>
527 <height>510</height>
528 <orientation>vertical</orientation>
529 <itemgap>15</itemgap>
530
531 <!-- Recommended (2051) -->
532 <control type="group">
533 <visible>Integer.IsGreater(Container(2051).NumItems,0)</visible>
534 <height>460</height>
535 <control type="label">
536 <width>600</width>
537 <height>25</height>
538 <font>font12</font>
539 <textcolor>FF888888</textcolor>
540 <label>Recommended $INFO[Window.Property(recommended.number)] | $INFO[Container(2051).ListItem.Property(name)]</label>
541 </control>
542 <control type="fixedlist" id="2051">
543 <top>35</top>
544 <width>1800</width>
545 <height>420</height>
546 <onup>22</onup>
547 <ondown>2052</ondown>
548 <orientation>horizontal</orientation>
549 <scrolltime tween="sine">400</scrolltime>
550 <focusposition>0</focusposition>
551 <movement>3</movement>
552 <itemlayout height="400" width="200">
553 <control type="image">
554 <left>10</left>
555 <top>5</top>
556 <height>390</height>
557 <width>180</width>
558 <aspectratio>scale</aspectratio>
559 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
560 </control>
561 </itemlayout>
562 <focusedlayout height="400" width="200">
563 <control type="image">
564 <left>6</left>
565 <top>1</top>
566 <height>398</height>
567 <width>188</width>
568 <texture colordiffuse="FFFFFFFF" border="10">panel-256-box.png</texture>
569 <visible>Control.HasFocus(2051)</visible>
570 </control>
571 <control type="image">
572 <left>10</left>
573 <top>5</top>
574 <height>390</height>
575 <width>180</width>
576 <aspectratio>scale</aspectratio>
577 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
578 </control>
579 </focusedlayout>
580 </control>
581 </control>
582
583 <!-- More Like This (2052) -->
584 <control type="group">
585 <visible>Integer.IsGreater(Container(2052).NumItems,0)</visible>
586 <height>460</height>
587 <control type="label">
588 <width>600</width>
589 <height>25</height>
590 <font>font12</font>
591 <textcolor>FF888888</textcolor>
592 <label>More Like This $INFO[Window.Property(similar.number)]</label>
593 </control>
594 <control type="fixedlist" id="2052">
595 <top>35</top>
596 <width>1800</width>
597 <height>420</height>
598 <onup>2051</onup>
599 <ondown>2053</ondown>
600 <orientation>horizontal</orientation>
601 <scrolltime tween="sine">400</scrolltime>
602 <focusposition>0</focusposition>
603 <movement>3</movement>
604 <itemlayout height="400" width="200">
605 <control type="image">
606 <left>10</left>
607 <top>5</top>
608 <height>390</height>
609 <width>180</width>
610 <aspectratio>scale</aspectratio>
611 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
612 </control>
613 </itemlayout>
614 <focusedlayout height="400" width="200">
615 <control type="image">
616 <left>6</left>
617 <top>1</top>
618 <height>398</height>
619 <width>188</width>
620 <texture colordiffuse="FFFFFFFF" border="10">panel-256-box.png</texture>
621 <visible>Control.HasFocus(2052)</visible>
622 </control>
623 <control type="image">
624 <left>10</left>
625 <top>5</top>
626 <height>390</height>
627 <width>180</width>
628 <aspectratio>scale</aspectratio>
629 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
630 </control>
631 </focusedlayout>
632 </control>
633 </control>
634
635 <!-- AI Similar (2053) -->
636 <control type="group">
637 <visible>Integer.IsGreater(Container(2053).NumItems,0)</visible>
638 <height>460</height>
639 <control type="label">
640 <width>600</width>
641 <height>25</height>
642 <font>font12</font>
643 <textcolor>FF888888</textcolor>
644 <label>AI Similar $INFO[Window.Property(ai_similar.number)]</label>
645 </control>
646 <control type="fixedlist" id="2053">
647 <top>35</top>
648 <width>1800</width>
649 <height>420</height>
650 <onup>2052</onup>
651 <orientation>horizontal</orientation>
652 <scrolltime tween="sine">400</scrolltime>
653 <focusposition>0</focusposition>
654 <movement>3</movement>
655 <itemlayout height="400" width="200">
656 <control type="image">
657 <left>10</left>
658 <top>5</top>
659 <height>390</height>
660 <width>180</width>
661 <aspectratio>scale</aspectratio>
662 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
663 </control>
664 </itemlayout>
665 <focusedlayout height="400" width="200">
666 <control type="image">
667 <left>6</left>
668 <top>1</top>
669 <height>398</height>
670 <width>188</width>
671 <texture colordiffuse="FFFFFFFF" border="10">panel-256-box.png</texture>
672 <visible>Control.HasFocus(2053)</visible>
673 </control>
674 <control type="image">
675 <left>10</left>
676 <top>5</top>
677 <height>390</height>
678 <width>180</width>
679 <aspectratio>scale</aspectratio>
680 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
681 </control>
682 </focusedlayout>
683 </control>
684 </control>
685 </control>
686 </control>
687
688 <!-- ═══════════════════════════════════════
689 TAB: REVIEWS
690 ═══════════════════════════════════════ -->
691 <control type="group">
692 <visible>String.IsEqual(Window.Property(active_tab),reviews)</visible>
693 <control type="grouplist">
694 <width>1800</width>
695 <height>510</height>
696 <orientation>vertical</orientation>
697 <itemgap>15</itemgap>
698
699 <!-- Reviews (2054) -->
700 <control type="group">
701 <visible>Integer.IsGreater(Container(2054).NumItems,0)</visible>
702 <height>300</height>
703 <control type="label">
704 <width>600</width>
705 <height>25</height>
706 <font>font12</font>
707 <textcolor>FF888888</textcolor>
708 <label>Reviews $INFO[Window.Property(tmdb_reviews.number)]</label>
709 </control>
710 <control type="fixedlist" id="2054">
711 <top>35</top>
712 <width>1800</width>
713 <height>260</height>
714 <onup>23</onup>
715 <ondown>2055</ondown>
716 <orientation>horizontal</orientation>
717 <scrolltime tween="sine">400</scrolltime>
718 <focusposition>0</focusposition>
719 <movement>1</movement>
720 <itemlayout height="240" width="500">
721 <control type="image">
722 <width>480</width>
723 <height>230</height>
724 <texture colordiffuse="FF1F2020" border="25">circle.png</texture>
725 </control>
726 <control type="label">
727 <left>20</left>
728 <top>15</top>
729 <width>440</width>
730 <height>200</height>
731 <font>font12</font>
732 <textcolor>FFCCCCCC</textcolor>
733 <label>$INFO[ListItem.Property(text)]</label>
734 <wrapmultiline>true</wrapmultiline>
735 </control>
736 </itemlayout>
737 <focusedlayout height="240" width="500">
738 <control type="image">
739 <width>480</width>
740 <height>230</height>
741 <texture colordiffuse="FFB7B4BB" border="25">circle.png</texture>
742 </control>
743 <control type="label">
744 <left>20</left>
745 <top>15</top>
746 <width>440</width>
747 <height>200</height>
748 <font>font12</font>
749 <textcolor>FF1F2020</textcolor>
750 <label>$INFO[ListItem.Property(text)]</label>
751 <wrapmultiline>true</wrapmultiline>
752 </control>
753 </focusedlayout>
754 </control>
755 </control>
756
757 <!-- Comments (2055) -->
758 <control type="group">
759 <visible>Integer.IsGreater(Container(2055).NumItems,0)</visible>
760 <height>300</height>
761 <control type="label">
762 <width>600</width>
763 <height>25</height>
764 <font>font12</font>
765 <textcolor>FF888888</textcolor>
766 <label>Trakt Comments $INFO[Window.Property(trakt_comments.number)]</label>
767 </control>
768 <control type="fixedlist" id="2055">
769 <top>35</top>
770 <width>1800</width>
771 <height>260</height>
772 <onup>2054</onup>
773 <orientation>horizontal</orientation>
774 <scrolltime tween="sine">400</scrolltime>
775 <focusposition>0</focusposition>
776 <movement>1</movement>
777 <itemlayout height="240" width="500">
778 <control type="image">
779 <width>480</width>
780 <height>230</height>
781 <texture colordiffuse="FF1F2020" border="25">circle.png</texture>
782 </control>
783 <control type="label">
784 <left>20</left>
785 <top>15</top>
786 <width>440</width>
787 <height>200</height>
788 <font>font12</font>
789 <textcolor>FFCCCCCC</textcolor>
790 <label>$INFO[ListItem.Property(text)]</label>
791 <wrapmultiline>true</wrapmultiline>
792 </control>
793 </itemlayout>
794 <focusedlayout height="240" width="500">
795 <control type="image">
796 <width>480</width>
797 <height>230</height>
798 <texture colordiffuse="FFB7B4BB" border="25">circle.png</texture>
799 </control>
800 <control type="label">
801 <left>20</left>
802 <top>15</top>
803 <width>440</width>
804 <height>200</height>
805 <font>font12</font>
806 <textcolor>FF1F2020</textcolor>
807 <label>$INFO[ListItem.Property(text)]</label>
808 <wrapmultiline>true</wrapmultiline>
809 </control>
810 </focusedlayout>
811 </control>
812 </control>
813 </control>
814 </control>
815
816 <!-- ═══════════════════════════════════════
817 TAB: VIDEOS — YouTube (2060)
818 ═══════════════════════════════════════ -->
819 <control type="group">
820 <visible>String.IsEqual(Window.Property(active_tab),videos)</visible>
821 <control type="label">
822 <width>600</width>
823 <height>25</height>
824 <font>font12</font>
825 <textcolor>FF888888</textcolor>
826 <label>YouTube Videos $INFO[Window.Property(youtube_videos.number)]</label>
827 </control>
828 <control type="fixedlist" id="2060">
829 <top>35</top>
830 <width>1800</width>
831 <height>420</height>
832 <onup>24</onup>
833 <orientation>horizontal</orientation>
834 <scrolltime tween="sine">400</scrolltime>
835 <focusposition>0</focusposition>
836 <movement>2</movement>
837 <itemlayout height="350" width="500">
838 <control type="image">
839 <left>10</left>
840 <top>10</top>
841 <height>280</height>
842 <width>480</width>
843 <aspectratio>scale</aspectratio>
844 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
845 </control>
846 <control type="label">
847 <top>300</top>
848 <left>10</left>
849 <width>480</width>
850 <height>22</height>
851 <font>font10</font>
852 <textcolor>FFAAAAAA</textcolor>
853 <label>$INFO[ListItem.Property(name)]</label>
854 </control>
855 </itemlayout>
856 <focusedlayout height="350" width="500">
857 <control type="image">
858 <left>6</left>
859 <top>6</top>
860 <height>288</height>
861 <width>488</width>
862 <texture colordiffuse="FFFFFFFF" border="10">panel-256-box.png</texture>
863 <visible>Control.HasFocus(2060)</visible>
864 </control>
865 <control type="image">
866 <left>10</left>
867 <top>10</top>
868 <height>280</height>
869 <width>480</width>
870 <aspectratio>scale</aspectratio>
871 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
872 </control>
873 <control type="label">
874 <top>300</top>
875 <left>10</left>
876 <width>480</width>
877 <height>22</height>
878 <font>font12</font>
879 <textcolor>FFFFFFFF</textcolor>
880 <label>$INFO[ListItem.Property(name)]</label>
881 </control>
882 </focusedlayout>
883 </control>
884 </control>
885
886 <!-- ═══════════════════════════════════════
887 TAB: TRIVIA — Trivia(2056), Blunders(2057), Parental(2058)
888 ═══════════════════════════════════════ -->
889 <control type="group">
890 <visible>String.IsEqual(Window.Property(active_tab),trivia)</visible>
891 <control type="grouplist">
892 <width>1800</width>
893 <height>510</height>
894 <orientation>vertical</orientation>
895 <itemgap>15</itemgap>
896
897 <!-- Trivia (2056) -->
898 <control type="group">
899 <visible>Integer.IsGreater(Container(2056).NumItems,0)</visible>
900 <height>300</height>
901 <control type="label">
902 <width>600</width>
903 <height>25</height>
904 <font>font12</font>
905 <textcolor>FF888888</textcolor>
906 <label>Trivia $INFO[Window.Property(imdb_trivia.number)]</label>
907 </control>
908 <control type="fixedlist" id="2056">
909 <top>35</top>
910 <width>1800</width>
911 <height>260</height>
912 <onup>25</onup>
913 <ondown>2057</ondown>
914 <orientation>horizontal</orientation>
915 <scrolltime tween="sine">400</scrolltime>
916 <focusposition>0</focusposition>
917 <movement>1</movement>
918 <itemlayout height="240" width="500">
919 <control type="image">
920 <width>480</width>
921 <height>230</height>
922 <texture colordiffuse="FF1F2020" border="25">circle.png</texture>
923 </control>
924 <control type="label">
925 <left>20</left>
926 <top>15</top>
927 <width>440</width>
928 <height>200</height>
929 <font>font12</font>
930 <textcolor>FFCCCCCC</textcolor>
931 <label>$INFO[ListItem.Property(text)]</label>
932 <wrapmultiline>true</wrapmultiline>
933 </control>
934 </itemlayout>
935 <focusedlayout height="240" width="500">
936 <control type="image">
937 <width>480</width>
938 <height>230</height>
939 <texture colordiffuse="FFB7B4BB" border="25">circle.png</texture>
940 </control>
941 <control type="label">
942 <left>20</left>
943 <top>15</top>
944 <width>440</width>
945 <height>200</height>
946 <font>font12</font>
947 <textcolor>FF1F2020</textcolor>
948 <label>$INFO[ListItem.Property(text)]</label>
949 <wrapmultiline>true</wrapmultiline>
950 </control>
951 </focusedlayout>
952 </control>
953 </control>
954
955 <!-- Blunders (2057) -->
956 <control type="group">
957 <visible>Integer.IsGreater(Container(2057).NumItems,0)</visible>
958 <height>300</height>
959 <control type="label">
960 <width>600</width>
961 <height>25</height>
962 <font>font12</font>
963 <textcolor>FF888888</textcolor>
964 <label>Blunders $INFO[Window.Property(imdb_blunders.number)]</label>
965 </control>
966 <control type="fixedlist" id="2057">
967 <top>35</top>
968 <width>1800</width>
969 <height>260</height>
970 <onup>2056</onup>
971 <ondown>2058</ondown>
972 <orientation>horizontal</orientation>
973 <scrolltime tween="sine">400</scrolltime>
974 <focusposition>0</focusposition>
975 <movement>1</movement>
976 <itemlayout height="240" width="500">
977 <control type="image">
978 <width>480</width>
979 <height>230</height>
980 <texture colordiffuse="FF1F2020" border="25">circle.png</texture>
981 </control>
982 <control type="label">
983 <left>20</left>
984 <top>15</top>
985 <width>440</width>
986 <height>200</height>
987 <font>font12</font>
988 <textcolor>FFCCCCCC</textcolor>
989 <label>$INFO[ListItem.Property(text)]</label>
990 <wrapmultiline>true</wrapmultiline>
991 </control>
992 </itemlayout>
993 <focusedlayout height="240" width="500">
994 <control type="image">
995 <width>480</width>
996 <height>230</height>
997 <texture colordiffuse="FFB7B4BB" border="25">circle.png</texture>
998 </control>
999 <control type="label">
1000 <left>20</left>
1001 <top>15</top>
1002 <width>440</width>
1003 <height>200</height>
1004 <font>font12</font>
1005 <textcolor>FF1F2020</textcolor>
1006 <label>$INFO[ListItem.Property(text)]</label>
1007 <wrapmultiline>true</wrapmultiline>
1008 </control>
1009 </focusedlayout>
1010 </control>
1011 </control>
1012
1013 <!-- Parental Guide (2058) -->
1014 <control type="group">
1015 <visible>Integer.IsGreater(Container(2058).NumItems,0)</visible>
1016 <height>300</height>
1017 <control type="label">
1018 <width>600</width>
1019 <height>25</height>
1020 <font>font12</font>
1021 <textcolor>FF888888</textcolor>
1022 <label>Parental Guide $INFO[Window.Property(imdb_parentsguide.number)]</label>
1023 </control>
1024 <control type="fixedlist" id="2058">
1025 <top>35</top>
1026 <width>1800</width>
1027 <height>260</height>
1028 <onup>2057</onup>
1029 <orientation>horizontal</orientation>
1030 <scrolltime tween="sine">400</scrolltime>
1031 <focusposition>0</focusposition>
1032 <movement>1</movement>
1033 <itemlayout height="240" width="500">
1034 <control type="image">
1035 <width>480</width>
1036 <height>230</height>
1037 <texture colordiffuse="FF1F2020" border="25">circle.png</texture>
1038 </control>
1039 <control type="label">
1040 <left>20</left>
1041 <top>15</top>
1042 <width>440</width>
1043 <height>200</height>
1044 <font>font12</font>
1045 <textcolor>FFCCCCCC</textcolor>
1046 <label>$INFO[ListItem.Property(text)]</label>
1047 <wrapmultiline>true</wrapmultiline>
1048 </control>
1049 </itemlayout>
1050 <focusedlayout height="240" width="500">
1051 <control type="image">
1052 <width>480</width>
1053 <height>230</height>
1054 <texture colordiffuse="FFB7B4BB" border="25">circle.png</texture>
1055 </control>
1056 <control type="label">
1057 <left>20</left>
1058 <top>15</top>
1059 <width>440</width>
1060 <height>200</height>
1061 <font>font12</font>
1062 <textcolor>FF1F2020</textcolor>
1063 <label>$INFO[ListItem.Property(text)]</label>
1064 <wrapmultiline>true</wrapmultiline>
1065 </control>
1066 </focusedlayout>
1067 </control>
1068 </control>
1069 </control>
1070 </control>
1071
1072 <!-- ═══════════════════════════════════════
1073 TAB: MORE — Year(2061), Genres(2062), Networks(2063), Collection(2064)
1074 ═══════════════════════════════════════ -->
1075 <control type="group">
1076 <visible>String.IsEqual(Window.Property(active_tab),more)</visible>
1077 <control type="grouplist">
1078 <width>1800</width>
1079 <height>510</height>
1080 <orientation>vertical</orientation>
1081 <itemgap>15</itemgap>
1082
1083 <!-- More from Year (2061) -->
1084 <control type="group">
1085 <visible>Integer.IsGreater(Container(2061).NumItems,0)</visible>
1086 <height>460</height>
1087 <control type="label">
1088 <width>600</width>
1089 <height>25</height>
1090 <font>font12</font>
1091 <textcolor>FF888888</textcolor>
1092 <label>More from Year $INFO[Window.Property(more_from_year.number)]</label>
1093 </control>
1094 <control type="fixedlist" id="2061">
1095 <top>35</top>
1096 <width>1800</width>
1097 <height>420</height>
1098 <onup>26</onup>
1099 <ondown>2062</ondown>
1100 <orientation>horizontal</orientation>
1101 <scrolltime tween="sine">400</scrolltime>
1102 <focusposition>0</focusposition>
1103 <movement>3</movement>
1104 <itemlayout height="400" width="200">
1105 <control type="image">
1106 <left>10</left>
1107 <top>5</top>
1108 <height>390</height>
1109 <width>180</width>
1110 <aspectratio>scale</aspectratio>
1111 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
1112 </control>
1113 </itemlayout>
1114 <focusedlayout height="400" width="200">
1115 <control type="image">
1116 <left>6</left>
1117 <top>1</top>
1118 <height>398</height>
1119 <width>188</width>
1120 <texture colordiffuse="FFFFFFFF" border="10">panel-256-box.png</texture>
1121 <visible>Control.HasFocus(2061)</visible>
1122 </control>
1123 <control type="image">
1124 <left>10</left>
1125 <top>5</top>
1126 <height>390</height>
1127 <width>180</width>
1128 <aspectratio>scale</aspectratio>
1129 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
1130 </control>
1131 </focusedlayout>
1132 </control>
1133 </control>
1134
1135 <!-- More from Genres (2062) -->
1136 <control type="group">
1137 <visible>Integer.IsGreater(Container(2062).NumItems,0)</visible>
1138 <height>460</height>
1139 <control type="label">
1140 <width>600</width>
1141 <height>25</height>
1142 <font>font12</font>
1143 <textcolor>FF888888</textcolor>
1144 <label>More from Genres $INFO[Window.Property(more_from_genres.number)]</label>
1145 </control>
1146 <control type="fixedlist" id="2062">
1147 <top>35</top>
1148 <width>1800</width>
1149 <height>420</height>
1150 <onup>2061</onup>
1151 <ondown>2063</ondown>
1152 <orientation>horizontal</orientation>
1153 <scrolltime tween="sine">400</scrolltime>
1154 <focusposition>0</focusposition>
1155 <movement>3</movement>
1156 <itemlayout height="400" width="200">
1157 <control type="image">
1158 <left>10</left>
1159 <top>5</top>
1160 <height>390</height>
1161 <width>180</width>
1162 <aspectratio>scale</aspectratio>
1163 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
1164 </control>
1165 </itemlayout>
1166 <focusedlayout height="400" width="200">
1167 <control type="image">
1168 <left>6</left>
1169 <top>1</top>
1170 <height>398</height>
1171 <width>188</width>
1172 <texture colordiffuse="FFFFFFFF" border="10">panel-256-box.png</texture>
1173 <visible>Control.HasFocus(2062)</visible>
1174 </control>
1175 <control type="image">
1176 <left>10</left>
1177 <top>5</top>
1178 <height>390</height>
1179 <width>180</width>
1180 <aspectratio>scale</aspectratio>
1181 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
1182 </control>
1183 </focusedlayout>
1184 </control>
1185 </control>
1186
1187 <!-- More from Networks (2063) -->
1188 <control type="group">
1189 <visible>Integer.IsGreater(Container(2063).NumItems,0)</visible>
1190 <height>460</height>
1191 <control type="label">
1192 <width>600</width>
1193 <height>25</height>
1194 <font>font12</font>
1195 <textcolor>FF888888</textcolor>
1196 <label>More from Networks $INFO[Window.Property(more_from_networks.number)]</label>
1197 </control>
1198 <control type="fixedlist" id="2063">
1199 <top>35</top>
1200 <width>1800</width>
1201 <height>420</height>
1202 <onup>2062</onup>
1203 <ondown>2064</ondown>
1204 <orientation>horizontal</orientation>
1205 <scrolltime tween="sine">400</scrolltime>
1206 <focusposition>0</focusposition>
1207 <movement>3</movement>
1208 <itemlayout height="400" width="200">
1209 <control type="image">
1210 <left>10</left>
1211 <top>5</top>
1212 <height>390</height>
1213 <width>180</width>
1214 <aspectratio>scale</aspectratio>
1215 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
1216 </control>
1217 </itemlayout>
1218 <focusedlayout height="400" width="200">
1219 <control type="image">
1220 <left>6</left>
1221 <top>1</top>
1222 <height>398</height>
1223 <width>188</width>
1224 <texture colordiffuse="FFFFFFFF" border="10">panel-256-box.png</texture>
1225 <visible>Control.HasFocus(2063)</visible>
1226 </control>
1227 <control type="image">
1228 <left>10</left>
1229 <top>5</top>
1230 <height>390</height>
1231 <width>180</width>
1232 <aspectratio>scale</aspectratio>
1233 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
1234 </control>
1235 </focusedlayout>
1236 </control>
1237 </control>
1238
1239 <!-- Collection (2064) -->
1240 <control type="group">
1241 <visible>Integer.IsGreater(Container(2064).NumItems,0)</visible>
1242 <height>460</height>
1243 <control type="label">
1244 <width>600</width>
1245 <height>25</height>
1246 <font>font12</font>
1247 <textcolor>FF888888</textcolor>
1248 <label>Collection $INFO[Window.Property(collection.number)]</label>
1249 </control>
1250 <control type="fixedlist" id="2064">
1251 <top>35</top>
1252 <width>1800</width>
1253 <height>420</height>
1254 <onup>2063</onup>
1255 <orientation>horizontal</orientation>
1256 <scrolltime tween="sine">400</scrolltime>
1257 <focusposition>0</focusposition>
1258 <movement>3</movement>
1259 <itemlayout height="400" width="200">
1260 <control type="image">
1261 <left>10</left>
1262 <top>5</top>
1263 <height>390</height>
1264 <width>180</width>
1265 <aspectratio>scale</aspectratio>
1266 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
1267 </control>
1268 </itemlayout>
1269 <focusedlayout height="400" width="200">
1270 <control type="image">
1271 <left>6</left>
1272 <top>1</top>
1273 <height>398</height>
1274 <width>188</width>
1275 <texture colordiffuse="FFFFFFFF" border="10">panel-256-box.png</texture>
1276 <visible>Control.HasFocus(2064)</visible>
1277 </control>
1278 <control type="image">
1279 <left>10</left>
1280 <top>5</top>
1281 <height>390</height>
1282 <width>180</width>
1283 <aspectratio>scale</aspectratio>
1284 <texture diffuse="poster_w217_h320.png">$INFO[ListItem.Property(thumbnail)]</texture>
1285 </control>
1286 </focusedlayout>
1287 </control>
1288 </control>
1289 </control>
1290 </control>
1291
1292 </control>
1293 <!-- END CONTENT AREA -->
1294 </control>
1295 </controls>
1296</window>
plugin.video.jacktook/resources/skins/Default/1080i/filter_items.xml +108 -0
……@@ -0,0 +1,108 @@
1<window id="1200" type="dialog">
2 <coordinates>
3 <left>635</left> <!-- (1920 - 650)/2 -->
4 <top>215</top> <!-- (1080 - 650)/2 -->
5 <width>650</width>
6 <height>650</height>
7 </coordinates>
8 <defaultcontrol always="true">2000</defaultcontrol>
9 <animation type="WindowOpen" reversible="false">
10 <effect type="fade" start="0" end="100" time="200" />
11 <effect type="zoom" start="80" end="100" center="50%,50%" time="200" />
12 </animation>
13 <animation type="WindowClose" reversible="false">
14 <effect type="fade" start="100" end="0" time="200" />
15 <effect type="zoom" start="100" end="80" center="50%,50%" time="200" />
16 </animation>
17 <depth>0.40</depth>
18
19 <controls>
20 <!-- Background Card -->
21 <control type="image">
22 <left>0</left>
23 <top>0</top>
24 <width>650</width>
25 <height>650</height>
26 <texture border="30">circle.png</texture>
27 <colordiffuse>CC000000</colordiffuse>
28 </control>
29
30 <!-- Title -->
31 <control type="label" id="2100">
32 <left>0</left>
33 <top>30</top>
34 <width>650</width>
35 <height>60</height>
36 <font>font16_title</font>
37 <textcolor>white</textcolor>
38 <align>center</align>
39 <label>Select Filter</label>
40 </control>
41
42 <!-- Filter Items List -->
43 <control type="list" id="2000">
44 <left>25</left>
45 <top>110</top>
46 <width>600</width>
47 <height>510</height>
48 <onleft>2000</onleft>
49 <onright>2000</onright>
50 <onup>2000</onup>
51 <ondown>2000</ondown>
52 <itemgap>20</itemgap>
53
54 <!-- Normal item -->
55 <itemlayout height="70">
56 <control type="group">
57 <width>600</width>
58 <height>70</height>
59 <control type="image">
60 <left>0</left>
61 <top>10</top>
62 <width>600</width>
63 <height>50</height>
64 <texture border="30">circle.png</texture>
65 <colordiffuse>CC222222</colordiffuse>
66 </control>
67 <control type="label">
68 <left>0</left>
69 <top>10</top>
70 <width>600</width>
71 <height>50</height>
72 <font>font14</font>
73 <textcolor>white</textcolor>
74 <align>center</align>
75 <label>$INFO[ListItem.Label]</label>
76 </control>
77 </control>
78 </itemlayout>
79
80 <!-- Focused item -->
81 <focusedlayout height="70">
82 <control type="group">
83 <width>600</width>
84 <height>70</height>
85 <control type="image">
86 <left>0</left>
87 <top>10</top>
88 <width>600</width>
89 <height>50</height>
90 <texture border="30">circle.png</texture>
91 <colordiffuse>FF12A0C7</colordiffuse>
92 </control>
93 <control type="label">
94 <left>0</left>
95 <top>10</top>
96 <width>600</width>
97 <height>50</height>
98 <font>font14</font>
99 <textcolor>white</textcolor>
100 <align>center</align>
101 <label>$INFO[ListItem.Label]</label>
102 </control>
103 </control>
104 </focusedlayout>
105
106 </control>
107 </controls>
108</window>
plugin.video.jacktook/resources/skins/Default/1080i/filter_type.xml +108 -0
……@@ -0,0 +1,108 @@
1<window id="1210" type="dialog">
2 <coordinates>
3 <left>710</left>
4 <top>290</top>
5 <width>500</width>
6 <height>500</height>
7 </coordinates>
8 <defaultcontrol always="true">2000</defaultcontrol>
9 <animation type="WindowOpen" reversible="false">
10 <effect type="fade" start="0" end="100" time="200" />
11 <effect type="zoom" start="80" end="100" center="50%,50%" time="200" />
12 </animation>
13 <animation type="WindowClose" reversible="false">
14 <effect type="fade" start="100" end="0" time="200" />
15 <effect type="zoom" start="100" end="80" center="50%,50%" time="200" />
16 </animation>
17 <depth>0.40</depth>
18
19 <controls>
20 <!-- Background Card -->
21 <control type="image">
22 <left>0</left>
23 <top>0</top>
24 <width>470</width>
25 <height>560</height>
26 <texture border="30">circle.png</texture>
27 <colordiffuse>CC000000</colordiffuse>
28 </control>
29
30 <!-- Title -->
31 <control type="label" id="2100">
32 <left>0</left>
33 <top>40</top>
34 <width>500</width>
35 <height>50</height>
36 <font>font16_title</font>
37 <textcolor>white</textcolor>
38 <align>center</align>
39 <label>Choose Filter</label>
40 </control>
41
42 <!-- Filter Types List -->
43 <control type="list" id="2000">
44 <left>50</left>
45 <top>100</top>
46 <width>400</width>
47 <height>420</height>
48 <onleft>2000</onleft>
49 <onright>2000</onright>
50 <onup>2000</onup>
51 <ondown>2000</ondown>
52 <itemgap>30</itemgap>
53
54 <!-- Normal item -->
55 <itemlayout height="60">
56 <control type="group">
57 <width>400</width>
58 <height>80</height>
59 <control type="image">
60 <left>0</left>
61 <top>10</top>
62 <width>60</width>
63 <height>50</height>
64 <texture>circle.png</texture>
65 <colordiffuse>CC222222</colordiffuse>
66 </control>
67 <control type="label">
68 <left>80</left>
69 <top>10</top>
70 <width>320</width>
71 <height>50</height>
72 <font>font14</font>
73 <textcolor>white</textcolor>
74 <align>left</align>
75 <label>$INFO[ListItem.Label]</label>
76 </control>
77 </control>
78 </itemlayout>
79
80 <!-- Focused item -->
81 <focusedlayout height="60">
82 <control type="group">
83 <width>400</width>
84 <height>60</height>
85 <control type="image">
86 <left>0</left>
87 <top>10</top>
88 <width>60</width>
89 <height>40</height>
90 <texture>circle.png</texture>
91 <colordiffuse>FF12A0C7</colordiffuse>
92 </control>
93 <control type="label">
94 <left>80</left>
95 <top>10</top>
96 <width>320</width>
97 <height>40</height>
98 <font>font14</font>
99 <textcolor>white</textcolor>
100 <align>left</align>
101 <label>$INFO[ListItem.Label]</label>
102 </control>
103 </control>
104 </focusedlayout>
105
106 </control>
107 </controls>
108</window>
plugin.video.jacktook/resources/skins/Default/1080i/playing_next.xml +103 -0
……@@ -0,0 +1,103 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<window>
3 <onload>Dialog.Close(fullscreeninfo,true)</onload>
4 <onload>Dialog.Close(videoosd,true)</onload>
5
6 <defaultcontrol always="true">3001</defaultcontrol>
7
8 <controls>
9 <control type="group">
10 <right>20</right>
11 <bottom>150</bottom>
12 <width>520</width>
13 <height>120</height>
14
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"/>
18 </animation>
19
20 <animation type="WindowClose" reversible="false">
21 <effect type="slide" start="0,0" end="400%,0" time="400" tween="cubic" easing="in"/>
22 </animation>
23
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>
55
56 </control>
57
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>
102 </controls>
103</window>
plugin.video.jacktook/resources/skins/Default/1080i/qr_dialog.xml +93 -0
……@@ -0,0 +1,93 @@
1<window type="dialog" id="12000">
2 <coordinates>
3 <left>480</left>
4 <top>300</top>
5 <width>900</width>
6 <height>450</height>
7 </coordinates>
8 <defaultcontrol always="true">12003</defaultcontrol>
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"/>
12 </animation>
13 <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"/>
16 </animation>
17 <depth>0.40</depth>
18
19 <controls>
20 <!-- Background -->
21 <control type="image">
22 <width>900</width>
23 <height>450</height>
24 <texture border="30">circle.png</texture>
25 <colordiffuse>CC000000</colordiffuse>
26 </control>
27
28 <!-- Title -->
29 <control type="label" id="12001">
30 <top>20</top>
31 <width>880</width>
32 <height>40</height>
33 <align>center</align>
34 <font>font16</font>
35 <textcolor>white</textcolor>
36 <shadowcolor>AA000000</shadowcolor>
37 </control>
38
39 <!-- QR Code -->
40 <control type="image" id="12006">
41 <top>80</top>
42 <left>60</left>
43 <width>220</width>
44 <height>220</height>
45 <texture>qr_placeholder.png</texture>
46 </control>
47
48 <!-- Instructions -->
49 <control type="label" id="12002">
50 <top>80</top>
51 <left>300</left>
52 <width>580</width>
53 <height>220</height>
54 <align>left</align>
55 <font>font14</font>
56 <textcolor>white</textcolor>
57 <shadowcolor>AA000000</shadowcolor>
58 <wrapmultiline>true</wrapmultiline>
59 </control>
60
61 <!-- Progress bar -->
62 <control type="progress" id="12004">
63 <top>320</top>
64 <left>60</left>
65 <width>780</width>
66 <height>30</height>
67 <info>percentage</info>
68 </control>
69
70 <!-- Buttons group -->
71 <control type="grouplist" orientation="horizontal">
72 <orientation>horizontal</orientation>
73 <top>370</top>
74 <left>360</left>
75 <itemgap>120</itemgap>
76
77 <!-- Close Button -->
78 <control type="button" id="12003">
79 <width>140</width>
80 <height>50</height>
81 <label>Close</label>
82 <align>center</align>
83 <font>font14</font>
84 <textcolor>DDFFFFFF</textcolor>
85 <focusedcolor>EEFFFFFF</focusedcolor>
86 <selectedcolor>DDFFFFFF</selectedcolor>
87 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
88 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
89 <pulseonselect>no</pulseonselect>
90 </control>
91 </control>
92 </controls>
93</window>
plugin.video.jacktook/resources/skins/Default/1080i/resolver.xml +106 -0
……@@ -0,0 +1,106 @@
1<window type="dialog">
2 <coordinates>
3 <left>0</left>
4 <top>0</top>
5 <width>1920</width>
6 <height>1080</height>
7 </coordinates>
8
9 <controls>
10 <!-- Background -->
11 <control type="group">
12 <width>1920</width>
13 <height>1080</height>
14
15 <!-- Fallback Jacktook Fanart-->
16 <control type="image">
17 <texture background="true">jtk_fanart.png</texture>
18 <aspectratio scalediffuse="false" align="center" aligny="center">scale</aspectratio>
19 <visible>String.IsEmpty(Window().Property(info.poster))</visible>
20 </control>
21
22 <!-- Fallback Poster -->
23 <control type="image">
24 <texture background="true">$INFO[Window().Property(info.poster)]</texture>
25 <aspectratio scalediffuse="false" align="center" aligny="center">scale</aspectratio>
26 <visible>!String.IsEmpty(Window().Property(info.poster))</visible>
27 </control>
28
29 <!-- Fanart -->
30 <control type="image">
31 <texture background="true">$INFO[Window().Property(info.fanart)]</texture>
32 <aspectratio scalediffuse="false" align="center" aligny="center">scale</aspectratio>
33 <visible>!String.IsEmpty(Window().Property(info.fanart))</visible>
34 </control>
35
36 <!-- Dark Overlay -->
37 <control type="image">
38 <texture background="true">white.png</texture>
39 <colordiffuse>CC000000</colordiffuse>
40 </control>
41 </control>
42
43 <!-- === CLEARLOGO (top of stack) === -->
44 <control type="image">
45 <centerleft>960</centerleft> <!-- 1920 / 2 -->
46 <centertop>340</centertop> <!-- Center vertically -200px offset -->
47 <width>400</width>
48 <height>100</height>
49 <texture>$INFO[Window().Property(info.clearlogo)]</texture>
50 <visible>!String.IsEmpty(Window().Property(info.clearlogo))</visible>
51 </control>
52
53 <!-- Fallback Clearlogo -->
54 <control type="image">
55 <centerleft>960</centerleft>
56 <centertop>340</centertop>
57 <width>400</width>
58 <height>100</height>
59 <aspectratio align="center" aligny="center">scale</aspectratio>
60 <texture>jtk_clearlogo.png</texture>
61 <visible>String.IsEmpty(Window().Property(info.clearlogo))</visible>
62 </control>
63
64 <!-- === PLOT TEXT (middle) === -->
65 <control type="label">
66 <centerleft>960</centerleft>
67 <centertop>580</centertop> <!-- Middle of screen -->
68 <width>700</width>
69 <height>200</height>
70 <align>center</align>
71 <aligny>center</aligny>
72 <wrapmultiline>true</wrapmultiline>
73 <truncate>true</truncate>
74 <font>font14</font>
75 <textcolor>FFDDDDDD</textcolor>
76 <label>$INFO[Window().Property(info.overview)]</label>
77 </control>
78
79 <!-- === SPINNER (bottom of stack) === -->
80 <control type="group">
81 <centerleft>960</centerleft>
82 <centertop>840</centertop> <!-- 200px below the plot -->
83 <width>150</width>
84 <height>150</height>
85
86 <!-- Outer Spinner -->
87 <control type="image">
88 <visible>String.IsEqual(Window.Property(enable_busy_spinner),true)</visible>
89 <width>150</width>
90 <height>150</height>
91 <texture flipx="true" colordiffuse="66FAFAFA">spinner.png</texture>
92 <animation effect="rotate" end="360" center="auto" time="2400" loop="true" reversible="false" condition="true">OuterRotation</animation>
93 </control>
94
95 <!-- Inner Spinner -->
96 <control type="image">
97 <width>100</width>
98 <height>100</height>
99 <top>25</top>
100 <left>25</left>
101 <texture flipx="true" colordiffuse="66FAFAFA">spinner.png</texture>
102 <animation effect="rotate" end="-360" center="auto" time="2400" loop="true" reversible="false" condition="true">InnerRotation</animation>
103 </control>
104 </control>
105 </controls>
106</window>
plugin.video.jacktook/resources/skins/Default/1080i/resume_dialog.xml +46 -0
……@@ -0,0 +1,46 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<window>
3 <defaultcontrol always="true">1002</defaultcontrol>
4 <controls>
5 <control type="group">
6 <left>400</left>
7 <bottom>500</bottom>
8 <width>520</width>
9 <height>220</height>
10
11 <!-- Buttons -->
12 <control type="grouplist">
13 <orientation>vertical</orientation>
14 <height>150</height>
15 <itemgap>15</itemgap>
16 <align>right</align>
17
18 <control type="button" id="1002">
19 <height>56</height>
20 <width>auto</width>
21 <font>font12</font>
22 <textoffsetx>20</textoffsetx>
23 <textcolor>ddffffff</textcolor>
24 <focusedcolor>eeffffff</focusedcolor>
25 <selectedcolor>ddffffff</selectedcolor>
26 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
27 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
28 <pulseonselect>no</pulseonselect>
29 </control>
30
31 <control type="button" id="1003">
32 <height>56</height>
33 <width>auto</width>
34 <font>font12</font>
35 <textoffsetx>20</textoffsetx>
36 <textcolor>DDFFFFFF</textcolor>
37 <focusedcolor>EEFFFFFF</focusedcolor>
38 <selectedcolor>DDFFFFFF</selectedcolor>
39 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
40 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
41 <pulseonselect>no</pulseonselect>
42 </control>
43 </control>
44 </control>
45 </controls>
46</window>
plugin.video.jacktook/resources/skins/Default/1080i/search_status.xml +441 -0
……@@ -0,0 +1,441 @@
1<window type="dialog" id="13000">
2 <coordinates>
3 <left>0</left>
4 <top>0</top>
5 <width>1920</width>
6 <height>1080</height>
7 </coordinates>
8 <defaultcontrol always="true">13004</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 <!-- Dark tint over background -->
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>99000000</colordiffuse>
33 </control>
34
35 <control type="group">
36 <left>460</left>
37 <top>120</top>
38 <width>1000</width>
39 <height>840</height>
40
41 <!-- Clearlogo -->
42 <control type="image">
43 <top>0</top>
44 <width>1000</width>
45 <height>150</height>
46 <texture>$INFO[Window.Property(info.clearlogo)]</texture>
47 <aspectratio align="center" aligny="center">keep</aspectratio>
48 <visible>!String.IsEmpty(Window.Property(info.clearlogo))</visible>
49 </control>
50 <!-- Title fallback (no clearlogo) -->
51 <control type="label" id="13001">
52 <top>50</top>
53 <width>1000</width>
54 <height>50</height>
55 <align>center</align>
56 <font>font30_title</font>
57 <textcolor>white</textcolor>
58 <shadowcolor>AA000000</shadowcolor>
59 <label></label>
60 <visible>String.IsEmpty(Window.Property(info.clearlogo))</visible>
61 </control>
62
63 <!-- Results Count -->
64 <control type="label">
65 <top>160</top>
66 <width>1000</width>
67 <height>30</height>
68 <align>center</align>
69 <font>font13</font>
70 <textcolor>EEEEEE</textcolor>
71 <label>$INFO[Window.Property(search_results_count)]</label>
72 </control>
73
74 <!-- Task List Area Background -->
75 <control type="image">
76 <top>210</top>
77 <width>1000</width>
78 <height>420</height>
79 <texture border="20">circle.png</texture>
80 <colordiffuse>33000000</colordiffuse>
81 </control>
82
83 <!-- Task Rows (up to 10 slots, each 40px high) -->
84 <!-- Task 0 -->
85 <control type="group">
86 <top>220</top>
87 <left>30</left>
88 <width>940</width>
89 <height>40</height>
90 <visible>!String.IsEmpty(Window.Property(task_0_name))</visible>
91 <control type="label">
92 <width>350</width>
93 <height>40</height>
94 <font>font13</font>
95 <textcolor>white</textcolor>
96 <label>$INFO[Window.Property(task_0_name)]</label>
97 </control>
98 <control type="label">
99 <left>370</left>
100 <width>350</width>
101 <height>40</height>
102 <font>font13</font>
103 <textcolor>$INFO[Window.Property(task_0_color)]</textcolor>
104 <label>$INFO[Window.Property(task_0_status)]</label>
105 </control>
106 <control type="label">
107 <left>750</left>
108 <width>180</width>
109 <height>40</height>
110 <font>font13</font>
111 <align>right</align>
112 <textcolor>white</textcolor>
113 <label>$INFO[Window.Property(task_0_results)]</label>
114 </control>
115 </control>
116
117 <!-- Task 1 -->
118 <control type="group">
119 <top>260</top>
120 <left>30</left>
121 <width>940</width>
122 <height>40</height>
123 <visible>!String.IsEmpty(Window.Property(task_1_name))</visible>
124 <control type="label">
125 <width>350</width>
126 <height>40</height>
127 <font>font13</font>
128 <textcolor>white</textcolor>
129 <label>$INFO[Window.Property(task_1_name)]</label>
130 </control>
131 <control type="label">
132 <left>370</left>
133 <width>350</width>
134 <height>40</height>
135 <font>font13</font>
136 <textcolor>$INFO[Window.Property(task_1_color)]</textcolor>
137 <label>$INFO[Window.Property(task_1_status)]</label>
138 </control>
139 <control type="label">
140 <left>750</left>
141 <width>180</width>
142 <height>40</height>
143 <font>font13</font>
144 <align>right</align>
145 <textcolor>white</textcolor>
146 <label>$INFO[Window.Property(task_1_results)]</label>
147 </control>
148 </control>
149
150 <!-- Task 2 -->
151 <control type="group">
152 <top>300</top>
153 <left>30</left>
154 <width>940</width>
155 <height>40</height>
156 <visible>!String.IsEmpty(Window.Property(task_2_name))</visible>
157 <control type="label">
158 <width>350</width>
159 <height>40</height>
160 <font>font13</font>
161 <textcolor>white</textcolor>
162 <label>$INFO[Window.Property(task_2_name)]</label>
163 </control>
164 <control type="label">
165 <left>370</left>
166 <width>350</width>
167 <height>40</height>
168 <font>font13</font>
169 <textcolor>$INFO[Window.Property(task_2_color)]</textcolor>
170 <label>$INFO[Window.Property(task_2_status)]</label>
171 </control>
172 <control type="label">
173 <left>750</left>
174 <width>180</width>
175 <height>40</height>
176 <font>font13</font>
177 <align>right</align>
178 <textcolor>white</textcolor>
179 <label>$INFO[Window.Property(task_2_results)]</label>
180 </control>
181 </control>
182
183 <!-- Task 3 -->
184 <control type="group">
185 <top>340</top>
186 <left>30</left>
187 <width>940</width>
188 <height>40</height>
189 <visible>!String.IsEmpty(Window.Property(task_3_name))</visible>
190 <control type="label">
191 <width>350</width>
192 <height>40</height>
193 <font>font13</font>
194 <textcolor>white</textcolor>
195 <label>$INFO[Window.Property(task_3_name)]</label>
196 </control>
197 <control type="label">
198 <left>370</left>
199 <width>350</width>
200 <height>40</height>
201 <font>font13</font>
202 <textcolor>$INFO[Window.Property(task_3_color)]</textcolor>
203 <label>$INFO[Window.Property(task_3_status)]</label>
204 </control>
205 <control type="label">
206 <left>750</left>
207 <width>180</width>
208 <height>40</height>
209 <font>font13</font>
210 <align>right</align>
211 <textcolor>white</textcolor>
212 <label>$INFO[Window.Property(task_3_results)]</label>
213 </control>
214 </control>
215
216 <!-- Task 4 -->
217 <control type="group">
218 <top>380</top>
219 <left>30</left>
220 <width>940</width>
221 <height>40</height>
222 <visible>!String.IsEmpty(Window.Property(task_4_name))</visible>
223 <control type="label">
224 <width>350</width>
225 <height>40</height>
226 <font>font13</font>
227 <textcolor>white</textcolor>
228 <label>$INFO[Window.Property(task_4_name)]</label>
229 </control>
230 <control type="label">
231 <left>370</left>
232 <width>350</width>
233 <height>40</height>
234 <font>font13</font>
235 <textcolor>$INFO[Window.Property(task_4_color)]</textcolor>
236 <label>$INFO[Window.Property(task_4_status)]</label>
237 </control>
238 <control type="label">
239 <left>750</left>
240 <width>180</width>
241 <height>40</height>
242 <font>font13</font>
243 <align>right</align>
244 <textcolor>white</textcolor>
245 <label>$INFO[Window.Property(task_4_results)]</label>
246 </control>
247 </control>
248
249 <!-- Task 5 -->
250 <control type="group">
251 <top>420</top>
252 <left>30</left>
253 <width>940</width>
254 <height>40</height>
255 <visible>!String.IsEmpty(Window.Property(task_5_name))</visible>
256 <control type="label">
257 <width>350</width>
258 <height>40</height>
259 <font>font13</font>
260 <textcolor>white</textcolor>
261 <label>$INFO[Window.Property(task_5_name)]</label>
262 </control>
263 <control type="label">
264 <left>370</left>
265 <width>350</width>
266 <height>40</height>
267 <font>font13</font>
268 <textcolor>$INFO[Window.Property(task_5_color)]</textcolor>
269 <label>$INFO[Window.Property(task_5_status)]</label>
270 </control>
271 <control type="label">
272 <left>750</left>
273 <width>180</width>
274 <height>40</height>
275 <font>font13</font>
276 <align>right</align>
277 <textcolor>white</textcolor>
278 <label>$INFO[Window.Property(task_5_results)]</label>
279 </control>
280 </control>
281
282 <!-- Task 6 -->
283 <control type="group">
284 <top>460</top>
285 <left>30</left>
286 <width>940</width>
287 <height>40</height>
288 <visible>!String.IsEmpty(Window.Property(task_6_name))</visible>
289 <control type="label">
290 <width>350</width>
291 <height>40</height>
292 <font>font13</font>
293 <textcolor>white</textcolor>
294 <label>$INFO[Window.Property(task_6_name)]</label>
295 </control>
296 <control type="label">
297 <left>370</left>
298 <width>350</width>
299 <height>40</height>
300 <font>font13</font>
301 <textcolor>$INFO[Window.Property(task_6_color)]</textcolor>
302 <label>$INFO[Window.Property(task_6_status)]</label>
303 </control>
304 <control type="label">
305 <left>750</left>
306 <width>180</width>
307 <height>40</height>
308 <font>font13</font>
309 <align>right</align>
310 <textcolor>white</textcolor>
311 <label>$INFO[Window.Property(task_6_results)]</label>
312 </control>
313 </control>
314
315 <!-- Task 7 -->
316 <control type="group">
317 <top>500</top>
318 <left>30</left>
319 <width>940</width>
320 <height>40</height>
321 <visible>!String.IsEmpty(Window.Property(task_7_name))</visible>
322 <control type="label">
323 <width>350</width>
324 <height>40</height>
325 <font>font13</font>
326 <textcolor>white</textcolor>
327 <label>$INFO[Window.Property(task_7_name)]</label>
328 </control>
329 <control type="label">
330 <left>370</left>
331 <width>350</width>
332 <height>40</height>
333 <font>font13</font>
334 <textcolor>$INFO[Window.Property(task_7_color)]</textcolor>
335 <label>$INFO[Window.Property(task_7_status)]</label>
336 </control>
337 <control type="label">
338 <left>750</left>
339 <width>180</width>
340 <height>40</height>
341 <font>font13</font>
342 <align>right</align>
343 <textcolor>white</textcolor>
344 <label>$INFO[Window.Property(task_7_results)]</label>
345 </control>
346 </control>
347
348 <!-- Task 8 -->
349 <control type="group">
350 <top>540</top>
351 <left>30</left>
352 <width>940</width>
353 <height>40</height>
354 <visible>!String.IsEmpty(Window.Property(task_8_name))</visible>
355 <control type="label">
356 <width>350</width>
357 <height>40</height>
358 <font>font13</font>
359 <textcolor>white</textcolor>
360 <label>$INFO[Window.Property(task_8_name)]</label>
361 </control>
362 <control type="label">
363 <left>370</left>
364 <width>350</width>
365 <height>40</height>
366 <font>font13</font>
367 <textcolor>$INFO[Window.Property(task_8_color)]</textcolor>
368 <label>$INFO[Window.Property(task_8_status)]</label>
369 </control>
370 <control type="label">
371 <left>750</left>
372 <width>180</width>
373 <height>40</height>
374 <font>font13</font>
375 <align>right</align>
376 <textcolor>white</textcolor>
377 <label>$INFO[Window.Property(task_8_results)]</label>
378 </control>
379 </control>
380
381 <!-- Task 9 -->
382 <control type="group">
383 <top>580</top>
384 <left>30</left>
385 <width>940</width>
386 <height>40</height>
387 <visible>!String.IsEmpty(Window.Property(task_9_name))</visible>
388 <control type="label">
389 <width>350</width>
390 <height>40</height>
391 <font>font13</font>
392 <textcolor>white</textcolor>
393 <label>$INFO[Window.Property(task_9_name)]</label>
394 </control>
395 <control type="label">
396 <left>370</left>
397 <width>350</width>
398 <height>40</height>
399 <font>font13</font>
400 <textcolor>$INFO[Window.Property(task_9_color)]</textcolor>
401 <label>$INFO[Window.Property(task_9_status)]</label>
402 </control>
403 <control type="label">
404 <left>750</left>
405 <width>180</width>
406 <height>40</height>
407 <font>font13</font>
408 <align>right</align>
409 <textcolor>white</textcolor>
410 <label>$INFO[Window.Property(task_9_results)]</label>
411 </control>
412 </control>
413
414 <!-- Progress Bar -->
415 <control type="progress" id="13002">
416 <top>660</top>
417 <left>50</left>
418 <width>900</width>
419 <height>15</height>
420 <info>percentage</info>
421 </control>
422
423 <!-- Cancel Button -->
424 <control type="button" id="13004">
425 <top>710</top>
426 <left>250</left>
427 <width>500</width>
428 <height>60</height>
429 <label>$ADDON[plugin.video.jacktook 90242]</label>
430 <align>center</align>
431 <font>font13</font>
432 <textcolor>DDFFFFFF</textcolor>
433 <focusedcolor>EEFFFFFF</focusedcolor>
434 <selectedcolor>DDFFFFFF</selectedcolor>
435 <texturefocus border="25" colordiffuse="FF999999">circle.png</texturefocus>
436 <texturenofocus border="25" colordiffuse="99000000">circle.png</texturenofocus>
437 <pulseonselect>no</pulseonselect>
438 </control>
439 </control>
440 </controls>
441</window>
plugin.video.jacktook/resources/skins/Default/1080i/skip_intro.xml +54 -0
……@@ -0,0 +1,54 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<window>
3 <onload>Dialog.Close(fullscreeninfo,true)</onload>
4 <onload>Dialog.Close(videoosd,true)</onload>
5
6 <defaultcontrol always="true">4001</defaultcontrol>
7
8 <controls>
9 <control type="group">
10 <right>40</right>
11 <bottom>120</bottom>
12 <width>300</width>
13 <height>60</height>
14
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="out"/>
18 </animation>
19
20 <animation type="WindowClose" reversible="false">
21 <effect type="fade" start="100" end="0" time="300"/>
22 <effect type="slide" start="0,0" end="400,0" time="400" tween="cubic" easing="in"/>
23 </animation>
24
25 <!-- Background with blue fill and white border -->
26 <control type="image">
27 <width>300</width>
28 <height>60</height>
29 <texture border="5" colordiffuse="DD1565C0">white.png</texture>
30 </control>
31 <control type="image">
32 <width>300</width>
33 <height>60</height>
34 <texture border="2" colordiffuse="EEFFFFFF">white-border.png</texture>
35 </control>
36
37 <!-- Skip Button -->
38 <control type="button" id="4001">
39 <width>300</width>
40 <height>60</height>
41 <label>$INFO[Window.Property(skip_label)]</label>
42 <font>font13_title</font>
43 <textcolor>FFFFFFFF</textcolor>
44 <focusedcolor>FFFFFFFF</focusedcolor>
45 <selectedcolor>FFFFFFFF</selectedcolor>
46 <align>center</align>
47 <aligny>center</aligny>
48 <texturefocus border="5" colordiffuse="FF1976D2">white.png</texturefocus>
49 <texturenofocus>-</texturenofocus>
50 <pulseonselect>no</pulseonselect>
51 </control>
52 </control>
53 </controls>
54</window>
plugin.video.jacktook/resources/skins/Default/1080i/source_pack_select.xml +451 -0
……@@ -0,0 +1,451 @@
1<window type="dialog">
2 <coordinates>
3 <left>0</left>
4 <top>0</top>
5 <width>1920</width>
6 <height>1080</height>
7 </coordinates>
8 <controls>
9 <!-- Background -->
10 <control type="group">
11 <top>0</top>
12 <left>0</left>
13 <width>1920</width>
14 <height>1080</height>
15
16 <!-- Fallback -->
17 <control type="image">
18 <texture>white.png</texture>
19 <colordiffuse>8000000</colordiffuse>
20 <aspectratio scalediffuse="false" align="center" aligny="center">scale</aspectratio>
21 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
22 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
23 </control>
24
25 <!-- Fanart -->
26 <control type="image">
27 <texture background="true">$INFO[Window().Property(info.fanart)]</texture>
28 <colordiffuse>FFFFFFFF</colordiffuse>
29 <aspectratio scalediffuse="false" align="center" aligny="center">scale</aspectratio>
30 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
31 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
32 </control>
33
34 <!-- Overlay -->
35 <control type="image">
36 <texture background="true">white.png</texture>
37 <colordiffuse>CC000000</colordiffuse>
38 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
39 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
40 </control>
41 </control>
42
43 <!-- Clearlogo -->
44 <control type="image">
45 <animation type="WindowOpen">
46 <effect type="slide" start="841,401" time="350" tween="cubic" easing="in"/>
47 <effect type="zoom" start="140,140" time="350" center="auto" tween="cubic" easing="in"/>
48 </animation>
49 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(instant_close),true)">WindowClose</animation>
50 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
51 <animation type="Conditional" condition="!String.IsEqual(Window().Property(resolving),true)">
52 <effect type="slide" start="841,401" time="350" delay="350" tween="cubic" easing="in"/>
53 <effect type="zoom" start="140,140" time="350" delay="350" center="auto" tween="cubic" easing="in"/>
54 </animation>
55 <top>0</top>
56 <left>20</left>
57 <width>200</width>
58 <height>150</height>
59 <align>left</align>
60 <aspectratio>keep</aspectratio>
61 <texture>jtk_clearlogo.png</texture>
62 <colordiffuse>CCFFFFFF</colordiffuse>
63 <visible>!String.IsEmpty(Window().Property(info.clearlogo)) + !String.IsEqual(Window().Property(instant_close),true)</visible>
64 </control>
65
66 <!-- Scrollbar -->
67 <control type="scrollbar" id="1111">
68 <animation effect="fade" time="350" tween="cubic" easing="in" delay="350">WindowOpen</animation>
69 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(instant_close),true)">WindowClose</animation>
70 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
71 <animation effect="fade" time="350" delay="700" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
72 <left>1366</left>
73 <top>140</top>
74 <width>20</width>
75 <height>890</height>
76 <onleft>1000</onleft>
77 <showonepage>false</showonepage>
78 <texturesliderbackground colordiffuse="99362e33">white.png</texturesliderbackground>
79 <texturesliderbar colordiffuse="66FFFFFF">white.png</texturesliderbar>
80 <texturesliderbarfocus colordiffuse="$INFO[Window().Property(settings.color)]">white.png</texturesliderbarfocus>
81 <visible>String.IsEqual(Window().Property(instant_close),false)</visible>
82 </control>
83
84 <!-- Sources List -->
85 <control type="list" id="1000">
86 <animation effect="slide" start="-1400,0" time="350" tween="cubic" easing="in">WindowOpen</animation>
87 <animation effect="slide" end="-1400,0" time="350" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(instant_close),true)">WindowClose</animation>
88 <animation effect="slide" end="-1400,0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
89 <animation effect="slide" start="-1400,0" time="350" delay="350" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
90 <viewtype label="Sources">list</viewtype>
91 <pagecontrol>1111</pagecontrol>
92 <onup>1300</onup>
93 <onright>1111</onright>
94 <left>100</left>
95 <top>130</top>
96 <width>1340</width>
97 <height>900</height>
98 <itemgap>20</itemgap>
99 <orientation>vertical</orientation>
100 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
101
102 <!-- Unfocused Source -->
103 <itemlayout height="200">
104 <control type="group">
105 <width>1250</width>
106
107 <control type="image">
108 <top>10</top>
109 <width>200</width>
110 <texture border="30">left-circle.png</texture>
111 <colordiffuse>FF00559D</colordiffuse>
112 </control>
113
114 <!-- Card with Rounded Corners -->
115 <control type="image">
116 <top>10</top>
117 <texture border="30">circle.png</texture>
118 <colordiffuse>FF362e33</colordiffuse>
119 </control>
120
121 <!-- Resolution -->
122 <control type="label">
123 <font>font30</font>
124 <top>70</top>
125 <height>20</height>
126 <align>center</align>
127 <width>200</width>
128 <textcolor>66FFFFFF</textcolor>
129 <label>[UPPERCASE]$INFO[ListItem.Property(quality)][/UPPERCASE]</label>
130 </control>
131
132 <!-- Type -->
133 <control type="label">
134 <top>120</top>
135 <align>center</align>
136 <height>20</height>
137 <width>200</width>
138 <font>font20</font>
139 <textcolor>66FFFFFF</textcolor>
140 <align>left</align>
141 <label>$INFO[ListItem.Property(type)]</label>
142 </control>
143
144 <!-- Release Title -->
145 <control type="label">
146 <top>30</top>
147 <left>220</left>
148 <width>1000</width>
149 <height>20</height>
150 <font>font12</font>
151 <textcolor>FFFFFFFF</textcolor>
152 <align>left</align>
153 <label>[B]$INFO[ListItem.Property(title)][/B]</label>
154 </control>
155
156 <control type="group">
157 <top>40</top>
158 <left>20</left>
159
160 <control type="group">
161 <top>30</top>
162
163 <!-- Size -->
164 <control type="label">
165 <height>20</height>
166 <font>font12</font>
167 <textcolor>CCFFFFFF</textcolor>
168 <align>left</align>
169 <left>200</left>
170 <label>[UPPERCASE]SIZE: $INFO[ListItem.Property(size)][/UPPERCASE]</label>
171 <visible>!String.IsEmpty(ListItem.Property(size))</visible>
172
173 </control>
174
175 <!-- Seeders -->
176 <control type="label">
177 <right>20</right>
178 <height>20</height>
179 <font>font12</font>
180 <align>left</align>
181 <left>550</left>
182 <textcolor>CCFFFFFF</textcolor>
183 <label>[UPPERCASE]SEEDERS: $INFO[ListItem.Property(seeders)][/UPPERCASE]</label>
184 <visible>!String.IsEmpty(ListItem.Property(seeders))</visible>
185 </control>
186
187 </control>
188
189 <control type="group">
190 <top>70</top>
191
192 <!-- Indexer -->
193 <control type="label">
194 <width>330</width>
195 <height>20</height>
196 <font>font12</font>
197 <left>200</left>
198 <textcolor>CCFFFFFF</textcolor>
199 <align>left</align>
200 <label>[UPPERCASE]INDEXER: $INFO[ListItem.Property(indexer)][/UPPERCASE]</label>
201 <visible>!String.IsEmpty(ListItem.Property(indexer))</visible>
202 </control>
203
204 <!-- Provider -->
205 <control type="label">
206 <height>20</height>
207 <font>font12</font>
208 <align>left</align>
209 <left>550</left>
210 <textcolor>CCFFFFFF</textcolor>
211 <label>[UPPERCASE]PROVIDER: $INFO[ListItem.Property(provider)][/UPPERCASE]</label>
212 <visible>!String.IsEmpty(ListItem.Property(provider))</visible>
213 </control>
214
215 <!-- PublishDate -->
216 <control type="label">
217 <height>20</height>
218 <font>font12</font>
219 <align>left</align>
220 <left>900</left>
221 <textcolor>CCFFFFFF</textcolor>
222 <label>[UPPERCASE]DATE: $INFO[ListItem.Property(publishDate)][/UPPERCASE]</label>
223 <visible>!String.IsEmpty(ListItem.Property(publishDate))</visible>
224 </control>
225
226 </control>
227 <control type="group">
228 <top>110</top>
229
230 <!-- Status -->
231 <control type="label">
232 <height>20</height>
233 <font>font12</font>
234 <align>left</align>
235 <left>200</left>
236 <textcolor>FFFFFFFF</textcolor>
237 <label>[UPPERCASE]STATUS: [/UPPERCASE]$INFO[ListItem.Property(status)]</label>
238 <visible>!String.IsEmpty(ListItem.Property(status))</visible>
239 </control>
240
241 <!-- Languages -->
242 <control type="label">
243 <height>20</height>
244 <font>font12</font>
245 <align>left</align>
246 <left>550</left>
247 <textcolor>FFFFFFFF</textcolor>
248 <label>[UPPERCASE]LANGUAGES: $INFO[ListItem.Property(fullLanguages)][/UPPERCASE]</label>
249 <visible>!String.IsEmpty(ListItem.Property(fullLanguages))</visible>
250 </control>
251 </control>
252 </control>
253 </control>
254 </itemlayout>
255
256
257 <!-- Focused Source -->
258 <focusedlayout height="200">
259 <control type="group">
260 <width>1250</width>
261
262 <control type="image">
263 <top>10</top>
264 <width>200</width>
265 <texture border="30">left-circle.png</texture>
266 <colordiffuse>FF00559D</colordiffuse>
267 </control>
268
269 <!-- Card with Rounded Corners -->
270 <control type="image">
271 <top>10</top>
272 <texture border="30">circle.png</texture>
273 <colordiffuse>992A3E5C</colordiffuse>
274 </control>
275
276 <!-- Resolution -->
277 <control type="label">
278 <font>font30</font>
279 <top>70</top>
280 <height>20</height>
281 <align>center</align>
282 <width>200</width>
283 <textcolor>FFFFFFFF</textcolor>
284 <label>[UPPERCASE]$INFO[ListItem.Property(quality)][/UPPERCASE]</label>
285 </control>
286
287 <!-- Type -->
288 <control type="label">
289 <top>120</top>
290 <height>20</height>
291 <align>center</align>
292 <width>200</width>
293 <font>font20</font>
294 <textcolor>FFFFFFFF</textcolor>
295 <align>left</align>
296 <label>$INFO[ListItem.Property(type)]</label>
297 </control>
298
299 <!-- Release Title -->
300 <control type="label">
301 <top>30</top>
302 <left>220</left>
303 <width>1000</width>
304 <height>20</height>
305 <font>font12</font>
306 <textcolor>FFFFFFFF</textcolor>
307 <align>left</align>
308 <label>[B]$INFO[ListItem.Property(title)][/B]</label>
309 <scroll>true</scroll>
310 </control>
311
312 <control type="group">
313 <top>40</top>
314 <left>20</left>
315
316 <control type="group">
317 <top>30</top>
318
319 <!-- Size -->
320 <control type="label">
321 <height>20</height>
322 <font>font12</font>
323 <textcolor>FFFFFFFF</textcolor>
324 <align>left</align>
325 <left>200</left>
326 <label>[UPPERCASE]SIZE: $INFO[ListItem.Property(size)][/UPPERCASE]</label>
327 <visible>!String.IsEmpty(ListItem.Property(size))</visible>
328 </control>
329
330 <!-- Seeders -->
331 <control type="label">
332 <right>20</right>
333 <height>20</height>
334 <font>font12</font>
335 <align>left</align>
336 <left>550</left>
337 <textcolor>FFFFFFFF</textcolor>
338 <label>[UPPERCASE]SEEDERS: $INFO[ListItem.Property(seeders)][/UPPERCASE]</label>
339 <visible>!String.IsEmpty(ListItem.Property(seeders))</visible>
340 </control>
341
342 </control>
343
344 <control type="group">
345 <top>70</top>
346
347 <!-- Indexer -->
348 <control type="label">
349 <width>330</width>
350 <height>20</height>
351 <font>font12</font>
352 <left>200</left>
353 <textcolor>FFFFFFFF</textcolor>
354 <align>left</align>
355 <label>[UPPERCASE]INDEXER: $INFO[ListItem.Property(indexer)][/UPPERCASE]</label>
356 <visible>!String.IsEmpty(ListItem.Property(indexer))</visible>
357 </control>
358
359 <!-- Provider -->
360 <control type="label">
361 <height>20</height>
362 <font>font12</font>
363 <align>left</align>
364 <left>550</left>
365 <textcolor>FFFFFFFF</textcolor>
366 <label>[UPPERCASE]PROVIDER: $INFO[ListItem.Property(provider)][/UPPERCASE]</label>
367 <visible>!String.IsEmpty(ListItem.Property(provider))</visible>
368 </control>
369
370 <!-- PublishDate -->
371 <control type="label">
372 <height>20</height>
373 <font>font12</font>
374 <align>left</align>
375 <left>900</left>
376 <textcolor>CCFFFFFF</textcolor>
377 <label>[UPPERCASE]DATE: $INFO[ListItem.Property(publishDate)][/UPPERCASE]</label>
378 <visible>!String.IsEmpty(ListItem.Property(publishDate))</visible>
379 </control>
380 </control>
381 <control type="group">
382 <top>110</top>
383
384 <!-- Status -->
385 <control type="label">
386 <height>20</height>
387 <font>font12</font>
388 <align>left</align>
389 <left>200</left>
390 <textcolor>FFFFFFFF</textcolor>
391 <label>[UPPERCASE]STATUS: [/UPPERCASE]$INFO[ListItem.Property(status)]</label>
392 <visible>!String.IsEmpty(ListItem.Property(status))</visible>
393 </control>
394
395 <!-- Languages -->
396 <control type="label">
397 <height>20</height>
398 <font>font12</font>
399 <align>left</align>
400 <left>550</left>
401 <textcolor>FFFFFFFF</textcolor>
402 <label>[UPPERCASE]LANGUAGES: $INFO[ListItem.Property(fullLanguages)][/UPPERCASE]</label>
403 <visible>!String.IsEmpty(ListItem.Property(fullLanguages))</visible>
404 </control>
405 </control>
406 </control>
407 </control>
408 </focusedlayout>
409 </control>
410
411 <!-- Side Info -->
412 <control type="group">
413 <animation effect="slide" start="600,0" time="350" tween="cubic" easing="in">WindowOpen</animation>
414 <animation effect="slide" end="600,0" time="350" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(instant_close),true)">WindowClose</animation>
415 <animation effect="slide" end="600,0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
416 <animation effect="slide" start="600,0" delay="350" time="350" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
417 <top>100</top>
418 <right>70</right>
419 <width>500</width>
420 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
421
422 <!-- Poster -->
423 <control type="image">
424 <aspectratio align="center" aligny="center">keep</aspectratio>
425 <top>20</top>
426 <left>20</left>
427 <height>600</height>
428 <width>500</width>
429 <align>center</align>
430 <aligny>top</aligny>
431 <texture>$INFO[Window.Property(info.poster)]</texture>
432 <visible>!String.IsEmpty(Window.Property(info.poster))</visible>
433 </control>
434
435 <!-- Plot -->
436 <control type="label">
437 <wrapmultiline>true</wrapmultiline>
438 <top>650</top>
439 <left>60</left>
440 <height>200</height>
441 <width>460</width>
442 <font>font12</font>
443 <textcolor>FFFFFFFF</textcolor>
444 <align>left</align>
445 <aligny>top</aligny>
446 <label>$INFO[Window().Property(info.overview)]</label>
447 <visible>!String.IsEmpty(Window.Property(info.overview))</visible>
448 </control>
449 </control>
450 </controls>
451</window>
plugin.video.jacktook/resources/skins/Default/1080i/source_select.xml +456 -0
……@@ -0,0 +1,456 @@
1<window type="dialog">
2 <coordinates>
3 <left>0</left>
4 <top>0</top>
5 <width>1920</width>
6 <height>1080</height>
7 </coordinates>
8 <controls>
9 <!-- Background -->
10 <control type="group">
11 <top>0</top>
12 <left>0</left>
13 <width>1920</width>
14 <height>1080</height>
15
16 <!-- Fallback -->
17 <control type="image">
18 <texture>white.png</texture>
19 <colordiffuse>8000000</colordiffuse>
20 <aspectratio scalediffuse="false" align="center" aligny="center">scale</aspectratio>
21 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
22 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
23 </control>
24
25 <!-- Fanart -->
26 <control type="image">
27 <texture background="true">$INFO[Window().Property(info.fanart)]</texture>
28 <colordiffuse>FFFFFFFF</colordiffuse>
29 <aspectratio scalediffuse="false" align="center" aligny="center">scale</aspectratio>
30 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
31 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
32 </control>
33
34 <!-- Overlay -->
35 <control type="image">
36 <texture background="true">white.png</texture>
37 <colordiffuse>CC000000</colordiffuse>
38 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
39 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
40 </control>
41 </control>
42
43 <!-- Clearlogo -->
44 <control type="image">
45 <animation type="WindowOpen">
46 <effect type="slide" start="841,401" time="350" tween="cubic" easing="in"/>
47 <effect type="zoom" start="140,140" time="350" center="auto" tween="cubic" easing="in"/>
48 </animation>
49 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(instant_close),true)">WindowClose</animation>
50 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
51 <animation type="Conditional" condition="!String.IsEqual(Window().Property(resolving),true)">
52 <effect type="slide" start="841,401" time="350" delay="350" tween="cubic" easing="in"/>
53 <effect type="zoom" start="140,140" time="350" delay="350" center="auto" tween="cubic" easing="in"/>
54 </animation>
55 <top>0</top>
56 <left>20</left>
57 <width>200</width>
58 <height>150</height>
59 <align>left</align>
60 <aspectratio>keep</aspectratio>
61 <texture>jtk_clearlogo.png</texture>
62 <colordiffuse>CCFFFFFF</colordiffuse>
63 <visible>!String.IsEmpty(Window().Property(info.clearlogo)) + !String.IsEqual(Window().Property(instant_close),true)</visible>
64 </control>
65
66 <!-- Qualities Header as a horizontal list -->
67 <control type="list" id="1300">
68 <top>40</top>
69 <left>500</left> <!-- Centered above sources list -->
70 <width>800</width>
71 <height>60</height>
72 <orientation>horizontal</orientation>
73 <ondown>1000</ondown>
74 <itemgap>0</itemgap>
75 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
76
77 <itemlayout width="200" height="60">
78 <control type="group">
79 <control type="label">
80 <width>200</width>
81 <height>60</height>
82 <font>font25</font>
83 <textcolor>AAAAAAAA</textcolor>
84 <align>center</align>
85 <label>$INFO[ListItem.Label]</label>
86 </control>
87 <!-- Separator: only show if not last item -->
88 <control type="label">
89 <left>200</left>
90 <width>20</width>
91 <height>60</height>
92 <font>font25</font>
93 <textcolor>80FFFFFF</textcolor>
94 <align>center</align>
95 <label>|</label>
96 </control>
97 </control>
98 </itemlayout>
99 <focusedlayout width="200" height="60">
100 <control type="group">
101 <control type="label">
102 <width>200</width>
103 <height>60</height>
104 <font>font25</font>
105 <textcolor>FFFFFFFF</textcolor>
106 <align>center</align>
107 <label>$INFO[ListItem.Label]</label>
108 </control>
109 <control type="label">
110 <left>200</left>
111 <width>20</width>
112 <height>60</height>
113 <font>font25</font>
114 <textcolor>80FFFFFF</textcolor>
115 <align>center</align>
116 <label>|</label>
117 </control>
118 </control>
119 </focusedlayout>
120 </control>
121
122 <!-- Filter Hint -->
123 <control type="label" id="1201">
124 <top>85</top>
125 <left>500</left> <!-- Centered above sources list -->
126 <width>800</width>
127 <height>25</height>
128 <font>font12</font>
129 <textcolor>80FFFFFF</textcolor>
130 <align>center</align>
131 <label>Press [LEFT] for more filtering options</label>
132 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
133 </control>
134
135 <!-- Scrollbar -->
136 <control type="scrollbar" id="1111">
137 <left>1366</left>
138 <top>140</top>
139 <width>20</width>
140 <height>890</height>
141 <onleft>1000</onleft>
142 <showonepage>false</showonepage>
143 <texturesliderbackground colordiffuse="99362e33">white.png</texturesliderbackground>
144 <texturesliderbar colordiffuse="66FFFFFF">white.png</texturesliderbar>
145 <texturesliderbarfocus colordiffuse="$INFO[Window().Property(settings.color)]">white.png</texturesliderbarfocus>
146 <visible>String.IsEqual(Window().Property(instant_close),false)</visible>
147 </control>
148
149 <!-- Sources List -->
150 <control type="list" id="1000">
151 <viewtype label="Sources">list</viewtype>
152 <pagecontrol>1111</pagecontrol>
153 <onup>1300</onup>
154 <onright>1111</onright>
155 <left>100</left>
156 <top>130</top>
157 <width>1340</width>
158 <height>920</height>
159 <itemgap>20</itemgap>
160 <orientation>vertical</orientation>
161 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
162
163 <!-- Unfocused Source -->
164 <itemlayout height="220">
165 <control type="group">
166 <width>1250</width>
167 <control type="image">
168 <top>10</top>
169 <width>200</width>
170 <height>200</height>
171 <texture border="30">left-circle.png</texture>
172 <colordiffuse>$INFO[Window().Property(style.card_accent)]</colordiffuse>
173 </control>
174 <control type="image">
175 <top>10</top>
176 <height>200</height>
177 <texture border="30">circle.png</texture>
178 <colordiffuse>$INFO[Window().Property(style.card_bg)]</colordiffuse>
179 </control>
180 <control type="label">
181 <font>font30</font>
182 <top>75</top>
183 <height>20</height>
184 <align>center</align>
185 <width>200</width>
186 <textcolor>66FFFFFF</textcolor>
187 <label>[UPPERCASE]$INFO[ListItem.Property(quality)][/UPPERCASE]</label>
188 </control>
189 <control type="label">
190 <top>125</top>
191 <align>center</align>
192 <height>20</height>
193 <width>200</width>
194 <font>font20</font>
195 <textcolor>66FFFFFF</textcolor>
196 <label>$INFO[ListItem.Property(type)]</label>
197 </control>
198
199 <control type="label">
200 <top>20</top>
201 <left>220</left>
202 <width>1000</width>
203 <height>25</height>
204 <font>font12</font>
205 <textcolor>FFFFFFFF</textcolor>
206 <label>[B]$INFO[ListItem.Property(display_title)][/B]</label>
207 <visible>!String.IsEmpty(ListItem.Property(display_title))</visible>
208 </control>
209
210 <control type="label">
211 <top>70</top>
212 <left>220</left>
213 <width>1000</width>
214 <height>20</height>
215 <font>font12</font>
216 <textcolor>FFFFFFFF</textcolor>
217 <label>$INFO[ListItem.Property(hdr_info)]</label>
218 <visible>!String.IsEmpty(ListItem.Property(hdr_info))</visible>
219 </control>
220
221 <control type="group">
222 <top>105</top>
223 <control type="label">
224 <left>220</left>
225 <width>320</width>
226 <height>20</height>
227 <font>font12</font>
228 <textcolor>CCFFFFFF</textcolor>
229 <label>[B][UPPERCASE][COLOR CCFFFFFF]SIZE: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(size)]</label>
230 <visible>!String.IsEmpty(ListItem.Property(size))</visible>
231 </control>
232 <control type="label">
233 <left>550</left>
234 <width>380</width>
235 <height>20</height>
236 <font>font12</font>
237 <textcolor>CCFFFFFF</textcolor>
238 <label>[B][UPPERCASE][COLOR CCFFFFFF]S: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(seeders)]</label>
239 <visible>!String.IsEmpty(ListItem.Property(seeders))</visible>
240 </control>
241 <control type="label">
242 <left>950</left>
243 <width>350</width>
244 <height>20</height>
245 <font>font12</font>
246 <textcolor>CCFFFFFF</textcolor>
247 <label>[B][UPPERCASE][COLOR CCFFFFFF]STATUS: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(status)]</label>
248 <visible>!String.IsEmpty(ListItem.Property(status))</visible>
249 </control>
250 </control>
251
252 <control type="group">
253 <top>140</top>
254 <control type="label">
255 <left>220</left>
256 <width>320</width>
257 <height>20</height>
258 <font>font12</font>
259 <textcolor>CCFFFFFF</textcolor>
260 <label>[B][UPPERCASE][COLOR CCFFFFFF]INDEXER: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(indexer)]</label>
261 <visible>!String.IsEmpty(ListItem.Property(indexer))</visible>
262 </control>
263 <control type="label">
264 <left>550</left>
265 <width>380</width>
266 <height>20</height>
267 <font>font12</font>
268 <textcolor>CCFFFFFF</textcolor>
269 <label>[B][UPPERCASE][COLOR CCFFFFFF]PROVIDER: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(provider)]</label>
270 <visible>!String.IsEmpty(ListItem.Property(provider))</visible>
271 </control>
272 <control type="label">
273 <left>950</left>
274 <width>350</width>
275 <height>20</height>
276 <font>font12</font>
277 <textcolor>CCFFFFFF</textcolor>
278 <label>[B][UPPERCASE][COLOR CCFFFFFF]DATE: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(publishDate)]</label>
279 <visible>!String.IsEmpty(ListItem.Property(publishDate))</visible>
280 </control>
281 </control>
282
283 <control type="label">
284 <top>175</top>
285 <left>220</left>
286 <width>1000</width>
287 <height>20</height>
288 <font>font12</font>
289 <textcolor>CCFFFFFF</textcolor>
290 <label>[B][UPPERCASE][COLOR CCFFFFFF]LANGUAGES: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(fullLanguages)]</label>
291 <visible>!String.IsEmpty(ListItem.Property(fullLanguages))</visible>
292 </control>
293 </control>
294 </itemlayout>
295
296 <!-- Focused Source -->
297 <focusedlayout height="220">
298 <control type="group">
299 <width>1250</width>
300 <control type="image">
301 <top>10</top>
302 <width>200</width>
303 <height>200</height>
304 <texture border="30">left-circle.png</texture>
305 <colordiffuse>$INFO[Window().Property(style.card_accent)]</colordiffuse>
306 </control>
307 <control type="image">
308 <top>10</top>
309 <height>200</height>
310 <texture border="30">circle.png</texture>
311 <colordiffuse>$INFO[Window().Property(style.card_focus)]</colordiffuse>
312 </control>
313
314 <control type="label">
315 <font>font30</font>
316 <top>75</top>
317 <height>20</height>
318 <align>center</align>
319 <width>200</width>
320 <textcolor>FFFFFFFF</textcolor>
321 <label>[UPPERCASE]$INFO[ListItem.Property(quality)][/UPPERCASE]</label>
322 </control>
323 <control type="label">
324 <top>125</top>
325 <align>center</align>
326 <height>20</height>
327 <width>200</width>
328 <font>font20</font>
329 <textcolor>FFFFFFFF</textcolor>
330 <label>$INFO[ListItem.Property(type)]</label>
331 </control>
332
333 <control type="label">
334 <top>20</top>
335 <left>220</left>
336 <width>1000</width>
337 <height>25</height>
338 <font>font12</font>
339 <textcolor>FFFFFFFF</textcolor>
340 <label>[B]$INFO[ListItem.Property(display_title)][/B]</label>
341 <scroll>true</scroll>
342 <visible>!String.IsEmpty(ListItem.Property(display_title))</visible>
343 </control>
344
345 <control type="label">
346 <top>70</top>
347 <left>220</left>
348 <width>1000</width>
349 <height>20</height>
350 <font>font12</font>
351 <textcolor>FFFFFFFF</textcolor>
352 <label>$INFO[ListItem.Property(hdr_info)]</label>
353 <visible>!String.IsEmpty(ListItem.Property(hdr_info))</visible>
354 </control>
355
356 <control type="group">
357 <top>105</top>
358 <control type="label">
359 <left>220</left>
360 <width>320</width>
361 <height>20</height>
362 <font>font12</font>
363 <textcolor>FFFFFFFF</textcolor>
364 <label>[B][UPPERCASE][COLOR FFFFFFFF]SIZE: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(size)]</label>
365 <visible>!String.IsEmpty(ListItem.Property(size))</visible>
366 </control>
367 <control type="label">
368 <left>550</left>
369 <width>380</width>
370 <height>20</height>
371 <font>font12</font>
372 <textcolor>FFFFFFFF</textcolor>
373 <label>[B][UPPERCASE][COLOR FFFFFFFF]S: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(seeders)]</label>
374 <visible>!String.IsEmpty(ListItem.Property(seeders))</visible>
375 </control>
376 <control type="label">
377 <left>950</left>
378 <width>350</width>
379 <height>20</height>
380 <font>font12</font>
381 <textcolor>FFFFFFFF</textcolor>
382 <label>[B][UPPERCASE][COLOR FFFFFFFF]STATUS: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(status)]</label>
383 <visible>!String.IsEmpty(ListItem.Property(status))</visible>
384 </control>
385 </control>
386
387 <control type="group">
388 <top>140</top>
389 <control type="label">
390 <left>220</left>
391 <width>320</width>
392 <height>20</height>
393 <font>font12</font>
394 <textcolor>FFFFFFFF</textcolor>
395 <label>[B][UPPERCASE][COLOR FFFFFFFF]INDEXER: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(indexer)]</label>
396 <visible>!String.IsEmpty(ListItem.Property(indexer))</visible>
397 </control>
398 <control type="label">
399 <left>550</left>
400 <width>380</width>
401 <height>20</height>
402 <font>font12</font>
403 <textcolor>FFFFFFFF</textcolor>
404 <label>[B][UPPERCASE][COLOR FFFFFFFF]PROVIDER: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(provider)]</label>
405 <visible>!String.IsEmpty(ListItem.Property(provider))</visible>
406 </control>
407 <control type="label">
408 <left>950</left>
409 <width>350</width>
410 <height>20</height>
411 <font>font12</font>
412 <textcolor>FFFFFFFF</textcolor>
413 <label>[B][UPPERCASE][COLOR FFFFFFFF]DATE: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(publishDate)]</label>
414 <visible>!String.IsEmpty(ListItem.Property(publishDate))</visible>
415 </control>
416 </control>
417
418 <control type="label">
419 <top>175</top>
420 <left>220</left>
421 <width>1000</width>
422 <height>20</height>
423 <font>font12</font>
424 <textcolor>FFFFFFFF</textcolor>
425 <label>[B][UPPERCASE][COLOR FFFFFFFF]LANGUAGES: [/COLOR][/UPPERCASE][/B]$INFO[ListItem.Property(fullLanguages)]</label>
426 <visible>!String.IsEmpty(ListItem.Property(fullLanguages))</visible>
427 </control>
428 </control>
429 </focusedlayout>
430 </control>
431
432 <control type="group">
433 <top>100</top>
434 <right>70</right>
435 <width>500</width>
436 <control type="image">
437 <aspectratio align="center" aligny="center">keep</aspectratio>
438 <top>20</top>
439 <left>20</left>
440 <height>600</height>
441 <width>500</width>
442 <texture>$INFO[Window.Property(info.poster)]</texture>
443 </control>
444 <control type="label">
445 <wrapmultiline>true</wrapmultiline>
446 <top>650</top>
447 <left>60</left>
448 <height>200</height>
449 <width>460</width>
450 <font>font12</font>
451 <textcolor>FFFFFFFF</textcolor>
452 <label>$INFO[Window().Property(info.overview)]</label>
453 </control>
454 </control>
455 </controls>
456</window>
plugin.video.jacktook/resources/skins/Default/1080i/source_select_direct.xml +606 -0
……@@ -0,0 +1,606 @@
1<window type="dialog">
2 <coordinates>
3 <left>0</left>
4 <top>0</top>
5 <width>1920</width>
6 <height>1080</height>
7 </coordinates>
8 <controls>
9 <!-- Background -->
10 <control type="group">
11 <top>0</top>
12 <left>0</left>
13 <width>1920</width>
14 <height>1080</height>
15
16 <!-- Fallback -->
17 <control type="image">
18 <texture>white.png</texture>
19 <colordiffuse>8000000</colordiffuse>
20 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
21 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
22 </control>
23
24 <!-- Fanart -->
25 <control type="image">
26 <texture background="true">$INFO[Window().Property(info.fanart)]</texture>
27 <colordiffuse>FFFFFFFF</colordiffuse>
28 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
29 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
30 </control>
31
32 <!-- Overlay -->
33 <control type="image">
34 <texture background="true">white.png</texture>
35 <colordiffuse>CC000000</colordiffuse>
36 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
37 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
38 </control>
39 </control>
40
41 <!-- Clearlogo -->
42 <control type="image">
43 <animation type="WindowOpen">
44 <effect type="slide" start="841,401" time="350" tween="cubic" easing="in"/>
45 <effect type="zoom" start="140,140" time="350" center="auto" tween="cubic" easing="in"/>
46 </animation>
47 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(instant_close),true)">WindowClose</animation>
48 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
49 <animation type="Conditional" condition="!String.IsEqual(Window().Property(resolving),true)">
50 <effect type="slide" start="841,401" time="350" delay="350" tween="cubic" easing="in"/>
51 <effect type="zoom" start="140,140" time="350" delay="350" center="auto" tween="cubic" easing="in"/>
52 </animation>
53 <top>0</top>
54 <left>20</left>
55 <width>200</width>
56 <height>150</height>
57 <aspectratio>keep</aspectratio>
58 <texture>jtk_clearlogo.png</texture>
59 <colordiffuse>CCFFFFFF</colordiffuse>
60 <visible>!String.IsEmpty(Window().Property(info.clearlogo)) + !String.IsEqual(Window().Property(instant_close),true)</visible>
61 </control>
62
63 <!-- Qualities Header as a horizontal list -->
64 <control type="list" id="1300">
65 <top>40</top>
66 <left>500</left> <!-- Centered above sources list -->
67 <width>800</width>
68 <height>60</height>
69 <orientation>horizontal</orientation>
70 <ondown>1000</ondown>
71 <itemgap>0</itemgap>
72 <itemlayout width="200" height="60">
73 <control type="group">
74 <control type="label">
75 <width>200</width>
76 <height>60</height>
77 <font>font25</font>
78 <textcolor>AAAAAAAA</textcolor>
79 <align>center</align>
80 <label>$INFO[ListItem.Label]</label>
81 </control>
82 <!-- Separator: only show if not last item -->
83 <control type="label">
84 <left>200</left>
85 <width>20</width>
86 <height>60</height>
87 <font>font25</font>
88 <textcolor>80FFFFFF</textcolor>
89 <align>center</align>
90 <label>|</label>
91 </control>
92 </control>
93 </itemlayout>
94 <focusedlayout width="200" height="60">
95 <control type="group">
96 <control type="label">
97 <width>200</width>
98 <height>60</height>
99 <font>font25</font>
100 <textcolor>FFFFFFFF</textcolor>
101 <align>center</align>
102 <label>$INFO[ListItem.Label]</label>
103 </control>
104 <control type="label">
105 <left>200</left>
106 <width>20</width>
107 <height>60</height>
108 <font>font25</font>
109 <textcolor>80FFFFFF</textcolor>
110 <align>center</align>
111 <label>|</label>
112 </control>
113 </control>
114 </focusedlayout>
115 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
116 </control>
117
118 <!-- Scrollbar -->
119 <control type="scrollbar" id="1111">
120 <animation effect="fade" time="350" tween="cubic" easing="in" delay="350">WindowOpen</animation>
121 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(instant_close),true)">WindowClose</animation>
122 <animation effect="fade" end="0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
123 <animation effect="fade" time="350" delay="700" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
124 <showonepage>false</showonepage>
125 <texturesliderbackground colordiffuse="99362e33">white.png</texturesliderbackground>
126 <texturesliderbar colordiffuse="66FFFFFF">white.png</texturesliderbar>
127 <texturesliderbarfocus colordiffuse="$INFO[Window().Property(settings.color)]">white.png</texturesliderbarfocus>
128 <visible>!String.IsEqual(Window().Property(instant_close),false)</visible>
129 </control>
130
131 <!-- Sources List -->
132 <control type="list" id="1000">
133 <animation effect="slide" start="-1400,0" time="350" tween="cubic" easing="in">WindowOpen</animation>
134 <animation effect="slide" end="-1400,0" time="350" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(instant_close),true)">WindowClose</animation>
135 <animation effect="slide" end="-1400,0" time="350" tween="cubic" easing="in" condition="String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
136 <animation effect="slide" start="-1400,0" time="350" delay="350" tween="cubic" easing="in" condition="!String.IsEqual(Window().Property(resolving),true)">Conditional</animation>
137 <viewtype label="Sources">list</viewtype>
138 <pagecontrol>1111</pagecontrol>
139 <left>320</left>
140 <top>110</top>
141 <width>1500</width>
142 <height>900</height>
143 <itemgap>20</itemgap>
144 <orientation>vertical</orientation>
145 <visible>!String.IsEqual(Window().Property(instant_close),true)</visible>
146
147 <!-- Unfocused Source -->
148 <itemlayout height="200">
149 <control type="group">
150 <width>1250</width>
151
152 <control type="image">
153 <top>10</top>
154 <width>200</width>
155 <texture border="30">left-circle.png</texture>
156 <colordiffuse>$INFO[Window().Property(style.card_accent)]</colordiffuse>
157 </control>
158
159 <!-- Card with Rounded Corners -->
160 <control type="image">
161 <top>10</top>
162 <texture border="30">circle.png</texture>
163 <colordiffuse>$INFO[Window().Property(style.card_bg)]</colordiffuse>
164 </control>
165
166 <!-- Resolution -->
167 <control type="label">
168 <font>font30</font>
169 <height>20</height>
170 <top>70</top>
171 <align>center</align>
172 <width>200</width>
173 <textcolor>66FFFFFF</textcolor>
174 <label>[UPPERCASE]$INFO[ListItem.Property(quality)][/UPPERCASE]</label>
175 </control>
176
177 <!-- Type -->
178 <control type="label">
179 <top>120</top>
180 <align>center</align>
181 <width>200</width>
182 <font>font20</font>
183 <height>20</height>
184 <textcolor>66FFFFFF</textcolor>
185 <align>left</align>
186 <label>$INFO[ListItem.Property(type)]</label>
187 </control>
188
189 <!-- Release Title -->
190 <control type="label">
191 <top>30</top>
192 <left>220</left>
193 <width>1000</width>
194 <height>20</height>
195 <font>font12</font>
196 <textcolor>FFFFFFFF</textcolor>
197 <align>left</align>
198 <label>[B]$INFO[ListItem.Property(title)][/B]</label>
199 </control>
200
201 <control type="group">
202 <top>40</top>
203 <left>20</left>
204
205 <control type="group">
206 <top>30</top>
207
208 <!-- Size -->
209 <control type="label">
210 <height>20</height>
211 <font>font12</font>
212 <textcolor>CCFFFFFF</textcolor>
213 <align>left</align>
214 <left>200</left>
215 <label>[UPPERCASE]SIZE: $INFO[ListItem.Property(size)][/UPPERCASE]</label>
216 <visible>!String.IsEmpty(ListItem.Property(size))</visible>
217
218 </control>
219
220 <!-- Seeders -->
221 <control type="label">
222 <right>20</right>
223 <height>20</height>
224 <font>font12</font>
225 <align>left</align>
226 <left>550</left>
227 <textcolor>CCFFFFFF</textcolor>
228 <label>[UPPERCASE]SEEDERS: $INFO[ListItem.Property(seeders)][/UPPERCASE]</label>
229 <visible>!String.IsEmpty(ListItem.Property(seeders))</visible>
230 </control>
231
232 </control>
233
234 <control type="group">
235 <top>70</top>
236
237 <!-- Indexer -->
238 <control type="label">
239 <width>330</width>
240 <height>20</height>
241 <font>font12</font>
242 <left>200</left>
243 <textcolor>CCFFFFFF</textcolor>
244 <align>left</align>
245 <label>[UPPERCASE]INDEXER: $INFO[ListItem.Property(indexer)][/UPPERCASE]</label>
246 <visible>!String.IsEmpty(ListItem.Property(indexer))</visible>
247 </control>
248
249 <!-- Provider -->
250 <control type="label">
251 <height>20</height>
252 <font>font12</font>
253 <align>left</align>
254 <left>550</left>
255 <textcolor>CCFFFFFF</textcolor>
256 <label>[UPPERCASE]PROVIDER: $INFO[ListItem.Property(provider)][/UPPERCASE]</label>
257 <visible>!String.IsEmpty(ListItem.Property(provider))</visible>
258 </control>
259
260 <!-- PublishDate -->
261 <control type="label">
262 <height>20</height>
263 <font>font12</font>
264 <align>left</align>
265 <left>900</left>
266 <textcolor>CCFFFFFF</textcolor>
267 <label>[UPPERCASE]DATE: $INFO[ListItem.Property(publishDate)][/UPPERCASE]</label>
268 <visible>!String.IsEmpty(ListItem.Property(publishDate))</visible>
269 </control>
270
271 </control>
272 <control type="group">
273 <top>110</top>
274
275 <!-- Status -->
276 <control type="label">
277 <height>20</height>
278 <font>font12</font>
279 <align>left</align>
280 <left>200</left>
281 <textcolor>FFFFFFFF</textcolor>
282 <label>[UPPERCASE]STATUS: [/UPPERCASE]$INFO[ListItem.Property(status)]</label>
283 <visible>!String.IsEmpty(ListItem.Property(status))</visible>
284 </control>
285
286 <!-- Languages -->
287 <control type="label">
288 <height>20</height>
289 <font>font12</font>
290 <align>left</align>
291 <left>550</left>
292 <textcolor>FFFFFFFF</textcolor>
293 <label>[UPPERCASE]LANGUAGES: $INFO[ListItem.Property(fullLanguages)][/UPPERCASE]</label>
294 <visible>!String.IsEmpty(ListItem.Property(fullLanguages))</visible>
295 </control>
296 </control>
297 </control>
298 </control>
299 </itemlayout>
300
301 <!-- Focused Source -->
302 <focusedlayout height="200" condition="!Control.HasFocus(1000)">
303 <control type="group">
304 <width>1250</width>
305
306 <control type="image">
307 <top>10</top>
308 <width>200</width>
309 <texture border="30">left-circle.png</texture>
310 <colordiffuse>$INFO[Window().Property(style.card_accent)]</colordiffuse>
311 </control>
312
313 <!-- Card with Rounded Corners -->
314 <control type="image">
315 <top>10</top>
316 <texture border="30">circle.png</texture>
317 <colordiffuse>$INFO[Window().Property(style.card_focus)]</colordiffuse>
318 </control>
319
320 <!-- Resolution -->
321 <control type="label">
322 <font>font30</font>
323 <top>70</top>
324 <height>20</height>
325 <align>center</align>
326 <width>200</width>
327 <textcolor>66FFFFFF</textcolor>
328 <label>[UPPERCASE]$INFO[ListItem.Property(quality)][/UPPERCASE]</label>
329 </control>
330
331 <!-- Type -->
332 <control type="label">
333 <top>120</top>
334 <align>center</align>
335 <width>200</width>
336 <font>font20</font>
337 <height>20</height>
338 <textcolor>66FFFFFF</textcolor>
339 <align>left</align>
340 <label>$INFO[ListItem.Property(type)]</label>
341 </control>
342
343 <!-- Release Title -->
344 <control type="label">
345 <top>30</top>
346 <left>220</left>
347 <width>1000</width>
348 <height>20</height>
349 <font>font12</font>
350 <textcolor>FFFFFFFF</textcolor>
351 <align>left</align>
352 <label>[B]$INFO[ListItem.Property(title)][/B]</label>
353 </control>
354
355 <control type="group">
356 <top>40</top>
357 <left>20</left>
358
359 <control type="group">
360 <top>30</top>
361
362 <!-- Size -->
363 <control type="label">
364 <height>20</height>
365 <font>font12</font>
366 <textcolor>FFFFFFFF</textcolor>
367 <align>left</align>
368 <left>200</left>
369 <label>[UPPERCASE]SIZE: $INFO[ListItem.Property(size)][/UPPERCASE]</label>
370 <visible>!String.IsEmpty(ListItem.Property(size))</visible>
371 </control>
372
373 <!-- Seeders -->
374 <control type="label">
375 <right>20</right>
376 <height>20</height>
377 <font>font12</font>
378 <align>left</align>
379 <left>550</left>
380 <textcolor>FFFFFFFF</textcolor>
381 <label>[UPPERCASE]SEEDERS: $INFO[ListItem.Property(seeders)][/UPPERCASE]</label>
382 <visible>!String.IsEmpty(ListItem.Property(seeders))</visible>
383 </control>
384 </control>
385
386 <control type="group">
387 <top>70</top>
388
389 <!-- Indexer -->
390 <control type="label">
391 <width>330</width>
392 <height>20</height>
393 <font>font12</font>
394 <left>200</left>
395 <textcolor>FFFFFFFF</textcolor>
396 <align>left</align>
397 <label>[UPPERCASE]INDEXER: $INFO[ListItem.Property(indexer)][/UPPERCASE]</label>
398 <visible>!String.IsEmpty(ListItem.Property(indexer))</visible>
399 </control>
400
401 <!-- Provider -->
402 <control type="label">
403 <height>20</height>
404 <font>font12</font>
405 <align>left</align>
406 <left>550</left>
407 <textcolor>FFFFFFFF</textcolor>
408 <label>[UPPERCASE]PROVIDER: $INFO[ListItem.Property(provider)][/UPPERCASE]</label>
409 <visible>!String.IsEmpty(ListItem.Property(provider))</visible>
410 </control>
411
412 <!-- PublishDate -->
413 <control type="label">
414 <height>20</height>
415 <font>font12</font>
416 <align>left</align>
417 <left>900</left>
418 <textcolor>CCFFFFFF</textcolor>
419 <label>[UPPERCASE]DATE: $INFO[ListItem.Property(publishDate)][/UPPERCASE]</label>
420 <visible>!String.IsEmpty(ListItem.Property(publishDate))</visible>
421 </control>
422
423 <control type="group">
424 <top>110</top>
425
426 <!-- Status -->
427 <control type="label">
428 <height>20</height>
429 <font>font12</font>
430 <align>left</align>
431 <left>200</left>
432 <textcolor>FFFFFFFF</textcolor>
433 <label>[UPPERCASE]STATUS: [/UPPERCASE]$INFO[ListItem.Property(status)]</label>
434 <visible>!String.IsEmpty(ListItem.Property(status))</visible>
435 </control>
436
437 <!-- Languages -->
438 <control type="label">
439 <height>20</height>
440 <font>font12</font>
441 <align>left</align>
442 <left>550</left>
443 <textcolor>FFFFFFFF</textcolor>
444 <label>[UPPERCASE]LANGUAGES: $INFO[ListItem.Property(fullLanguages)][/UPPERCASE]</label>
445 <visible>!String.IsEmpty(ListItem.Property(fullLanguages))</visible>
446 </control>
447 </control>
448 </control>
449 </control>
450 </control>
451 </focusedlayout>
452
453 <!-- Focused Source -->
454 <focusedlayout height="200" condition="Control.HasFocus(1000)">
455 <control type="group">
456 <width>1250</width>
457
458 <control type="image">
459 <top>10</top>
460 <width>200</width>
461 <texture border="30">left-circle.png</texture>
462 <colordiffuse>$INFO[Window().Property(style.card_accent)]</colordiffuse>
463 </control>
464
465 <!-- Card with Rounded Corners -->
466 <control type="image">
467 <top>10</top>
468 <texture border="30">circle.png</texture>
469 <colordiffuse>$INFO[Window().Property(style.card_focus)]</colordiffuse>
470 </control>
471
472 <!-- Resolution -->
473 <control type="label">
474 <font>font30</font>
475 <top>70</top>
476 <height>20</height>
477 <align>center</align>
478 <width>200</width>
479 <textcolor>FFFFFFFF</textcolor>
480 <label>[UPPERCASE]$INFO[ListItem.Property(quality)][/UPPERCASE]</label>
481 </control>
482
483 <!-- Type -->
484 <control type="label">
485 <top>120</top>
486 <align>center</align>
487 <width>200</width>
488 <font>font20</font>
489 <height>20</height>
490 <textcolor>FFFFFFFF</textcolor>
491 <align>left</align>
492 <label>$INFO[ListItem.Property(type)]</label>
493 </control>
494
495 <!-- Release Title -->
496 <control type="label">
497 <top>30</top>
498 <left>220</left>
499 <width>1000</width>
500 <height>20</height>
501 <font>font12</font>
502 <textcolor>FFFFFFFF</textcolor>
503 <align>left</align>
504 <label>[B]$INFO[ListItem.Property(title)][/B]</label>
505 </control>
506
507 <control type="group">
508 <top>40</top>
509 <left>20</left>
510
511 <control type="group">
512 <top>30</top>
513
514 <!-- Size -->
515 <control type="label">
516 <height>20</height>
517 <font>font12</font>
518 <textcolor>FFFFFFFF</textcolor>
519 <align>left</align>
520 <left>200</left>
521 <label>[UPPERCASE]SIZE: $INFO[ListItem.Property(size)][/UPPERCASE]</label>
522 <visible>!String.IsEmpty(ListItem.Property(size))</visible>
523 </control>
524
525 <!-- Seeders -->
526 <control type="label">
527 <right>20</right>
528 <height>20</height>
529 <font>font12</font>
530 <align>left</align>
531 <left>550</left>
532 <textcolor>FFFFFFFF</textcolor>
533 <label>[UPPERCASE]SEEDERS: $INFO[ListItem.Property(seeders)][/UPPERCASE]</label>
534 <visible>!String.IsEmpty(ListItem.Property(seeders))</visible>
535 </control>
536
537 </control>
538
539 <control type="group">
540 <top>70</top>
541
542 <!-- Indexer -->
543 <control type="label">
544 <width>330</width>
545 <height>20</height>
546 <font>font12</font>
547 <left>200</left>
548 <textcolor>FFFFFFFF</textcolor>
549 <align>left</align>
550 <label>[UPPERCASE]INDEXER: $INFO[ListItem.Property(indexer)][/UPPERCASE]</label>
551 <visible>!String.IsEmpty(ListItem.Property(indexer))</visible>
552 </control>
553
554 <!-- Provider -->
555 <control type="label">
556 <height>20</height>
557 <font>font12</font>
558 <align>left</align>
559 <left>550</left>
560 <textcolor>FFFFFFFF</textcolor>
561 <label>[UPPERCASE]PROVIDER: $INFO[ListItem.Property(provider)][/UPPERCASE]</label>
562 <visible>!String.IsEmpty(ListItem.Property(provider))</visible>
563 </control>
564
565 <!-- PublishDate -->
566 <control type="label">
567 <height>20</height>
568 <font>font12</font>
569 <align>left</align>
570 <left>900</left>
571 <textcolor>CCFFFFFF</textcolor>
572 <label>[UPPERCASE]DATE: $INFO[ListItem.Property(publishDate)][/UPPERCASE]</label>
573 <visible>!String.IsEmpty(ListItem.Property(publishDate))</visible>
574 </control>
575 </control>
576 <control type="group">
577 <top>110</top>
578
579 <!-- Status -->
580 <control type="label">
581 <height>20</height>
582 <font>font12</font>
583 <align>left</align>
584 <left>200</left>
585 <textcolor>FFFFFFFF</textcolor>
586 <label>[UPPERCASE]STATUS: [/UPPERCASE]$INFO[ListItem.Property(status)]</label>
587 <visible>!String.IsEmpty(ListItem.Property(status))</visible>
588 </control>
589
590 <!-- Languages -->
591 <control type="label">
592 <height>20</height>
593 <font>font12</font>
594 <align>left</align>
595 <left>550</left>
596 <textcolor>FFFFFFFF</textcolor>
597 <label>[UPPERCASE]LANGUAGES: $INFO[ListItem.Property(fullLanguages)][/UPPERCASE]</label>
598 <visible>!String.IsEmpty(ListItem.Property(fullLanguages))</visible>
599 </control>
600 </control>
601 </control>
602 </control>
603 </focusedlayout>
604 </control>
605 </controls>
606</window>
plugin.video.jacktook/resources/skins/Default/media/AddonWindow/black.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/AddonWindow/white.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/Button/close.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/arrow_left.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/arrow_right.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/circle.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/info.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/jtk_clearlogo.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/jtk_fanart.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/left-circle.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/panel-256-box.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/poster_w217_h320.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/spinner.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/texture.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/skins/Default/media/white.png +0 -0
(no content changes — binary or rename)
plugin.video.jacktook/resources/web/index.html +498 -0
……@@ -0,0 +1,498 @@
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Jacktook - Stremio Sources</title>
7 <style>
8 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
10 :root {
11 --bg: #0d1117;
12 --surface: #161b22;
13 --surface-hover: #1c2333;
14 --border: #30363d;
15 --text: #e6edf3;
16 --text-muted: #8b949e;
17 --accent: #58a6ff;
18 --accent-hover: #79c0ff;
19 --success: #3fb950;
20 --danger: #f85149;
21 --danger-hover: #ff6e67;
22 --warning: #d29922;
23 --badge-stream: #388bfd;
24 --badge-catalog: #a371f7;
25 --badge-tv: #f78166;
26 --radius: 12px;
27 --radius-sm: 8px;
28 }
29
30 body {
31 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
32 background: var(--bg);
33 color: var(--text);
34 min-height: 100vh;
35 line-height: 1.5;
36 }
37
38 /* Header */
39 .header {
40 background: linear-gradient(135deg, #1a1e2e 0%, #0d1117 100%);
41 border-bottom: 1px solid var(--border);
42 padding: 20px 16px;
43 text-align: center;
44 position: sticky;
45 top: 0;
46 z-index: 100;
47 backdrop-filter: blur(10px);
48 }
49 .header h1 {
50 font-size: 1.4rem;
51 font-weight: 700;
52 background: linear-gradient(135deg, var(--accent), var(--accent-hover));
53 -webkit-background-clip: text;
54 -webkit-text-fill-color: transparent;
55 background-clip: text;
56 }
57 .header .subtitle {
58 font-size: 0.8rem;
59 color: var(--text-muted);
60 margin-top: 4px;
61 }
62 .status-bar {
63 display: flex;
64 align-items: center;
65 justify-content: center;
66 gap: 6px;
67 margin-top: 8px;
68 font-size: 0.75rem;
69 color: var(--text-muted);
70 }
71 .status-dot {
72 width: 8px; height: 8px;
73 border-radius: 50%;
74 background: var(--success);
75 animation: pulse 2s infinite;
76 }
77 @keyframes pulse {
78 0%, 100% { opacity: 1; }
79 50% { opacity: 0.5; }
80 }
81
82 /* Container */
83 .container { max-width: 600px; margin: 0 auto; padding: 16px; }
84
85 /* Add Section */
86 .add-section {
87 background: var(--surface);
88 border: 1px solid var(--border);
89 border-radius: var(--radius);
90 padding: 16px;
91 margin-bottom: 20px;
92 }
93 .add-section h2 {
94 font-size: 0.9rem;
95 color: var(--text-muted);
96 margin-bottom: 12px;
97 text-transform: uppercase;
98 letter-spacing: 0.5px;
99 }
100 .input-row {
101 display: flex;
102 gap: 8px;
103 }
104 .input-row input {
105 flex: 1;
106 background: var(--bg);
107 border: 1px solid var(--border);
108 border-radius: var(--radius-sm);
109 padding: 10px 14px;
110 color: var(--text);
111 font-size: 0.9rem;
112 outline: none;
113 transition: border-color 0.2s;
114 }
115 .input-row input:focus {
116 border-color: var(--accent);
117 }
118 .input-row input::placeholder { color: var(--text-muted); }
119
120 .btn {
121 display: inline-flex;
122 align-items: center;
123 justify-content: center;
124 gap: 6px;
125 padding: 10px 20px;
126 border: none;
127 border-radius: var(--radius-sm);
128 font-size: 0.85rem;
129 font-weight: 600;
130 cursor: pointer;
131 transition: all 0.2s;
132 white-space: nowrap;
133 }
134 .btn-primary {
135 background: var(--accent);
136 color: #fff;
137 }
138 .btn-primary:hover { background: var(--accent-hover); }
139 .btn-primary:disabled {
140 opacity: 0.5;
141 cursor: not-allowed;
142 }
143
144 .btn-danger {
145 background: transparent;
146 color: var(--danger);
147 border: 1px solid var(--danger);
148 padding: 6px 12px;
149 font-size: 0.75rem;
150 }
151 .btn-danger:hover {
152 background: var(--danger);
153 color: #fff;
154 }
155
156 .feedback {
157 margin-top: 10px;
158 padding: 8px 12px;
159 border-radius: var(--radius-sm);
160 font-size: 0.8rem;
161 display: none;
162 }
163 .feedback.error {
164 display: block;
165 background: rgba(248, 81, 73, 0.1);
166 border: 1px solid var(--danger);
167 color: var(--danger);
168 }
169 .feedback.success {
170 display: block;
171 background: rgba(63, 185, 80, 0.1);
172 border: 1px solid var(--success);
173 color: var(--success);
174 }
175 .feedback.loading {
176 display: block;
177 background: rgba(88, 166, 255, 0.1);
178 border: 1px solid var(--accent);
179 color: var(--accent);
180 }
181
182 /* Addon List */
183 .addon-list-header {
184 display: flex;
185 align-items: center;
186 justify-content: space-between;
187 margin-bottom: 12px;
188 }
189 .addon-list-header h2 {
190 font-size: 0.9rem;
191 color: var(--text-muted);
192 text-transform: uppercase;
193 letter-spacing: 0.5px;
194 }
195 .addon-count {
196 font-size: 0.75rem;
197 color: var(--text-muted);
198 background: var(--surface);
199 padding: 2px 10px;
200 border-radius: 12px;
201 }
202
203 .addon-card {
204 background: var(--surface);
205 border: 1px solid var(--border);
206 border-radius: var(--radius);
207 padding: 14px;
208 margin-bottom: 10px;
209 transition: border-color 0.2s, background 0.2s;
210 }
211 .addon-card:hover {
212 border-color: var(--accent);
213 background: var(--surface-hover);
214 }
215
216 .addon-top {
217 display: flex;
218 align-items: flex-start;
219 gap: 12px;
220 }
221 .addon-logo {
222 width: 42px; height: 42px;
223 border-radius: var(--radius-sm);
224 object-fit: cover;
225 background: var(--bg);
226 flex-shrink: 0;
227 }
228 .addon-info { flex: 1; min-width: 0; }
229 .addon-name {
230 font-size: 0.95rem;
231 font-weight: 600;
232 white-space: nowrap;
233 overflow: hidden;
234 text-overflow: ellipsis;
235 }
236 .addon-desc {
237 font-size: 0.78rem;
238 color: var(--text-muted);
239 margin-top: 2px;
240 display: -webkit-box;
241 -webkit-line-clamp: 2;
242 line-clamp: 2;
243 -webkit-box-orient: vertical;
244 overflow: hidden;
245 }
246 .addon-url {
247 font-size: 0.7rem;
248 color: var(--text-muted);
249 margin-top: 4px;
250 word-break: break-all;
251 opacity: 0.7;
252 }
253
254 /* Badges + Controls */
255 .addon-controls {
256 display: flex;
257 align-items: center;
258 justify-content: space-between;
259 margin-top: 10px;
260 flex-wrap: wrap;
261 gap: 6px;
262 }
263 .badge-group {
264 display: flex;
265 gap: 6px;
266 flex-wrap: wrap;
267 }
268 .badge {
269 display: inline-flex;
270 align-items: center;
271 gap: 4px;
272 padding: 3px 10px;
273 border-radius: 20px;
274 font-size: 0.7rem;
275 font-weight: 600;
276 cursor: pointer;
277 transition: all 0.2s;
278 user-select: none;
279 border: 1px solid transparent;
280 }
281 .badge.stream { background: rgba(56, 139, 253, 0.15); color: var(--badge-stream); border-color: rgba(56, 139, 253, 0.3); }
282 .badge.stream.active { background: var(--badge-stream); color: #fff; }
283 .badge.catalog { background: rgba(163, 113, 247, 0.15); color: var(--badge-catalog); border-color: rgba(163, 113, 247, 0.3); }
284 .badge.catalog.active { background: var(--badge-catalog); color: #fff; }
285 .badge.tv { background: rgba(247, 129, 102, 0.15); color: var(--badge-tv); border-color: rgba(247, 129, 102, 0.3); }
286 .badge.tv.active { background: var(--badge-tv); color: #fff; }
287 .badge.disabled {
288 opacity: 0.3;
289 cursor: default;
290 }
291
292 .empty-state {
293 text-align: center;
294 padding: 40px 20px;
295 color: var(--text-muted);
296 }
297 .empty-state .icon { font-size: 2.5rem; margin-bottom: 10px; }
298 .empty-state p { font-size: 0.85rem; }
299
300 /* Spinner */
301 .spinner {
302 display: inline-block;
303 width: 14px; height: 14px;
304 border: 2px solid rgba(255,255,255,0.3);
305 border-top-color: #fff;
306 border-radius: 50%;
307 animation: spin 0.6s linear infinite;
308 }
309 @keyframes spin { to { transform: rotate(360deg); } }
310
311 /* Responsive */
312 @media (max-width: 400px) {
313 .input-row { flex-direction: column; }
314 .btn { width: 100%; }
315 }
316 </style>
317</head>
318<body>
319
320<div class="header">
321 <h1>⚡ Jacktook</h1>
322 <div class="subtitle">Stremio Source Manager</div>
323 <div class="status-bar">
324 <span class="status-dot"></span>
325 <span id="status-text">Connected</span>
326 <span>·</span>
327 <span id="addon-count-status">0 addons</span>
328 </div>
329</div>
330
331<div class="container">
332 <!-- Add Section -->
333 <div class="add-section">
334 <h2>Add Stremio Source</h2>
335 <div class="input-row">
336 <input type="text" id="addon-url" placeholder="Paste manifest URL (e.g. https://addon.example/manifest.json)">
337 <button class="btn btn-primary" id="add-btn" onclick="addAddon()">
338 <span id="add-btn-text">Add</span>
339 </button>
340 </div>
341 <div class="feedback" id="feedback"></div>
342 </div>
343
344 <!-- Addon List -->
345 <div class="addon-list-header">
346 <h2>Your Sources</h2>
347 <span class="addon-count" id="addon-count">0</span>
348 </div>
349 <div id="addon-list"></div>
350</div>
351
352<script>
353const API_BASE = '';
354
355function showFeedback(msg, type) {
356 const el = document.getElementById('feedback');
357 el.className = 'feedback ' + type;
358 el.textContent = msg;
359 if (type === 'success') setTimeout(() => { el.className = 'feedback'; }, 3000);
360}
361
362function clearFeedback() {
363 document.getElementById('feedback').className = 'feedback';
364}
365
366async function loadAddons() {
367 try {
368 const res = await fetch(API_BASE + '/api/addons');
369 const data = await res.json();
370 renderAddons(data.addons || []);
371 } catch (e) {
372 document.getElementById('addon-list').innerHTML =
373 '<div class="empty-state"><div class="icon">⚠️</div><p>Failed to connect to Kodi</p></div>';
374 }
375}
376
377function renderAddons(addons) {
378 const list = document.getElementById('addon-list');
379 document.getElementById('addon-count').textContent = addons.length;
380 document.getElementById('addon-count-status').textContent = addons.length + ' addon' + (addons.length !== 1 ? 's' : '');
381
382 if (!addons.length) {
383 list.innerHTML = '<div class="empty-state"><div class="icon">📦</div><p>No custom sources added yet.<br>Paste a Stremio addon manifest URL above to get started.</p></div>';
384 return;
385 }
386
387 list.innerHTML = addons.map(a => {
388 const logoUrl = a.logo && !a.logo.endsWith('.svg') ? a.logo : '';
389 const logoHtml = logoUrl
390 ? `<img class="addon-logo" src="${escapeHtml(logoUrl)}" onerror="this.style.display='none'" alt="">`
391 : `<div class="addon-logo" style="display:flex;align-items:center;justify-content:center;font-size:1.2rem;">📦</div>`;
392
393 const badges = ['stream', 'catalog', 'tv'].map(cat => {
394 const capable = a.capabilities[cat];
395 const selected = a.selected[cat];
396 const cls = capable ? (selected ? 'active' : '') : 'disabled';
397 const title = capable
398 ? (selected ? `Click to disable ${cat}` : `Click to enable ${cat}`)
399 : `Not supported`;
400 const onclick = capable ? `onclick="toggleAddon('${escapeHtml(a.id)}', '${cat}', ${!selected})"` : '';
401 return `<span class="badge ${cat} ${cls}" ${onclick} title="${title}">${cat.charAt(0).toUpperCase() + cat.slice(1)}</span>`;
402 }).join('');
403
404 return `
405 <div class="addon-card" id="card-${escapeAttr(a.id)}">
406 <div class="addon-top">
407 ${logoHtml}
408 <div class="addon-info">
409 <div class="addon-name">${escapeHtml(a.name)}</div>
410 <div class="addon-desc">${escapeHtml(a.description)}</div>
411 <div class="addon-url">${escapeHtml(a.transportUrl)}</div>
412 </div>
413 </div>
414 <div class="addon-controls">
415 <div class="badge-group">${badges}</div>
416 <button class="btn btn-danger" onclick="removeAddon('${escapeHtml(a.id)}')">Remove</button>
417 </div>
418 </div>`;
419 }).join('');
420}
421
422async function addAddon() {
423 const input = document.getElementById('addon-url');
424 const btn = document.getElementById('add-btn');
425 const btnText = document.getElementById('add-btn-text');
426 const url = input.value.trim();
427 if (!url) { showFeedback('Please enter a manifest URL', 'error'); return; }
428
429 btn.disabled = true;
430 btnText.innerHTML = '<span class="spinner"></span>';
431 showFeedback('Fetching manifest...', 'loading');
432
433 try {
434 const res = await fetch(API_BASE + '/api/addons', {
435 method: 'POST',
436 headers: { 'Content-Type': 'application/json' },
437 body: JSON.stringify({ url })
438 });
439 const data = await res.json();
440 if (data.error) {
441 showFeedback(data.error, 'error');
442 } else {
443 showFeedback(`Added "${data.addon.name}" successfully!`, 'success');
444 input.value = '';
445 loadAddons();
446 }
447 } catch (e) {
448 showFeedback('Network error: ' + e.message, 'error');
449 } finally {
450 btn.disabled = false;
451 btnText.textContent = 'Add';
452 }
453}
454
455async function removeAddon(id) {
456 if (!confirm('Remove this addon?')) return;
457 try {
458 await fetch(API_BASE + '/api/addons/' + encodeURIComponent(id), { method: 'DELETE' });
459 loadAddons();
460 } catch (e) {
461 showFeedback('Failed to remove: ' + e.message, 'error');
462 }
463}
464
465async function toggleAddon(id, category, enabled) {
466 try {
467 await fetch(API_BASE + '/api/addons/' + encodeURIComponent(id) + '/toggle', {
468 method: 'PUT',
469 headers: { 'Content-Type': 'application/json' },
470 body: JSON.stringify({ category, enabled })
471 });
472 loadAddons();
473 } catch (e) {
474 showFeedback('Failed to toggle: ' + e.message, 'error');
475 }
476}
477
478function escapeHtml(str) {
479 const div = document.createElement('div');
480 div.textContent = str || '';
481 return div.innerHTML;
482}
483
484function escapeAttr(str) {
485 return (str || '').replace(/[^a-zA-Z0-9._-]/g, '_');
486}
487
488// Enter key to add
489document.getElementById('addon-url').addEventListener('keydown', e => {
490 if (e.key === 'Enter') addAddon();
491});
492
493// Initial load
494loadAddons();
495</script>
496
497</body>
498</html>
plugin.video.jacktook/resources/web/upload.html +536 -0
……@@ -0,0 +1,536 @@
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <title>Jacktook - Upload Subtitle</title>
7 <style>
8 *,
9 *::before,
10 *::after {
11 box-sizing: border-box;
12 margin: 0;
13 padding: 0;
14 }
15
16 :root {
17 --bg: #0d1117;
18 --surface: #161b22;
19 --surface-hover: #1c2333;
20 --border: #30363d;
21 --text: #e6edf3;
22 --text-muted: #8b949e;
23 --accent: #58a6ff;
24 --accent-hover: #79c0ff;
25 --success: #3fb950;
26 --danger: #f85149;
27 --warning: #d29922;
28 --radius: 12px;
29 --radius-sm: 8px;
30 }
31
32 body {
33 font-family:
34 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
35 sans-serif;
36 background: var(--bg);
37 color: var(--text);
38 min-height: 100vh;
39 line-height: 1.5;
40 }
41
42 /* Header */
43 .header {
44 background: linear-gradient(135deg, #1a1e2e 0%, #0d1117 100%);
45 border-bottom: 1px solid var(--border);
46 padding: 20px 16px;
47 text-align: center;
48 position: sticky;
49 top: 0;
50 z-index: 100;
51 backdrop-filter: blur(10px);
52 }
53 .header h1 {
54 font-size: 1.4rem;
55 font-weight: 700;
56 background: linear-gradient(135deg, var(--accent), var(--accent-hover));
57 -webkit-background-clip: text;
58 -webkit-text-fill-color: transparent;
59 background-clip: text;
60 }
61 .header .subtitle {
62 font-size: 0.8rem;
63 color: var(--text-muted);
64 margin-top: 4px;
65 }
66
67 /* Container */
68 .container {
69 max-width: 600px;
70 margin: 0 auto;
71 padding: 16px;
72 }
73
74 /* Upload Area */
75 .upload-section {
76 background: var(--surface);
77 border: 1px solid var(--border);
78 border-radius: var(--radius);
79 padding: 24px;
80 margin-top: 20px;
81 }
82 .upload-section h2 {
83 font-size: 0.9rem;
84 color: var(--text-muted);
85 margin-bottom: 16px;
86 text-transform: uppercase;
87 letter-spacing: 0.5px;
88 }
89
90 .drop-zone {
91 border: 2px dashed var(--border);
92 border-radius: var(--radius);
93 padding: 40px 20px;
94 text-align: center;
95 transition: all 0.2s;
96 cursor: pointer;
97 position: relative;
98 }
99 .drop-zone:hover {
100 border-color: var(--accent);
101 background: var(--surface-hover);
102 }
103 .drop-zone.dragover {
104 border-color: var(--accent);
105 background: rgba(88, 166, 255, 0.1);
106 }
107 .drop-zone.disabled {
108 opacity: 0.5;
109 cursor: not-allowed;
110 }
111
112 .drop-icon {
113 font-size: 3rem;
114 margin-bottom: 12px;
115 }
116 .drop-text {
117 font-size: 1rem;
118 color: var(--text);
119 margin-bottom: 8px;
120 }
121 .drop-hint {
122 font-size: 0.8rem;
123 color: var(--text-muted);
124 }
125 .drop-formats {
126 font-size: 0.7rem;
127 color: var(--text-muted);
128 margin-top: 8px;
129 padding: 4px 10px;
130 background: var(--bg);
131 border-radius: var(--radius-sm);
132 display: inline-block;
133 }
134
135 .file-input {
136 display: none;
137 }
138
139 /* Selected File */
140 .selected-file {
141 background: var(--bg);
142 border: 1px solid var(--border);
143 border-radius: var(--radius-sm);
144 padding: 12px 16px;
145 margin-top: 16px;
146 display: flex;
147 align-items: center;
148 gap: 12px;
149 }
150 .file-icon {
151 font-size: 1.5rem;
152 }
153 .file-info {
154 flex: 1;
155 min-width: 0;
156 }
157 .file-name {
158 font-size: 0.9rem;
159 color: var(--text);
160 white-space: nowrap;
161 overflow: hidden;
162 text-overflow: ellipsis;
163 }
164 .file-size {
165 font-size: 0.75rem;
166 color: var(--text-muted);
167 }
168 .file-remove {
169 background: transparent;
170 border: none;
171 color: var(--danger);
172 cursor: pointer;
173 font-size: 1.2rem;
174 padding: 4px;
175 }
176 .file-remove:hover {
177 opacity: 0.8;
178 }
179
180 /* Buttons */
181 .btn {
182 display: inline-flex;
183 align-items: center;
184 justify-content: center;
185 gap: 6px;
186 padding: 12px 24px;
187 border: none;
188 border-radius: var(--radius-sm);
189 font-size: 0.9rem;
190 font-weight: 600;
191 cursor: pointer;
192 transition: all 0.2s;
193 width: 100%;
194 margin-top: 16px;
195 }
196 .btn-primary {
197 background: var(--accent);
198 color: #fff;
199 }
200 .btn-primary:hover:not(:disabled) {
201 background: var(--accent-hover);
202 }
203 .btn-primary:disabled {
204 opacity: 0.5;
205 cursor: not-allowed;
206 }
207
208 /* Progress */
209 .progress-container {
210 margin-top: 16px;
211 display: none;
212 }
213 .progress-container.visible {
214 display: block;
215 }
216 .progress-bar {
217 height: 8px;
218 background: var(--bg);
219 border-radius: 4px;
220 overflow: hidden;
221 }
222 .progress-fill {
223 height: 100%;
224 background: var(--accent);
225 width: 0%;
226 transition: width 0.3s;
227 }
228 .progress-text {
229 font-size: 0.8rem;
230 color: var(--text-muted);
231 margin-top: 8px;
232 text-align: center;
233 }
234
235 /* Feedback */
236 .feedback {
237 margin-top: 16px;
238 padding: 12px 16px;
239 border-radius: var(--radius-sm);
240 font-size: 0.9rem;
241 display: none;
242 }
243 .feedback.error {
244 display: block;
245 background: rgba(248, 81, 73, 0.1);
246 border: 1px solid var(--danger);
247 color: var(--danger);
248 }
249 .feedback.success {
250 display: block;
251 background: rgba(63, 185, 80, 0.1);
252 border: 1px solid var(--success);
253 color: var(--success);
254 }
255
256 /* Instructions */
257 .instructions {
258 background: var(--surface);
259 border: 1px solid var(--border);
260 border-radius: var(--radius);
261 padding: 16px;
262 margin-top: 20px;
263 }
264 .instructions h3 {
265 font-size: 0.85rem;
266 color: var(--text-muted);
267 margin-bottom: 8px;
268 }
269 .instructions p {
270 font-size: 0.8rem;
271 color: var(--text-muted);
272 line-height: 1.6;
273 }
274
275 /* Spinner */
276 .spinner {
277 display: inline-block;
278 width: 16px;
279 height: 16px;
280 border: 2px solid rgba(255, 255, 255, 0.3);
281 border-top-color: #fff;
282 border-radius: 50%;
283 animation: spin 0.6s linear infinite;
284 }
285 @keyframes spin {
286 to {
287 transform: rotate(360deg);
288 }
289 }
290
291 /* Responsive */
292 @media (max-width: 400px) {
293 .drop-zone {
294 padding: 30px 15px;
295 }
296 .drop-icon {
297 font-size: 2.5rem;
298 }
299 .drop-text {
300 font-size: 0.9rem;
301 }
302 }
303 </style>
304 </head>
305 <body>
306 <div class="header">
307 <h1>🔐 Jacktook</h1>
308 <div class="subtitle">Upload Subtitle</div>
309 </div>
310
311 <div class="container">
312 <!-- Upload Section -->
313 <div class="upload-section">
314 <h2>Drop your subtitle file here</h2>
315
316 <div class="drop-zone" id="dropZone">
317 <div class="drop-icon">📁</div>
318 <div class="drop-text">Click to select or drag & drop</div>
319 <div class="drop-hint">Supports SRT, ASS, SSA, SUB, VTT formats</div>
320 <div class="drop-formats">.srt .ass .ssa .sub .vtt .txt</div>
321 <input
322 type="file"
323 class="file-input"
324 id="fileInput"
325 accept=".srt,.ass,.ssa,.sub,.vtt,.txt"
326 />
327 </div>
328
329 <div class="selected-file" id="selectedFile" style="display: none">
330 <div class="file-icon">📄</div>
331 <div class="file-info">
332 <div class="file-name" id="fileName"></div>
333 <div class="file-size" id="fileSize"></div>
334 </div>
335 <button class="file-remove" id="removeFile" title="Remove">
336 &times;
337 </button>
338 </div>
339
340 <button class="btn btn-primary" id="uploadBtn" disabled>
341 <span id="btnText">Upload Subtitle</span>
342 </button>
343
344 <div class="progress-container" id="progressContainer">
345 <div class="progress-bar">
346 <div class="progress-fill" id="progressFill"></div>
347 </div>
348 <div class="progress-text" id="progressText">Uploading...</div>
349 </div>
350
351 <div class="feedback" id="feedback"></div>
352 </div>
353
354 <!-- Instructions -->
355 <div class="instructions">
356 <h3>How it works</h3>
357 <p>
358 Select a subtitle file from your device or drag and drop it here. The
359 file will be uploaded to your Kodi device and automatically attached
360 to the video you're about to watch. You can close this page once the
361 upload is complete.
362 </p>
363 </div>
364 </div>
365
366 <script>
367 const dropZone = document.getElementById("dropZone");
368 const fileInput = document.getElementById("fileInput");
369 const selectedFile = document.getElementById("selectedFile");
370 const fileName = document.getElementById("fileName");
371 const fileSize = document.getElementById("fileSize");
372 const removeFile = document.getElementById("removeFile");
373 const uploadBtn = document.getElementById("uploadBtn");
374 const btnText = document.getElementById("btnText");
375 const progressContainer = document.getElementById("progressContainer");
376 const progressFill = document.getElementById("progressFill");
377 const progressText = document.getElementById("progressText");
378 const feedback = document.getElementById("feedback");
379
380 let currentFile = null;
381
382 // Click to select
383
384 dropZone.addEventListener("click", () => {
385 if (!currentFile) fileInput.click();
386 });
387
388 // File selected via input
389 fileInput.addEventListener("change", (e) => {
390 if (e.target.files.length > 0) {
391 handleFile(e.target.files[0]);
392 }
393 });
394
395 // Drag & drop
396 dropZone.addEventListener("dragover", (e) => {
397 e.preventDefault();
398 if (!currentFile) dropZone.classList.add("dragover");
399 });
400
401 dropZone.addEventListener("dragleave", () => {
402 dropZone.classList.remove("dragover");
403 });
404
405 dropZone.addEventListener("drop", (e) => {
406 e.preventDefault();
407 dropZone.classList.remove("dragover");
408 if (!currentFile && e.dataTransfer.files.length > 0) {
409 handleFile(e.dataTransfer.files[0]);
410 }
411 });
412
413 function handleFile(file) {
414 // Validate extension
415 const validExtensions = [
416 ".srt",
417 ".ass",
418 ".ssa",
419 ".sub",
420 ".vtt",
421 ".txt",
422 ];
423 const ext = "." + file.name.split(".").pop().toLowerCase();
424
425 if (!validExtensions.includes(ext)) {
426 showFeedback(
427 "Invalid file type. Please select a subtitle file.",
428 "error",
429 );
430 return;
431 }
432
433 currentFile = file;
434 fileName.textContent = file.name;
435 fileSize.textContent = formatFileSize(file.size);
436 selectedFile.style.display = "flex";
437 dropZone.style.display = "none";
438 uploadBtn.disabled = false;
439 hideFeedback();
440 }
441
442 function formatFileSize(bytes) {
443 if (bytes === 0) return "0 Bytes";
444 const k = 1024;
445 const sizes = ["Bytes", "KB", "MB"];
446 const i = Math.floor(Math.log(bytes) / Math.log(k));
447 return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
448 }
449
450 removeFile.addEventListener("click", () => {
451 currentFile = null;
452 selectedFile.style.display = "none";
453 dropZone.style.display = "block";
454 uploadBtn.disabled = true;
455 fileInput.value = "";
456 hideFeedback();
457 });
458
459 uploadBtn.addEventListener("click", async () => {
460 if (!currentFile) return;
461
462 uploadBtn.disabled = true;
463 progressContainer.classList.add("visible");
464 btnText.innerHTML = '<span class="spinner"></span> Uploading...';
465 hideFeedback();
466
467 const formData = new FormData();
468 formData.append("file", currentFile);
469
470 // Simulate progress (since fetch doesn't support progress natively)
471 let progress = 0;
472 const progressInterval = setInterval(() => {
473 progress = Math.min(progress + 5, 90);
474 progressFill.style.width = progress + "%";
475 }, 100);
476
477 try {
478 const response = await fetch("/upload", {
479 method: "POST",
480 body: formData,
481 });
482
483 clearInterval(progressInterval);
484 progressFill.style.width = "100%";
485
486 const contentType = response.headers.get("content-type");
487
488 if (response.ok) {
489 clearInterval(progressInterval);
490 progressFill.style.width = "100%";
491
492 if (contentType && contentType.includes("application/json")) {
493 const data = await response.json();
494 showFeedback(
495 `Uploaded successfully: ${data.filename}`,
496 "success",
497 );
498 } else {
499 // HTML response - show success message
500 showFeedback(
501 "Upload complete! You can close this tab.",
502 "success",
503 );
504 }
505 btnText.textContent = "Upload Complete";
506 progressText.textContent =
507 "Done! Kodi will detect the file automatically.";
508 } else {
509 if (contentType && contentType.includes("application/json")) {
510 const data = await response.json();
511 showFeedback(data.error || "Upload failed", "error");
512 } else {
513 showFeedback("Upload failed. Please try again.", "error");
514 }
515 uploadBtn.disabled = false;
516 btnText.textContent = "Upload Subtitle";
517 }
518 } catch (error) {
519 clearInterval(progressInterval);
520 showFeedback("Network error: " + error.message, "error");
521 uploadBtn.disabled = false;
522 btnText.textContent = "Upload Subtitle";
523 }
524 });
525
526 function showFeedback(message, type) {
527 feedback.textContent = message;
528 feedback.className = "feedback " + type;
529 }
530
531 function hideFeedback() {
532 feedback.className = "feedback";
533 }
534 </script>
535 </body>
536</html>
plugin.video.jacktook/scripts/check_py37_compat.py +84 -0
……@@ -0,0 +1,84 @@
1import ast
2import os
3import sys
4
5# Disallowed base names used as generics in Python < 3.9
6PEP585_TYPES = {"list", "dict", "set", "tuple", "frozenset", "type", "callable"}
7
8# PEP 593, 604, 647, 612, 673 keywords
9UNSUPPORTED_NAMES = {
10 "Annotated", "Self", "TypeGuard", "ParamSpec", "Concatenate", "Required", "NotRequired"
11}
12
13def find_incompatible_syntax(file_path):
14 with open(file_path, "r", encoding="utf-8") as f:
15 try:
16 tree = ast.parse(f.read(), filename=file_path)
17 except SyntaxError as e:
18 return [("SYNTAX ERROR", e.lineno, str(e))]
19
20 issues = []
21
22 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 |"))
27 self.generic_visit(node)
28
29 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"))
34 self.generic_visit(node)
35
36 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"))
46 self.generic_visit(node)
47
48 def visit_Match(self, node):
49 issues.append(("match-case", node.lineno, "Structural pattern matching not supported in Python 3.7"))
50 self.generic_visit(node)
51
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"))
55 self.generic_visit(node)
56
57 Visitor().visit(tree)
58 return issues
59
60def scan_project(root_path):
61 results = []
62 for dirpath, _, filenames in os.walk(root_path):
63 for file in filenames:
64 if file.endswith(".py"):
65 filepath = os.path.join(dirpath, file)
66 issues = find_incompatible_syntax(filepath)
67 for kind, lineno, message in issues:
68 results.append((filepath, lineno, kind, message))
69 return results
70
71if __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)
78
79 if not findings:
80 print("✅ No Python 3.7 incompatibilities found.")
81 else:
82 print("❌ Found potential Python 3.7 incompatibilities:\n")
83 for path, line, kind, msg in findings:
84 print(f"{path}:{line} [{kind}] {msg}")
plugin.video.jacktook/scripts/manage_labels.py +142 -0
……@@ -0,0 +1,142 @@
1import sys
2import os
3import re
4
5def print_help():
6 print("""Usage: python manage_labels.py [operation] [po_file]
7
8Operations:
9 replace Convert numeric labels/help to text strings using PO file
10 restore Convert text labels/help back to numeric IDs using PO file
11
12Required arguments:
13 operation Either 'replace' or 'restore'
14 po_file Path to the strings.po file
15
16Features:
17 - Auto-generates missing IDs starting from 30800 during restore
18 - Creates .missing.po file for unresolved strings during restore
19 - Handles both label and help attributes
20 - Preserves empty strings and existing numeric IDs
21 - Maintains XML structure and formatting""")
22 sys.exit(0)
23
24def parse_po(po_file):
25 id_to_msg = {}
26 msg_to_id = {}
27 existing_ids = set()
28 current_id = current_msg = None
29
30 with open(po_file, 'r', encoding='utf-8') as f:
31 for line in f:
32 line = line.strip()
33 if line.startswith('msgctxt'):
34 current_id = line.split('"')[1][1:] # Extract number after #
35 existing_ids.add(current_id)
36 elif line.startswith('msgid'):
37 current_msg = line.split('"')[1]
38 elif line == '':
39 if current_id and current_msg:
40 id_to_msg[current_id] = current_msg
41 msg_to_id[current_msg] = current_id
42 current_id = current_msg = None
43
44 if current_id and current_msg:
45 id_to_msg[current_id] = current_msg
46 msg_to_id[current_msg] = current_id
47 existing_ids.add(current_id)
48
49 return id_to_msg, msg_to_id, existing_ids
50
51def process_restore(content, msg_to_id, existing_ids, po_file):
52 missing_entries = {}
53 current_new_id = 30800
54 existing_ids = set(map(int, existing_ids)) # Convert to integers
55
56 # Find all text labels that need replacement
57 matches = re.findall(r'(label|help)="(.*?)"', content)
58 text_labels = {m[1] for m in matches if not m[1].isdigit() and m[1] not in msg_to_id and m[1]}
59
60 # Assign new IDs to missing entries
61 for text in text_labels:
62 while current_new_id in existing_ids:
63 current_new_id += 1
64 missing_entries[text] = current_new_id
65 existing_ids.add(current_new_id)
66 current_new_id += 1
67
68 # Replace text labels with IDs
69 def replacer(m):
70 attr_type = m.group(1)
71 text = m.group(2)
72 if text == "": return f'{attr_type}=""'
73 if text.isdigit(): return f'{attr_type}="{text}"'
74 return f'{attr_type}="{msg_to_id.get(text, missing_entries.get(text, "XXXXX"))}"'
75
76 new_content = re.sub(r'(label|help)="(.*?)"', replacer, content)
77
78 # Generate missing.po file if needed
79 if missing_entries:
80 po_dir = os.path.dirname(po_file)
81 po_name = os.path.splitext(os.path.basename(po_file))[0]
82 missing_path = os.path.join(po_dir, f"{po_name}.missing.po")
83
84 with open(missing_path, 'w', encoding='utf-8') as f:
85 for text, num_id in sorted(missing_entries.items(), key=lambda x: x[1]):
86 f.write(f'msgctxt "#{num_id}"\nmsgid "{text}"\nmsgstr ""\n\n')
87
88 return new_content
89
90def process_replace(content, id_to_msg):
91 return re.sub(
92 r'(label|help)="(\d+)"',
93 lambda m: f'{m.group(1)}="{id_to_msg.get(m.group(2), m.group(2))}"',
94 content
95 )
96
97def main():
98 if '-h' in sys.argv or len(sys.argv) != 3:
99 print_help()
100
101 operation, po_file = sys.argv[1].lower(), sys.argv[2]
102
103 if operation not in ('replace', 'restore'):
104 print(f"Invalid operation: {operation}")
105 print_help()
106
107 # Validate paths
108 input_xml = f'resources/settings{"_strings" if operation == "restore" else ""}.xml'
109 output_xml = f'resources/settings{"_strings" if operation == "replace" else ""}.xml'
110
111 if not os.path.exists(input_xml):
112 print(f"Input file not found: {input_xml}")
113 sys.exit(1)
114
115 if not os.path.exists(po_file):
116 print(f"PO file not found: {po_file}")
117 sys.exit(1)
118
119 # Process files
120 try:
121 with open(input_xml, 'r', encoding='utf-8') as f:
122 content = f.read()
123
124 if operation == 'replace':
125 id_to_msg, _, _ = parse_po(po_file)
126 new_content = process_replace(content, id_to_msg)
127 else:
128 _, msg_to_id, existing_ids = parse_po(po_file)
129 new_content = process_restore(content, msg_to_id, existing_ids, po_file)
130
131 os.makedirs(os.path.dirname(output_xml), exist_ok=True)
132 with open(output_xml, 'w', encoding='utf-8') as f:
133 f.write(new_content)
134
135 print(f"Successfully generated {output_xml}")
136
137 except Exception as e:
138 print(f"Error: {str(e)}")
139 sys.exit(1)
140
141if __name__ == '__main__':
142 main()
plugin.video.jacktook/service.py +4 -0
……@@ -0,0 +1,4 @@
1from lib.services.background_service import JacktookMOnitor
2
3
4JacktookMOnitor().waitForAbort()
plugin.video.jacktook/tests/conftest.py +199 -0
……@@ -0,0 +1,199 @@
1import sys
2import os
3from unittest.mock import MagicMock
4
5# --- Create stub classes for xbmcgui to avoid type hint issues in Python 3.13 ---
6# Python 3.13 evaluates type hints at class definition time.
7# If xbmcgui.WindowXMLDialog is a MagicMock, using Optional[SomeClass] where
8# SomeClass inherits from it will break.
9
10
11class _XbmcguiStub:
12 """Stub module for xbmcgui with real classes for inheritance."""
13
14 class Window:
15 def __init__(self, *args, **kwargs):
16 pass
17
18 def show(self):
19 pass
20
21 def close(self):
22 pass
23
24 def setProperty(self, key, value):
25 pass
26
27 def getProperty(self, key):
28 return ""
29
30 def setFocus(self, control):
31 pass
32
33 def setFocusId(self, control_id):
34 pass
35
36 def getFocus(self):
37 return MagicMock()
38
39 def getFocusId(self):
40 return 0
41
42 def getControl(self, control_id):
43 return MagicMock()
44
45 def doModal(self):
46 pass
47
48 class WindowXML(Window):
49 def __init__(self, *args, **kwargs):
50 pass
51
52 class WindowXMLDialog(Window):
53 def __init__(self, *args, **kwargs):
54 pass
55
56 class Dialog:
57 def ok(self, *args, **kwargs):
58 return True
59
60 def yesno(self, *args, **kwargs):
61 return True
62
63 def select(self, *args, **kwargs):
64 return 0
65
66 def notification(self, *args, **kwargs):
67 pass
68
69 def textviewer(self, *args, **kwargs):
70 pass
71
72 def input(self, *args, **kwargs):
73 return ""
74
75 def browse(self, *args, **kwargs):
76 return ""
77
78 class DialogProgress:
79 def create(self, *args, **kwargs):
80 pass
81
82 def update(self, *args, **kwargs):
83 pass
84
85 def close(self):
86 pass
87
88 def iscanceled(self):
89 return False
90
91 class DialogProgressBG:
92 def create(self, *args, **kwargs):
93 pass
94
95 def update(self, *args, **kwargs):
96 pass
97
98 def close(self):
99 pass
100
101 def isFinished(self):
102 return False
103
104 class ListItem:
105 def __init__(self, *args, **kwargs):
106 pass
107
108 def setLabel(self, label):
109 pass
110
111 def setLabel2(self, label):
112 pass
113
114 def setInfo(self, *args, **kwargs):
115 pass
116
117 def setArt(self, *args, **kwargs):
118 pass
119
120 def setProperty(self, key, value):
121 pass
122
123 def getProperty(self, key):
124 return ""
125
126 def setPath(self, path):
127 pass
128
129 def getPath(self):
130 return ""
131
132 def setSubtitles(self, subtitleFiles):
133 pass
134
135 def addStreamInfo(self, cType, values):
136 pass
137
138 def addContextMenuItems(self, items):
139 pass
140
141 class ControlList:
142 def addItem(self, item):
143 pass
144
145 def addItems(self, items):
146 pass
147
148 def selectItem(self, item):
149 pass
150
151 def getSelectedItem(self):
152 return MagicMock()
153
154 def getSelectedPosition(self):
155 return 0
156
157 def size(self):
158 return 0
159
160 def reset(self):
161 pass
162
163 class ControlProgress:
164 def setPercent(self, percent):
165 pass
166
167 def getPercent(self):
168 return 0.0
169
170 # Fallback for any other attributes
171 def __getattr__(self, name):
172 return MagicMock()
173
174
175# --- Create mock modules ---
176xbmc_mock = MagicMock()
177xbmc_mock.getSupportedMedia.return_value = ".mp4|.mkv|.avi|.ts|.m2ts"
178xbmc_mock.LOGDEBUG = 0
179xbmc_mock.LOGINFO = 1
180xbmc_mock.LOGWARNING = 2
181xbmc_mock.LOGERROR = 3
182
183sys.modules["xbmc"] = xbmc_mock
184sys.modules["xbmcgui"] = _XbmcguiStub()
185sys.modules["xbmcaddon"] = MagicMock()
186sys.modules["xbmcvfs"] = MagicMock()
187sys.modules["xbmcplugin"] = MagicMock()
188sys.modules["pydevd"] = MagicMock()
189
190# Mock sys.argv to simulate Kodi addon arguments
191sys.argv = ["plugin.video.jacktook", "1", "/", ""]
192
193# Mock sqlite3.connect to avoid side effects during module imports
194import sqlite3
195
196sqlite3.connect = MagicMock(return_value=MagicMock())
197
198# Add the project root to sys.path to allow importing lib
199sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
plugin.video.jacktook/tests/fixtures/jackett_response.xml +34 -0
……@@ -0,0 +1,34 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<rss version="2.0" xmlns:torznab="http://torznab.com/schemas/2015/feed">
3 <channel>
4 <title>Jackett Indexer</title>
5 <item>
6 <title>Big Buck Bunny 1080p</title>
7 <guid>http://tracker.example.com/details/12345</guid>
8 <jackettindexer>
9 <id>example_tracker</id>
10 <text>Example Tracker</text>
11 </jackettindexer>
12 <link>magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678&amp;dn=Big+Buck+Bunny+1080p</link>
13 <size>1073741824</size>
14 <pubDate>Sun, 01 Jan 2023 12:00:00 +0000</pubDate>
15 <torznab:attr name="seeders" value="100" />
16 <torznab:attr name="peers" value="200" />
17 <torznab:attr name="infohash" value="1234567890abcdef1234567890abcdef12345678" />
18 </item>
19 <item>
20 <title>Big Buck Bunny 720p</title>
21 <guid>http://tracker.example.com/details/67890</guid>
22 <jackettindexer>
23 <id>example_tracker</id>
24 <text>Example Tracker</text>
25 </jackettindexer>
26 <link>magnet:?xt=urn:btih:abcdef1234567890abcdef1234567890123456&amp;dn=Big+Buck+Bunny+720p</link>
27 <size>536870912</size>
28 <pubDate>Sun, 01 Jan 2023 13:00:00 +0000</pubDate>
29 <torznab:attr name="seeders" value="50" />
30 <torznab:attr name="peers" value="20" />
31 <torznab:attr name="infohash" value="abcdef1234567890abcdef1234567890123456" />
32 </item>
33 </channel>
34</rss>
plugin.video.jacktook/tests/fixtures/realdebrid_response.json +34 -0
……@@ -0,0 +1,34 @@
1{
2 "user": {
3 "id": 12345,
4 "username": "testuser",
5 "email": "test@example.com",
6 "points": 1000,
7 "locale": "en",
8 "avatar": "https://example.com/avatar.png",
9 "type": "premium",
10 "premium": 1234567890,
11 "expiration": "2023-01-01T00:00:00.000Z"
12 },
13 "torrents": [
14 {
15 "id": "TORRENT123",
16 "filename": "Big Buck Bunny",
17 "hash": "1234567890abcdef1234567890abcdef12345678",
18 "bytes": 1073741824,
19 "host": "real-debrid.com",
20 "split": 10,
21 "progress": 100,
22 "status": "downloaded",
23 "added": "2023-01-01T00:00:00.000Z",
24 "links": [
25 "https://real-debrid.com/d/12345"
26 ],
27 "ended": "2023-01-01T00:10:00.000Z"
28 }
29 ],
30 "addMagnet": {
31 "id": "NEWTORRENT456",
32 "uri": "https://real-debrid.com/torrents/NEWTORRENT456"
33 }
34}
plugin.video.jacktook/tests/manual_easynews_test.py +93 -0
……@@ -0,0 +1,93 @@
1import sys
2import os
3from unittest.mock import MagicMock
4
5if "pytest" in sys.modules:
6 import pytest
7
8 pytest.skip("manual script not intended for pytest collection", allow_module_level=True)
9
10# --- Mock Kodi modules before importing lib ---
11mock_xbmc = MagicMock()
12mock_xbmc.LOGINFO = 1
13mock_xbmc.LOGERROR = 3
14sys.modules["xbmc"] = mock_xbmc
15
16mock_xbmcgui = MagicMock()
17sys.modules["xbmcgui"] = mock_xbmcgui
18
19mock_xbmcaddon = MagicMock()
20sys.modules["xbmcaddon"] = mock_xbmcaddon
21
22# --- Mock sqlite3 to avoid DB initialization errors ---
23import sqlite3
24
25sqlite3.connect = MagicMock(return_value=MagicMock())
26
27mock_xbmcplugin = MagicMock()
28sys.modules["xbmcplugin"] = mock_xbmcplugin
29
30mock_xbmcvfs = MagicMock()
31mock_xbmcvfs.translatePath.return_value = ":memory:"
32sys.modules["xbmcvfs"] = mock_xbmcvfs
33
34# --- Mock kodi utils ---
35mock_kodi_utils = MagicMock()
36mock_kodi_utils.dialog_text = lambda title, body: print(
37 f"\n[DIALOG: {title}]\n{body}\n"
38)
39mock_kodi_utils.kodilog = lambda msg, level=1: print(f"[LOG] {msg}")
40sys.modules["lib.utils.kodi.utils"] = mock_kodi_utils
41
42# Add project root to path
43sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
44
45
46def mock_notification(msg):
47 print(f"[NOTIFICATION] {msg}")
48
49
50# Now we can import our client
51try:
52 from lib.clients.easynews import Easynews
53except ImportError as e:
54 print(f"Error importing Easynews: {e}")
55 sys.exit(1)
56
57
58def main():
59 print("=== Easynews Manual Test Script ===")
60 user = input("Enter Easynews Username: ").strip()
61 password = input("Enter Easynews Password: ").strip()
62
63 if not user or not password:
64 print("Username and Password are required.")
65 return
66
67 # Initialize client
68 client = Easynews(user, password, timeout=20, notification=mock_notification)
69
70 print("\n--- Testing Account Info (get_info) ---")
71 try:
72 client.get_info()
73 except Exception as e:
74 print(f"Error during get_info: {e}")
75
76 print("\n--- Testing Search (Query: 'Firebreak') ---")
77 try:
78 results = client.search("Firebreak", mode="movie", media_type="movie")
79 print(f"Found {len(results)} results.")
80 for i, res in enumerate(results[:5]):
81 print(f"{i+1}. {res.title} ({res.quality}) - {res.size // (1024*1024)} MB")
82 print(f" URL: {res.url[:80]}...")
83
84 if i == 0:
85 print("\n--- Testing URL Resolve (First Result) ---")
86 resolved = client.resolve_url(res.url)
87 print(f"Resolved URL: {resolved}")
88 except Exception as e:
89 print(f"Error during search: {e}")
90
91
92if __name__ == "__main__":
93 main()
plugin.video.jacktook/tests/manual_torbox_cloud_test.py +148 -0
……@@ -0,0 +1,148 @@
1import json
2from pathlib import Path
3import xml.etree.ElementTree as ET
4import sys
5
6if "pytest" in sys.modules:
7 import pytest
8
9 pytest.skip("manual script not intended for pytest collection", allow_module_level=True)
10
11import requests
12
13
14SETTINGS_PATHS = [
15 Path("/home/spider/.kodi/userdata/addon_data/plugin.video.jacktook/settings.xml"),
16 Path("/home/spider/.var/app/tv.kodi.Kodi/data/userdata/addon_data/plugin.video.jacktook/settings.xml"),
17]
18BASE_URL = "https://api.torbox.app/v1/api"
19VIDEO_EXTENSIONS = (".mp4", ".mkv", ".avi", ".ts", ".m2ts")
20MAX_LINK_TESTS = 20
21
22
23def load_saved_torbox_settings():
24 for path in SETTINGS_PATHS:
25 if not path.exists():
26 continue
27
28 root = ET.parse(path).getroot()
29 settings = {
30 element.get("id"): (element.text or element.get("value") or "")
31 for element in root.findall(".//setting")
32 }
33 token = settings.get("torbox_token", "")
34 enabled = settings.get("torbox_enabled", "")
35 if token:
36 return {
37 "path": str(path),
38 "enabled": enabled,
39 "token": token,
40 }
41
42 raise RuntimeError("No saved Torbox token found in known addon settings files")
43
44
45def api_get(token, endpoint, params=None):
46 headers = {
47 "User-Agent": "Jacktook/1.0",
48 "Accept": "application/json",
49 "Authorization": f"Bearer {token}",
50 }
51 response = requests.get(
52 f"{BASE_URL}{endpoint}", headers=headers, params=params, timeout=20
53 )
54 response.raise_for_status()
55 return response.json()
56
57
58def pick_playable_file(files):
59 playable_files = []
60 for file_item in files or []:
61 name = str(file_item.get("name") or file_item.get("short_name") or "")
62 if name.lower().endswith(VIDEO_EXTENSIONS):
63 playable_files.append(file_item)
64
65 if not playable_files:
66 return None
67
68 return max(playable_files, key=lambda item: item.get("size", 0))
69
70
71def main():
72 saved = load_saved_torbox_settings()
73 token = saved["token"]
74
75 print("=== Torbox Cloud Manual Test ===")
76 print(f"Settings file: {saved['path']}")
77 print(f"Torbox enabled: {saved['enabled']}")
78 print(f"Token present: {bool(token)} (length={len(token)})")
79
80 me = api_get(token, "/user/me")
81 print(f"/user/me success: {me.get('success')}")
82
83 listing = api_get(token, "/torrents/mylist", params={"bypass_cache": "true"})
84 torrents = listing.get("data") or []
85 print(f"/torrents/mylist success: {listing.get('success')}")
86 print(f"Torrent count: {len(torrents)}")
87
88 downloadable = []
89 candidate_count = 0
90 skipped = {
91 "not_dict": 0,
92 "download_not_present": 0,
93 "no_playable_files": 0,
94 "link_failed": 0,
95 }
96
97 for torrent in torrents:
98 if not isinstance(torrent, dict):
99 skipped["not_dict"] += 1
100 continue
101
102 if not torrent.get("download_present"):
103 skipped["download_not_present"] += 1
104 continue
105
106 file_item = pick_playable_file(torrent.get("files") or [])
107 if not file_item:
108 skipped["no_playable_files"] += 1
109 continue
110
111 candidate_count += 1
112 if candidate_count > MAX_LINK_TESTS:
113 continue
114
115 link = api_get(
116 token,
117 "/torrents/requestdl",
118 params={
119 "token": token,
120 "torrent_id": torrent.get("id"),
121 "file_id": file_item.get("id"),
122 },
123 )
124 if not link.get("success") or not link.get("data"):
125 skipped["link_failed"] += 1
126 continue
127
128 downloadable.append(
129 {
130 "torrent": torrent.get("name"),
131 "file": file_item.get("name"),
132 "download": link.get("data"),
133 }
134 )
135
136 print("Skips:", json.dumps(skipped, indent=2))
137 print(f"Playable download candidates: {candidate_count}")
138 print(f"Link tests executed: {min(candidate_count, MAX_LINK_TESTS)}")
139 print(f"Downloadable entries found: {len(downloadable)}")
140
141 for index, item in enumerate(downloadable[:5], start=1):
142 print(f"\n[{index}] Torrent: {item['torrent']}")
143 print(f" File: {item['file']}")
144 print(f" URL: {item['download'][:140]}...")
145
146
147if __name__ == "__main__":
148 main()
plugin.video.jacktook/tests/mocks.py +21 -0
……@@ -0,0 +1,21 @@
1import sys
2from unittest.mock import MagicMock
3
4# Create mock modules for Kodi
5mock_modules = [
6 "xbmc",
7 "xbmcgui",
8 "xbmcaddon",
9 "xbmcvfs",
10 "pydevd", # Sometimes used in debugging
11]
12
13for module in mock_modules:
14 sys.modules[module] = MagicMock()
15
16# Mock specific attributes if needed
17import xbmcgui
18
19xbmcgui.ListItem = MagicMock
20xbmcgui.Dialog = MagicMock
21xbmcgui.DialogProgressBG = MagicMock
plugin.video.jacktook/tests/unit/test_alldebrid_auth.py +52 -0
……@@ -0,0 +1,52 @@
1from unittest.mock import MagicMock, patch
2
3from lib.services.debrid.auth import run_alldebrid_auth
4
5
6@patch(
7 "lib.services.debrid.auth.translation",
8 side_effect=lambda value: "%s" if value == 90604 else str(value),
9)
10@patch("lib.services.debrid.auth.set_setting")
11@patch("lib.services.debrid.auth.copy2clip")
12@patch("lib.services.debrid.auth.make_qrcode", return_value="/tmp/qr.png")
13@patch("lib.services.debrid.auth.ksleep")
14@patch("lib.services.debrid.auth.time", side_effect=[0, 1])
15@patch("lib.services.debrid.auth.QRProgressDialog")
16def test_run_alldebrid_auth_reauths_even_with_existing_token(
17 mock_dialog_cls,
18 mock_time,
19 mock_sleep,
20 mock_make_qrcode,
21 mock_copy2clip,
22 mock_set_setting,
23 mock_translation,
24):
25 client = MagicMock()
26 client.token = "stale-token"
27 client.get_ping.return_value = {
28 "data": {
29 "expires_in": 600,
30 "pin": "ABCD",
31 "check": "check-id",
32 "user_url": "https://alldebrid.com/pin/?pin=ABCD",
33 }
34 }
35 client.poll_auth.return_value = {"activated": True, "apikey": "fresh-token"}
36 client.get_user_info.return_value = {"user": {"username": "tester"}}
37
38 dialog = MagicMock()
39 dialog.iscanceled = False
40 mock_dialog_cls.return_value = dialog
41
42 run_alldebrid_auth(client)
43
44 client.initialize_headers.assert_called()
45 client.poll_auth.assert_called_once_with("check-id", "ABCD")
46 assert client.token == "fresh-token"
47 dialog.show_dialog.assert_called_once_with()
48 dialog.update_progress.assert_called_with(100, "90545")
49 dialog.close_dialog.assert_called_once_with()
50 mock_set_setting.assert_any_call("alldebrid_token", "fresh-token")
51 mock_set_setting.assert_any_call("alldebrid_authorized", "true")
52 mock_set_setting.assert_any_call("alldebrid_user", "tester")
plugin.video.jacktook/tests/unit/test_base_window_focus_cache.py +62 -0
……@@ -0,0 +1,62 @@
1from unittest.mock import MagicMock, patch
2
3from lib.gui.base_window import BaseWindow
4
5
6class _DummyWindow(BaseWindow):
7 def handle_action(self, action_id, control_id=None):
8 return None
9
10
11def test_set_cached_focus_uses_window_property_not_addon_setting():
12 window = _DummyWindow("dummy.xml", "")
13 window.CACHE_KEY = "focus-key"
14
15 with patch("lib.gui.base_window.xbmcgui.Window") as xbmc_window, patch(
16 "lib.gui.base_window.ADDON"
17 ) as addon:
18 win = xbmc_window.return_value
19 win.getProperty.return_value = ""
20
21 window.set_cached_focus(1000, 5)
22
23 addon.setSetting.assert_not_called()
24 win.setProperty.assert_called_once()
25 prop_name, prop_value = win.setProperty.call_args[0]
26 assert prop_name.startswith("jacktook.focus.")
27 assert prop_value == "[1000, 5]"
28
29
30def test_get_cached_focus_migrates_legacy_addon_setting_to_window_property():
31 window = _DummyWindow("dummy.xml", "")
32 window.CACHE_KEY = "legacy-focus-key"
33
34 with patch("lib.gui.base_window.xbmcgui.Window") as xbmc_window, patch(
35 "lib.gui.base_window.ADDON"
36 ) as addon:
37 win = xbmc_window.return_value
38 win.getProperty.return_value = ""
39 addon.getSetting.return_value = "[1000, 7]"
40
41 control_id, item_id = window.get_cached_focus()
42
43 assert control_id == 1000
44 assert item_id == 7
45 addon.getSetting.assert_called_once_with("legacy-focus-key")
46 addon.setSetting.assert_called_once_with("legacy-focus-key", "")
47 win.setProperty.assert_called_once()
48
49
50def test_get_cached_focus_returns_empty_when_cache_key_missing():
51 window = _DummyWindow("dummy.xml", "")
52 window.CACHE_KEY = ""
53
54 with patch("lib.gui.base_window.xbmcgui.Window") as xbmc_window, patch(
55 "lib.gui.base_window.ADDON"
56 ) as addon:
57 control_id, item_id = window.get_cached_focus()
58
59 assert control_id is None
60 assert item_id is None
61 addon.getSetting.assert_not_called()
62 xbmc_window.assert_not_called()
plugin.video.jacktook/tests/unit/test_base_window_source_extraction.py +63 -0
……@@ -0,0 +1,63 @@
1from unittest.mock import patch
2
3from lib.domain.torrent import TorrentStream
4from lib.gui.base_window import BaseWindow
5from lib.utils.general.utils import Indexer, IndexerType
6
7
8class _DummyWindow(BaseWindow):
9 def handle_action(self, action_id, control_id=None):
10 return None
11
12
13def test_extract_source_details_prefers_torrent_url_over_magnet_candidate():
14 source = TorrentStream(
15 type=IndexerType.TORRENT,
16 indexer=Indexer.JACKETT,
17 url="https://jackett.local/dl/1",
18 )
19
20 window = _DummyWindow("dummy.xml", "")
21
22 with patch(
23 "lib.gui.base_window.get_magnet_from_uri",
24 return_value=(
25 "magnet:?xt=urn:btih:abc123",
26 "abc123",
27 "https://filelist.io/download.php?id=1",
28 ),
29 ):
30 url, magnet, _ = window._extract_source_details(source)
31
32 assert url == "https://filelist.io/download.php?id=1"
33 assert magnet == ""
34
35
36def test_extract_source_details_skips_infohash_fallback_when_http_url_exists():
37 source = TorrentStream(
38 type=IndexerType.TORRENT,
39 indexer=Indexer.JACKETT,
40 url="https://example.com/file.torrent",
41 infoHash="abc123",
42 )
43
44 window = _DummyWindow("dummy.xml", "")
45 url, magnet, _ = window._extract_source_details(source)
46
47 assert url == "https://example.com/file.torrent"
48 assert magnet == ""
49
50
51def test_extract_source_details_uses_infohash_fallback_without_http_url():
52 source = TorrentStream(
53 type=IndexerType.TORRENT,
54 indexer=Indexer.JACKETT,
55 guid="urn:btih:abc123",
56 infoHash="abc123",
57 )
58
59 window = _DummyWindow("dummy.xml", "")
60 url, magnet, _ = window._extract_source_details(source)
61
62 assert url == ""
63 assert magnet == "magnet:?xt=urn:btih:abc123"
plugin.video.jacktook/tests/unit/test_debrid_utils.py +70 -0
……@@ -0,0 +1,70 @@
1from unittest.mock import MagicMock, patch
2
3from lib.utils.debrid.debrid_utils import get_magnet_from_uri
4
5
6def test_get_magnet_from_uri_follows_redirect_to_magnet():
7 redirect_response = MagicMock()
8 redirect_response.status_code = 302
9 redirect_response.headers = {
10 "Location": "magnet:?xt=urn:btih:1234567890ABCDEF1234567890ABCDEF12345678&dn=Test"
11 }
12
13 with patch(
14 "lib.utils.debrid.debrid_utils.requests.get", return_value=redirect_response
15 ) as mock_get:
16 magnet, info_hash, torrent_url = get_magnet_from_uri(
17 "https://jackett.local/dl/test"
18 )
19
20 assert magnet.startswith("magnet:?xt=urn:btih:1234567890ABCDEF")
21 assert info_hash == "1234567890abcdef1234567890abcdef12345678"
22 assert torrent_url == ""
23 mock_get.assert_called_once()
24
25
26def test_get_magnet_from_uri_follows_http_redirect_chain():
27 first_response = MagicMock()
28 first_response.status_code = 302
29 first_response.headers = {"Location": "/download/next"}
30
31 second_response = MagicMock()
32 second_response.status_code = 302
33 second_response.headers = {
34 "Location": "magnet:?xt=urn:btih:ABCDEF1234567890ABCDEF1234567890ABCDEF12&dn=Test"
35 }
36
37 with patch(
38 "lib.utils.debrid.debrid_utils.requests.get",
39 side_effect=[first_response, second_response],
40 ) as mock_get:
41 magnet, info_hash, torrent_url = get_magnet_from_uri(
42 "https://jackett.local/dl/test"
43 )
44
45 assert magnet.startswith("magnet:?xt=urn:btih:ABCDEF1234567890")
46 assert info_hash == "abcdef1234567890abcdef1234567890abcdef12"
47 assert torrent_url == ""
48 assert mock_get.call_count == 2
49
50
51def test_get_magnet_from_uri_preserves_torrent_url_for_playback():
52 torrent_response = MagicMock()
53 torrent_response.status_code = 200
54 torrent_response.url = "https://jackett.local/download/file.torrent"
55 torrent_response.content = b"torrent-bytes"
56
57 with patch(
58 "lib.utils.debrid.debrid_utils.requests.get", return_value=torrent_response
59 ) as mock_get, patch(
60 "lib.utils.debrid.debrid_utils.extract_torrent_metadata",
61 return_value="magnet:?xt=urn:btih:AAAABBBBCCCCDDDDEEEEFFFF0000111122223333&dn=Test",
62 ):
63 magnet, info_hash, torrent_url = get_magnet_from_uri(
64 "https://jackett.local/dl/test"
65 )
66
67 assert magnet.startswith("magnet:?xt=urn:btih:AAAABBBBCCCCDDDDEEEEFFFF")
68 assert info_hash == "aaaabbbbccccddddeeeeffff0000111122223333"
69 assert torrent_url == "https://jackett.local/dl/test"
70 mock_get.assert_called_once()
plugin.video.jacktook/tests/unit/test_downloader.py +68 -0
……@@ -0,0 +1,68 @@
1import importlib
2import sys
3from unittest.mock import MagicMock, patch
4
5
6def _load_downloader_module():
7 if "lib.downloader" in sys.modules:
8 return importlib.reload(sys.modules["lib.downloader"])
9 return importlib.import_module("lib.downloader")
10
11
12def test_normalize_file_name_preserves_valid_video_extension():
13 downloader = _load_downloader_module()
14
15 assert downloader.normalize_file_name("Movie.mp4", "https://example.com/Movie.mkv") == "Movie.mp4"
16
17
18def test_normalize_file_name_uses_url_extension_when_title_has_none():
19 downloader = _load_downloader_module()
20
21 assert (
22 downloader.normalize_file_name(
23 "Project Hail Mary",
24 "https://example.com/files/Project%20Hail%20Mary.mkv",
25 )
26 == "Project Hail Mary.mkv"
27 )
28
29
30def test_normalize_file_name_ignores_tv_episode_like_suffix():
31 downloader = _load_downloader_module()
32
33 assert (
34 downloader.normalize_file_name(
35 "Game of Thrones.S01E01",
36 "https://example.com/files/Game%20of%20Thrones.mkv",
37 )
38 == "Game of Thrones_S01E01.mkv"
39 )
40
41
42def test_handle_download_file_passes_url_to_normalizer():
43 downloader = _load_downloader_module()
44
45 downloader_instance = MagicMock()
46 downloader_instance.is_cancelled = False
47
48 with patch.object(downloader.os.path, "exists", return_value=True), patch.object(
49 downloader, "normalize_file_name", return_value="Movie.mkv"
50 ) as normalize_file_name, patch.object(
51 downloader, "Downloader", return_value=downloader_instance
52 ) as downloader_cls, patch.object(downloader.cancel_flag_cache, "set") as cache_set:
53 downloader.handle_download_file(
54 {
55 "destination": "/downloads",
56 "file_name": "Movie",
57 "url": "https://example.com/Movie.mkv",
58 }
59 )
60
61 normalize_file_name.assert_called_once_with("Movie", "https://example.com/Movie.mkv")
62 downloader_cls.assert_called_once_with(
63 url="https://example.com/Movie.mkv",
64 destination="/downloads",
65 name="Movie.mkv",
66 )
67 cache_set.assert_called_once_with("/downloads/Movie.mkv", False)
68 downloader_instance.run.assert_called_once_with()
plugin.video.jacktook/tests/unit/test_extras_window.py +33 -0
……@@ -0,0 +1,33 @@
1from unittest.mock import patch
2
3from lib.gui.extras_window import ExtrasWindow
4
5
6def test_extras_trailer_button_uses_play_trailer_route():
7 window = ExtrasWindow(
8 "extras.xml",
9 "/tmp",
10 item_information={
11 "tmdb_id": "4604",
12 "media_type": "tv",
13 "title": "Smallville",
14 "imdb_id": "tt0279600",
15 },
16 )
17
18 with patch(
19 "lib.gui.extras_window.build_url",
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:
22 window._handle_click(11)
23
24 build_url_mock.assert_called_once_with(
25 "play_trailer",
26 tmdb_id="4604",
27 media_type="tv",
28 title="Smallville",
29 )
30 close_mock.assert_called_once_with()
31 execute_builtin.assert_called_once_with(
32 "PlayMedia(plugin://plugin.video.jacktook/?action=play_trailer&tmdb_id=4604&media_type=tv&title=Smallville)"
33 )
plugin.video.jacktook/tests/unit/test_general_utils.py +95 -0
……@@ -0,0 +1,95 @@
1import pytest
2from lib.utils.general.utils import (
3 is_url,
4 is_magnet_link,
5 info_hash_to_magnet,
6 supported_video_extensions,
7 extract_publish_date,
8 extract_release_group,
9 unicode_flag_to_country_code,
10 is_video,
11 get_random_color,
12 format_season_episode,
13)
14
15
16def test_is_url():
17 assert is_url("http://example.com") is not None
18 assert is_url("https://example.com/path?query=1") is not None
19 assert is_url("ftp://example.com") is not None
20 assert not is_url("not a url")
21 assert not is_url("")
22
23
24def test_is_magnet_link():
25 magnet = "magnet:?xt=urn:btih:1234567890abcdef1234567890abcdef12345678"
26 assert is_magnet_link(magnet) == magnet
27 assert is_magnet_link("http://example.com") is None
28 assert is_magnet_link("not a magnet") is None
29
30
31def test_info_hash_to_magnet():
32 info_hash = "1234567890abcdef1234567890abcdef12345678"
33 magnet = info_hash_to_magnet(info_hash)
34 assert info_hash in magnet
35 assert magnet.startswith("magnet:?xt=urn:btih:")
36
37
38def test_supported_video_extensions():
39 exts = supported_video_extensions()
40 assert isinstance(exts, (list, tuple))
41 assert ".mp4" in exts
42 assert ".mkv" in exts
43 assert ".avi" in exts
44
45
46def test_extract_publish_date():
47 assert extract_publish_date("2023-10-27") == "2023-10-27"
48 assert extract_publish_date("Released on 2022-01-01") == "2022-01-01"
49 assert extract_publish_date("NoDate") == ""
50
51
52def test_extract_release_group():
53 assert extract_release_group("Movie.Title.2023.1080p-Groupname") == "Groupname"
54 assert extract_release_group("[Groupname] Movie Title") == "Groupname"
55 assert extract_release_group("Movie.Title.mkv") == ""
56 assert extract_release_group(None) == ""
57
58
59def test_unicode_flag_to_country_code():
60 # US Flag 🇺🇸 -> us
61 assert unicode_flag_to_country_code("🇺🇸") == "us"
62 # GB Flag 🇬🇧 -> gb
63 assert unicode_flag_to_country_code("🇬🇧") == "gb"
64 # Invalid length
65 assert unicode_flag_to_country_code("ABC") == "Invalid flag Unicode"
66
67
68def test_is_video():
69 assert is_video("movie.mp4") is True
70 assert is_video("show.mkv") is True
71 assert is_video("document.txt") is False
72 assert is_video("archive.zip") is False
73
74
75def test_get_random_color():
76 color = get_random_color("Netflix")
77 # Expected format: [B][COLOR FFRRGGBB]Netflix[/COLOR][/B]
78 assert color.startswith("[B][COLOR FF")
79 assert color.endswith("]Netflix[/COLOR][/B]")
80
81
82@pytest.mark.parametrize(
83 "season, episode, expected",
84 [
85 (1, 2, "S01E02"),
86 ("1", "2", "S01E02"),
87 ("01", "02", "S01E02"),
88 ("special", "finale", "SspecialEfinale"),
89 (None, "2", "E02"),
90 ("3", None, "S03"),
91 ("", "", ""),
92 ],
93)
94def test_format_season_episode(season, episode, expected):
95 assert format_season_episode(season, episode) == expected
plugin.video.jacktook/tests/unit/test_jackett.py +165 -0
……@@ -0,0 +1,165 @@
1import pytest
2from unittest.mock import MagicMock, patch
3import os
4
5from lib.clients.jackett import Jackett
6from lib.domain.torrent import TorrentStream
7
8# Load fixture data
9FIXTURE_PATH = os.path.join(
10 os.path.dirname(__file__), "../fixtures/jackett_response.xml"
11)
12with open(FIXTURE_PATH, "r") as f:
13 XML_RESPONSE = f.read()
14
15
16@pytest.fixture
17def jackett_client():
18 # Mock notification function
19 mock_notification = MagicMock()
20 mock_session = MagicMock()
21 # Mock settings
22 with patch("lib.clients.jackett.get_jackett_timeout", return_value=10):
23 client = Jackett(
24 host="http://localhost",
25 apikey="testapikey",
26 port="9117",
27 notification=mock_notification,
28 session=mock_session,
29 )
30 return client
31
32
33def test_jackett_initialization(jackett_client):
34 assert jackett_client.host == "http://localhost"
35 assert jackett_client.apikey == "testapikey"
36 assert "9117" in jackett_client.base_url
37
38
39@patch("lib.clients.jackett.get_setting", return_value=False) # Disable season packs
40@patch("lib.clients.jackett.get_jackett_timeout", return_value=10)
41def test_search_movie(mock_timeout, mock_get_setting, jackett_client):
42 # Setup mock response
43 mock_response = MagicMock()
44 mock_response.status_code = 200
45 mock_response.content = XML_RESPONSE.encode("utf-8")
46
47 # Configure injected mock session
48 jackett_client.session.get.return_value = mock_response
49
50 results = jackett_client.search("Big Buck Bunny", "movies")
51
52 assert results is not None
53 assert len(results) == 2
54
55 # Verify first result
56 res1 = results[0]
57 assert isinstance(res1, TorrentStream)
58 assert res1.title == "Big Buck Bunny 1080p"
59 assert res1.seeders == 100
60 assert res1.peers == 200
61 assert res1.size == "1073741824"
62 assert res1.indexer == "Jackett"
63 assert res1.infoHash == "1234567890abcdef1234567890abcdef12345678"
64
65
66@patch("lib.clients.jackett.get_setting", return_value=True) # Enable season packs
67@patch("lib.clients.jackett.get_jackett_timeout", return_value=10)
68def test_search_tv_show(mock_timeout, mock_get_setting, jackett_client):
69 # Setup mock response
70 mock_response = MagicMock()
71 mock_response.status_code = 200
72 mock_response.content = XML_RESPONSE.encode("utf-8")
73
74 # Configure injected mock session
75 jackett_client.session.get.return_value = mock_response
76
77 # Search for S01E01
78 results = jackett_client.search("Big Buck Bunny", "tv", season=1, episode=1)
79
80 assert results is not None
81 # Should call twice: once for episode, once for season pack
82 assert jackett_client.session.get.call_count == 2
83 # Results are extended from both calls (duplicate xml used for simplicity)
84 assert len(results) == 4
85
86
87def test_parse_response_error(jackett_client):
88 mock_response = MagicMock()
89 mock_response.content = b"Invalid XML"
90
91 results = jackett_client.parse_response(mock_response)
92 assert results is None
93
94
95def test_extract_result(jackett_client):
96 item = {
97 "title": "Test Title",
98 "link": "magnet:?xt=urn:btih:hash",
99 "size": "100",
100 "pubDate": "2023-01-01",
101 "jackettindexer": {"#text": "Tracker"},
102 "torznab:attr": [
103 {"@name": "seeders", "@value": "10"},
104 {"@name": "peers", "@value": "5"},
105 {"@name": "infohash", "@value": "hash"},
106 ],
107 }
108
109 results = []
110 jackett_client.extract_result(results, item)
111
112 assert len(results) == 1
113 res = results[0]
114 assert res.title == "Test Title"
115 assert res.seeders == 10
116 assert res.peers == 5
117
118
119def test_jackett_url_with_year_parameter(jackett_client):
120 url = jackett_client._build_url(
121 "http://localhost:9117/api/v2.0/indexers/all/results/torznab/api?apikey=test",
122 "Inception",
123 "movies",
124 year=2010,
125 )
126
127 assert "&year=2010" in url
128 assert "&t=movie" in url
129 assert "&q=Inception" in url
130
131
132def test_jackett_url_without_year_parameter(jackett_client):
133 url = jackett_client._build_url(
134 "http://localhost:9117/api/v2.0/indexers/all/results/torznab/api?apikey=test",
135 "Inception",
136 "movies",
137 )
138
139 assert "&year=" not in url
140 assert "&t=movie" in url
141 assert "&q=Inception" in url
142
143
144def test_jackett_search_with_year_passes_to_indexer(jackett_client):
145 mock_response = MagicMock()
146 mock_response.status_code = 200
147 mock_response.content = b"""<?xml version="1.0" encoding="UTF-8"?>
148 <rss version="2.0" xmlns:torznab="http://torznab.com/schemas/2015/feed">
149 <channel>
150 <item>
151 <title>Test</title>
152 <size>100</size>
153 <pubDate>2023-01-01</pubDate>
154 </item>
155 </channel>
156 </rss>"""
157
158 jackett_client.session.get.return_value = mock_response
159
160 with patch("lib.clients.jackett.get_jackett_timeout", return_value=10):
161 jackett_client.search("Inception", "movies", year=2010)
162
163 call_args = jackett_client.session.get.call_args
164 url = call_args[0][0]
165 assert "&year=2010" in url
plugin.video.jacktook/tests/unit/test_kodi_utils.py +44 -0
……@@ -0,0 +1,44 @@
1from unittest.mock import patch
2
3from lib.utils.kodi import utils
4
5
6def 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 utils.xbmcaddon,
9 "Addon",
10 ) as addon_cls:
11 assert utils.is_youtube_addon_enabled() is False
12
13 get_cond_visibility.assert_called_once_with(
14 f"System.HasAddon({utils.YOUTUBE_ADDON_ID})"
15 )
16 addon_cls.assert_not_called()
17
18
19def 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 utils.xbmcaddon,
22 "Addon",
23 return_value=object(),
24 ) as addon_cls:
25 assert utils.is_youtube_addon_enabled() is True
26
27 get_cond_visibility.assert_called_once_with(
28 f"System.HasAddon({utils.YOUTUBE_ADDON_ID})"
29 )
30 addon_cls.assert_called_once_with(utils.YOUTUBE_ADDON_ID)
31
32
33def 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 utils.xbmcaddon,
36 "Addon",
37 side_effect=RuntimeError,
38 ) as addon_cls:
39 assert utils.is_youtube_addon_enabled() is False
40
41 get_cond_visibility.assert_called_once_with(
42 f"System.HasAddon({utils.YOUTUBE_ADDON_ID})"
43 )
44 addon_cls.assert_called_once_with(utils.YOUTUBE_ADDON_ID)
plugin.video.jacktook/tests/unit/test_library_menu_clear.py +99 -0
……@@ -0,0 +1,99 @@
1from lib.nav import library_history
2from lib.utils.views import library
3
4
5def test_library_menu_adds_clear_context_menu(monkeypatch):
6 added = []
7 end_calls = []
8
9 class _ListItem:
10 def __init__(self, label=""):
11 self.label = label
12 self.context_menu = []
13
14 def addContextMenuItems(self, items):
15 self.context_menu.extend(items)
16
17 monkeypatch.setattr(library_history, "build_list_item", lambda name, icon: _ListItem(name))
18 monkeypatch.setattr(
19 library_history,
20 "translation",
21 lambda value: {90202: "My TvShows", 90203: "My Movies", 90201: "Library", 90021: "Upcoming Episodes", 90690: "Clear All Items"}.get(value, str(value)),
22 )
23 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))
25 monkeypatch.setattr(
26 library_history,
27 "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()),
31 ),
32 )
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 monkeypatch.setattr(
35 library_history,
36 "add_directory_items_batch",
37 lambda items: added.extend(list_item for _url, list_item, _is_folder in items),
38 )
39
40 library_history.library_menu({})
41
42 assert added[0].label == "My TvShows"
43 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)")]
46 assert end_calls == [{"cache": False}]
47
48
49def test_maybe_clear_library_clears_requested_mode_when_confirmed(monkeypatch):
50 cleared = []
51
52 monkeypatch.setattr(library_history, "translation", lambda value: str(value))
53 monkeypatch.setattr(library_history.Dialog, "yesno", lambda self, *args, **kwargs: True)
54 monkeypatch.setattr(
55 "lib.utils.views.library.clear_library_items",
56 lambda params: cleared.append(params),
57 )
58
59 library_history._maybe_clear_library("tv", {"clear": 1})
60
61 assert cleared == [{"mode": "tv"}]
62
63
64def test_clear_library_items_removes_only_requested_mode(monkeypatch):
65 database = {
66 "Show 1": {"mode": "tv"},
67 "Movie 1": {"mode": "movies"},
68 "Movie 2": {"mode": "movie"},
69 }
70
71 monkeypatch.setattr(
72 library.PickleDatabase,
73 "get_key",
74 lambda self, key: database,
75 )
76 monkeypatch.setattr(
77 library.PickleDatabase,
78 "delete_item",
79 lambda self, key, subkey, commit=True: database.pop(subkey, None),
80 )
81 monkeypatch.setattr(
82 library.PickleDatabase,
83 "commit",
84 lambda self: None,
85 )
86 deleted = []
87 monkeypatch.setattr(library.cache, "delete", lambda key: deleted.append(key))
88 monkeypatch.setattr(library, "notification", lambda *args, **kwargs: None)
89 monkeypatch.setattr(library, "translation", lambda value: str(value))
90
91 import xbmc
92
93 monkeypatch.setattr(xbmc, "executebuiltin", lambda *args, **kwargs: None)
94
95 removed = library.clear_library_items({"mode": "movies"})
96
97 assert database == {"Show 1": {"mode": "tv"}}
98 assert deleted == ["library_view|tv", "library_view|movies"]
99 assert removed == 2
plugin.video.jacktook/tests/unit/test_library_stremio.py +192 -0
……@@ -0,0 +1,192 @@
1from unittest.mock import MagicMock
2
3from lib.utils.views import library
4
5
6def test_get_library_entries_prioritizes_stremio_metadata(monkeypatch):
7 monkeypatch.setattr(library.cache, "get", lambda key: None)
8 monkeypatch.setattr(library.cache, "set", lambda *args, **kwargs: None)
9
10 tmdb_calls = []
11 monkeypatch.setattr(library, "tmdb_get", lambda *args, **kwargs: tmdb_calls.append((args, kwargs)))
12
13 entries = library._get_library_entries(
14 [
15 (
16 "Kitsu Show",
17 {
18 "source": "stremio_catalog",
19 "title": "Kitsu Show",
20 "overview": "Catalog plot",
21 "poster": "poster.jpg",
22 "fanart": "fanart.jpg",
23 "genres": ["Anime"],
24 "mode": "tv",
25 "ids": {"imdb_id": "tt123", "original_id": "kitsu:show"},
26 },
27 )
28 ],
29 "tv",
30 )
31
32 assert tmdb_calls == []
33 assert entries[0]["is_stremio"] is True
34 assert entries[0]["details"]["title"] == "Kitsu Show"
35 assert entries[0]["details"]["overview"] == "Catalog plot"
36 assert entries[0]["details"]["poster"] == "poster.jpg"
37
38
39def test_get_library_item_url_uses_stremio_seasons_route_for_shows():
40 entry = {
41 "title": "Kitsu Show",
42 "is_stremio": True,
43 "data": {
44 "title": "Kitsu Show",
45 "addon_url": "https://anime-kitsu.strem.fun",
46 "catalog_type": "anime",
47 "meta_id": "kitsu:123",
48 "ids": {"original_id": "kitsu:123"},
49 },
50 }
51
52 url = library._get_library_item_url(entry, "tv")
53
54 assert url.startswith("plugin://")
55 assert "action=list_stremio_seasons" in url
56 assert "meta_id=kitsu%3A123" in url
57
58
59def test_get_library_item_url_uses_stremio_movie_route_for_movies():
60 entry = {
61 "title": "Kitsu Movie",
62 "is_stremio": True,
63 "data": {
64 "title": "Kitsu Movie",
65 "overview": "Catalog plot",
66 "poster": "poster.jpg",
67 "fanart": "fanart.jpg",
68 "genres": ["Anime"],
69 "addon_url": "https://anime-kitsu.strem.fun",
70 "catalog_type": "anime",
71 "meta_id": "kitsu:456",
72 "ids": {"original_id": "kitsu:456"},
73 },
74 }
75
76 url = library._get_library_item_url(entry, "movies")
77
78 assert url.startswith("plugin://")
79 assert "action=list_stremio_movie" in url
80 assert "meta_id=kitsu%3A456" in url
81
82
83def test_show_library_items_renders_stremio_entry_without_tmdb(monkeypatch):
84 added = []
85
86 class _ListItem:
87 def __init__(self, label=""):
88 self.label = label
89 self.context_menu = []
90 self.properties = {}
91
92 def getVideoInfoTag(self):
93 return MagicMock()
94
95 def addContextMenuItems(self, items):
96 self.context_menu.extend(items)
97
98 def setArt(self, *args, **kwargs):
99 pass
100
101 def setProperty(self, key, value):
102 self.properties[key] = value
103
104 monkeypatch.setattr(library, "make_list_item", lambda label="", path="", offscreen=True: _ListItem(label=label))
105 monkeypatch.setattr(library, "set_pluging_category", lambda *args, **kwargs: None)
106 monkeypatch.setattr(library, "setContent", lambda *args, **kwargs: None)
107 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)))
109 monkeypatch.setattr(library, "set_media_infoTag", lambda *args, **kwargs: None)
110 monkeypatch.setattr(
111 library,
112 "add_directory_items_batch",
113 lambda items: added.extend((url, listitem.label, is_folder) for url, listitem, is_folder in items),
114 )
115 monkeypatch.setattr(
116 library.PickleDatabase,
117 "get_key",
118 lambda self, key: {
119 "Kitsu Show": {
120 "source": "stremio_catalog",
121 "title": "Kitsu Show",
122 "overview": "Catalog plot",
123 "poster": "poster.jpg",
124 "fanart": "fanart.jpg",
125 "mode": "tv",
126 "addon_url": "https://anime-kitsu.strem.fun",
127 "catalog_type": "anime",
128 "meta_id": "kitsu:123",
129 "ids": {"original_id": "kitsu:123"},
130 "timestamp": "Sun, 05 Apr 2026 12:00 AM",
131 }
132 },
133 )
134 monkeypatch.setattr(library.cache, "get", lambda key: None)
135 monkeypatch.setattr(library.cache, "set", lambda *args, **kwargs: None)
136 monkeypatch.setattr(library, "tmdb_get", lambda *args, **kwargs: None)
137
138 library.show_library_items(mode="tv")
139
140 assert added == [(added[0][0], "Kitsu Show", True)]
141 assert "action=list_stremio_seasons" in added[0][0]
142
143
144def test_library_treats_entries_with_stremio_route_fields_as_stremio(monkeypatch):
145 monkeypatch.setattr(library.cache, "get", lambda key: None)
146 monkeypatch.setattr(library.cache, "set", lambda *args, **kwargs: None)
147
148 tmdb_calls = []
149 monkeypatch.setattr(library, "tmdb_get", lambda *args, **kwargs: tmdb_calls.append((args, kwargs)))
150
151 entries = library._get_library_entries(
152 [
153 (
154 "Jigokuraku 2nd Season",
155 {
156 "title": "Jigokuraku 2nd Season",
157 "overview": "Catalog plot",
158 "poster": "poster.jpg",
159 "fanart": "fanart.jpg",
160 "mode": "tv",
161 "addon_url": "https://anime-kitsu.strem.fun",
162 "catalog_type": "anime",
163 "meta_id": "kitsu:jigokuraku-2",
164 "ids": {"imdb_id": "tt123"},
165 },
166 )
167 ],
168 "tv",
169 )
170
171 assert tmdb_calls == []
172 assert entries[0]["is_stremio"] is True
173 assert entries[0]["data"]["meta_id"] == "kitsu:jigokuraku-2"
174
175
176def test_get_library_item_url_uses_original_id_when_meta_id_missing():
177 entry = {
178 "title": "Jigokuraku 2nd Season",
179 "is_stremio": True,
180 "data": {
181 "title": "Jigokuraku 2nd Season",
182 "addon_url": "https://anime-kitsu.strem.fun",
183 "catalog_type": "anime",
184 "ids": {"original_id": "kitsu:jigokuraku-2"},
185 },
186 }
187
188 url = library._get_library_item_url(entry, "tv")
189
190 assert url.startswith("plugin://")
191 assert "action=list_stremio_seasons" in url
192 assert "meta_id=kitsu%3Ajigokuraku-2" in url
plugin.video.jacktook/tests/unit/test_library_view.py +62 -0
……@@ -0,0 +1,62 @@
1from unittest.mock import MagicMock, patch
2
3from lib.utils.views import library
4from lib.utils.general.utils import add_to_library, remove_from_library
5
6
7def test_get_library_entries_uses_cached_materialized_list():
8 items = [("Movie", {"ids": {"tmdb_id": 1}})]
9 cached_entries = [("Movie", {"ids": {"tmdb_id": 1}}, {"id": 1, "title": "Movie"})]
10
11 with patch("lib.utils.views.library.cache.get", return_value=cached_entries) as mock_get, patch(
12 "lib.utils.views.library.tmdb_get"
13 ) as mock_tmdb_get:
14 entries = library._get_library_entries(items, "movies")
15
16 mock_get.assert_called_once_with("library_view|movies")
17 mock_tmdb_get.assert_not_called()
18 assert entries == cached_entries
19
20
21def test_get_library_entries_materializes_and_caches_missing_entries():
22 items = [
23 ("Movie", {"ids": {"tmdb_id": 1}}),
24 ("Missing", {"ids": {"tmdb_id": 2}}),
25 ]
26
27 with patch("lib.utils.views.library.cache.get", return_value=None), patch(
28 "lib.utils.views.library.cache.set"
29 ) as mock_set, patch(
30 "lib.utils.views.library.tmdb_get",
31 side_effect=[{"id": 1, "title": "Movie"}, None],
32 ) as mock_tmdb_get:
33 entries = library._get_library_entries(items, "movies")
34
35 assert entries == [
36 {
37 "title": "Movie",
38 "data": {"ids": {"tmdb_id": 1}},
39 "details": {"id": 1, "title": "Movie"},
40 "is_stremio": False,
41 }
42 ]
43 assert mock_tmdb_get.call_count == 2
44 mock_set.assert_called_once_with("library_view|movies", entries)
45
46
47def test_library_mutations_invalidate_materialized_cache():
48 with patch("lib.utils.general.utils.pickle_db.set_item"), patch(
49 "lib.utils.general.utils.cache.delete"
50 ) as mock_delete:
51 add_to_library({"title": "Movie", "ids": {"tmdb_id": 1}, "mode": "movies"})
52
53 assert mock_delete.call_args_list[0].args[0] == "library_view|tv"
54 assert mock_delete.call_args_list[1].args[0] == "library_view|movies"
55
56 with patch("lib.utils.general.utils.pickle_db.delete_item"), patch(
57 "lib.utils.general.utils.cache.delete"
58 ) as mock_delete:
59 remove_from_library("Movie")
60
61 assert mock_delete.call_args_list[0].args[0] == "library_view|tv"
62 assert mock_delete.call_args_list[1].args[0] == "library_view|movies"
plugin.video.jacktook/tests/unit/test_navigation_debrid_module.py +26 -0
……@@ -0,0 +1,26 @@
1from unittest.mock import patch
2
3from lib.nav import debrid as debrid_navigation
4
5
6def test_cloud_details_uses_registry_actions_for_realdebrid():
7 with patch("lib.nav.debrid.addDirectoryItem") as add_directory_item, patch(
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
13 ):
14 debrid_navigation.cloud_details({"debrid_name": debrid_navigation.DebridType.RD})
15
16 assert len(add_directory_item.call_args_list) == 1
17 assert add_directory_item.call_args_list[0].args[1] == "get_rd_downloads"
18
19
20def test_download_notifies_on_unknown_debrid_type():
21 with patch("lib.nav.debrid.notification") as notify, patch(
22 "lib.nav.debrid.translation", return_value="Unsupported debrid type"
23 ):
24 debrid_navigation.download("magnet:?xt=urn:btih:123", "UNKNOWN")
25
26 notify.assert_called_once_with("Unsupported debrid type")
plugin.video.jacktook/tests/unit/test_navigation_library_history_module.py +29 -0
……@@ -0,0 +1,29 @@
1from unittest.mock import patch
2
3from lib.nav import library_history
4
5
6def test_history_menu_renders_history_items():
7 with patch("lib.nav.library_history.set_pluging_category") as set_category, patch(
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(
12 "lib.nav.library_history.build_url", side_effect=lambda action, **kwargs: action
13 ), patch(
14 "lib.nav.library_history.build_list_item", side_effect=lambda label, *_args, **_kwargs: label
15 ):
16 library_history.history_menu({})
17
18 set_category.assert_called_once()
19 assert add_directory_items_batch.called
20
21
22def test_clear_history_delegates_and_notifies():
23 with patch("lib.nav.library_history.clear_history_by_type") as clear_history, patch(
24 "lib.nav.library_history.notification"
25 ) as notify, patch("lib.nav.library_history.translation", return_value="done"):
26 library_history.clear_history({"type": "movies"})
27
28 clear_history.assert_called_once_with(type="movies")
29 notify.assert_called_once_with("done")
plugin.video.jacktook/tests/unit/test_parse_time.py +15 -0
……@@ -0,0 +1,15 @@
1from datetime import datetime
2
3from lib.utils.general.utils import parse_time
4
5
6def test_parse_time_accepts_english_rfc822_style_timestamp():
7 parsed = parse_time(("Title", {"timestamp": "Fri, 03 Apr 2026 03:38 AM"}))
8
9 assert parsed == datetime(2026, 4, 3, 3, 38)
10
11
12def test_parse_time_accepts_spanish_localized_timestamp():
13 parsed = parse_time(("Title", {"timestamp": "vie, 03 abr 2026 05:18 p. m."}))
14
15 assert parsed == datetime(2026, 4, 3, 17, 18)
plugin.video.jacktook/tests/unit/test_player.py +36 -0
……@@ -0,0 +1,36 @@
1from pathlib import Path
2import re
3
4
5PLAYER_PATH = Path(__file__).resolve().parents[2] / "lib" / "player.py"
6
7
8def test_monitor_finally_uses_non_destructive_cleanup():
9 source = PLAYER_PATH.read_text()
10 monitor_match = re.search(r"def monitor\(self\):(?P<body>.*?)def handle_subtitles", source, re.S)
11
12 assert monitor_match is not None
13
14 monitor_body = monitor_match.group("body")
15 finally_match = re.search(r"finally:\n(?P<body>(?:\s+.*\n)+)", monitor_body)
16
17 assert finally_match is not None
18
19 finally_body = finally_match.group("body")
20 assert "self._cleanup_playback_session()" in finally_body
21 assert "self.cancel_playback()" not in finally_body
22
23
24def test_cancel_playback_still_invalidates_resolved_url():
25 source = PLAYER_PATH.read_text()
26 cancel_match = re.search(
27 r"def cancel_playback\(self\):(?P<body>.*?)def _cleanup_playback_session",
28 source,
29 re.S,
30 )
31
32 assert cancel_match is not None
33
34 cancel_body = cancel_match.group("body")
35 assert "self._cleanup_playback_session()" in cancel_body
36 assert "setResolvedUrl(ADDON_HANDLE, False, ListItem(offscreen=True))" in cancel_body
plugin.video.jacktook/tests/unit/test_preprocess_filters.py +80 -0
……@@ -0,0 +1,80 @@
1from unittest.mock import patch
2
3from lib.domain.torrent import TorrentStream
4from lib.utils.general.utils import pre_process
5
6
7def test_pre_process_excludes_unknown_sources_when_disabled():
8 results = [
9 TorrentStream(title="Movie.2024.1080p.WEB-DL", type="Torrent"),
10 TorrentStream(title="Movie.2024.1080p.SomeWeirdTag", type="Torrent"),
11 ]
12
13 with patch("lib.utils.general.utils.get_setting") as get_setting, patch(
14 "lib.utils.general.processors.get_setting"
15 ) as processor_get_setting:
16 get_setting.side_effect = lambda key, default=None: {
17 "unknown_enabled": False,
18 "unknown_quality_enabled": True,
19 "bluray_hd_enabled": True,
20 "web_hd_enabled": True,
21 "dvd_tv_enabled": True,
22 "cam_screener_enabled": True,
23 "filter_size_enabled": False,
24 }.get(key, default)
25 processor_get_setting.side_effect = get_setting.side_effect
26
27 filtered = pre_process(results, mode="movies", episode_name="", episode=0, season=0)
28
29 assert [item.title for item in filtered] == ["Movie.2024.1080p.WEB-DL"]
30
31
32def test_pre_process_keeps_known_source_even_with_unknown_quality():
33 results = [
34 TorrentStream(title="Movie.2024.WEB-DL", type="Torrent"),
35 TorrentStream(title="Movie.2024.CustomSource", type="Torrent"),
36 ]
37
38 with patch("lib.utils.general.utils.get_setting") as get_setting, patch(
39 "lib.utils.general.processors.get_setting"
40 ) as processor_get_setting:
41 get_setting.side_effect = lambda key, default=None: {
42 "unknown_enabled": False,
43 "unknown_quality_enabled": True,
44 "bluray_hd_enabled": True,
45 "web_hd_enabled": True,
46 "dvd_tv_enabled": True,
47 "cam_screener_enabled": True,
48 "filter_size_enabled": False,
49 }.get(key, default)
50 processor_get_setting.side_effect = get_setting.side_effect
51
52 filtered = pre_process(results, mode="movies", episode_name="", episode=0, season=0)
53
54 assert [item.title for item in filtered] == ["Movie.2024.WEB-DL"]
55 assert filtered[0].quality == "[B][COLOR yellow]N/A[/COLOR][/B]"
56
57
58def test_pre_process_excludes_unknown_quality_when_disabled():
59 results = [
60 TorrentStream(title="Movie.2024.1080p.WEB-DL", type="Torrent"),
61 TorrentStream(title="Movie.2024.WEB-DL", type="Torrent"),
62 ]
63
64 with patch("lib.utils.general.utils.get_setting") as get_setting, patch(
65 "lib.utils.general.processors.get_setting"
66 ) as processor_get_setting:
67 get_setting.side_effect = lambda key, default=None: {
68 "unknown_enabled": True,
69 "unknown_quality_enabled": False,
70 "bluray_hd_enabled": True,
71 "web_hd_enabled": True,
72 "dvd_tv_enabled": True,
73 "cam_screener_enabled": True,
74 "filter_size_enabled": False,
75 }.get(key, default)
76 processor_get_setting.side_effect = get_setting.side_effect
77
78 filtered = pre_process(results, mode="movies", episode_name="", episode=0, season=0)
79
80 assert [item.title for item in filtered] == ["Movie.2024.1080p.WEB-DL"]
plugin.video.jacktook/tests/unit/test_prowlarr.py +91 -0
……@@ -0,0 +1,91 @@
1import pytest
2from unittest.mock import MagicMock, patch
3
4from lib.clients.prowlarr import Prowlarr
5
6
7@pytest.fixture
8def prowlarr_client():
9 mock_notification = MagicMock()
10 with patch("lib.clients.prowlarr.get_prowlarr_timeout", return_value=10):
11 client = Prowlarr(
12 host="http://localhost",
13 apikey="testapikey",
14 port="9696",
15 notification=mock_notification,
16 )
17 return client
18
19
20def test_prowlarr_initialization(prowlarr_client):
21 assert prowlarr_client.base_url == "http://localhost:9696/api/v1/search"
22 assert prowlarr_client.apikey == "testapikey"
23
24
25def test_prowlarr_query_with_year_appended(prowlarr_client):
26 with patch.object(prowlarr_client.session, "get") as mock_get:
27 mock_response = MagicMock()
28 mock_response.status_code = 200
29 mock_response.json.return_value = []
30 mock_get.return_value = mock_response
31
32 with patch("lib.clients.prowlarr.get_prowlarr_timeout", return_value=10):
33 prowlarr_client.search("Inception", "movies", year=2010)
34
35 call_args = mock_get.call_args
36 params = call_args[1]["params"]
37 assert params["query"] == "Inception 2010"
38 assert params["categories"] == [2000, 8000]
39
40
41def test_prowlarr_query_without_year(prowlarr_client):
42 with patch.object(prowlarr_client.session, "get") as mock_get:
43 mock_response = MagicMock()
44 mock_response.status_code = 200
45 mock_response.json.return_value = []
46 mock_get.return_value = mock_response
47
48 with patch("lib.clients.prowlarr.get_prowlarr_timeout", return_value=10):
49 prowlarr_client.search("Inception", "movies")
50
51 call_args = mock_get.call_args
52 params = call_args[1]["params"]
53 assert params["query"] == "Inception"
54 assert params["categories"] == [2000, 8000]
55
56
57def test_prowlarr_search_variant_mapping(prowlarr_client):
58 with patch.object(prowlarr_client.session, "get") as mock_get:
59 mock_response = MagicMock()
60 mock_response.status_code = 200
61 mock_response.json.return_value = []
62 mock_get.return_value = mock_response
63
64 with patch("lib.clients.prowlarr.get_prowlarr_timeout", return_value=10):
65 # Test with variant and year
66 prowlarr_client.search(
67 "Spirited Away", "movies", variant=None, year=2001
68 )
69
70 call_args = mock_get.call_args
71 params = call_args[1]["params"]
72 # Year should be appended to query for movies
73 assert params["query"] == "Spirited Away 2001"
74
75
76def test_prowlarr_year_not_appended_for_tv(prowlarr_client):
77 with patch.object(prowlarr_client.session, "get") as mock_get:
78 mock_response = MagicMock()
79 mock_response.status_code = 200
80 mock_response.json.return_value = []
81 mock_get.return_value = mock_response
82
83 with patch("lib.clients.prowlarr.get_prowlarr_timeout", return_value=10):
84 with patch("lib.clients.prowlarr.get_setting", return_value=False):
85 prowlarr_client.search("Breaking Bad", "tv", season=1, episode=1, year=2008)
86
87 call_args = mock_get.call_args
88 params = call_args[1]["params"]
89 # Year should NOT be appended for TV shows
90 assert "2008" not in params["query"]
91 assert "S01E01" in params["query"]
plugin.video.jacktook/tests/unit/test_qr_progress_dialog.py +29 -0
……@@ -0,0 +1,29 @@
1from unittest.mock import MagicMock
2
3from lib.gui.qr_progress_dialog import QRProgressDialog
4
5
6def test_on_action_marks_dialog_canceled_before_closing():
7 dialog = QRProgressDialog("qr_dialog.xml", "")
8 dialog.close_dialog = MagicMock()
9
10 action = MagicMock()
11 action.getId.return_value = 92
12
13 dialog.onAction(action)
14
15 assert dialog.iscanceled is True
16 dialog.close_dialog.assert_called_once_with()
17
18
19def test_on_action_ignores_non_close_actions():
20 dialog = QRProgressDialog("qr_dialog.xml", "")
21 dialog.close_dialog = MagicMock()
22
23 action = MagicMock()
24 action.getId.return_value = 7
25
26 dialog.onAction(action)
27
28 assert dialog.iscanceled is False
29 dialog.close_dialog.assert_not_called()
plugin.video.jacktook/tests/unit/test_realdebrid.py +50 -0
……@@ -0,0 +1,50 @@
1import pytest
2from unittest.mock import MagicMock, patch
3import os
4import json
5
6from lib.api.debrid.realdebrid import RealDebrid
7
8# Load fixture data
9FIXTURE_PATH = os.path.join(
10 os.path.dirname(__file__), "../fixtures/realdebrid_response.json"
11)
12with open(FIXTURE_PATH, "r") as f:
13 JSON_DATA = json.load(f)
14
15
16@pytest.fixture
17def rd_client():
18 mock_session = MagicMock()
19 with patch.object(
20 RealDebrid, "decode_token_str", return_value={"private_token": "secret"}
21 ):
22 client = RealDebrid(token="test_token", session=mock_session)
23 client.headers = {"Authorization": f"Bearer {client.token}"}
24 return client
25
26
27def test_realdebrid_api(rd_client):
28 # Test get_user
29 mock_response = MagicMock()
30 mock_response.status_code = 200
31 mock_response.json.return_value = JSON_DATA["user"]
32 # The base class uses session.request(method=...)
33 rd_client.session.request.return_value = mock_response
34
35 user = rd_client.get_user()
36 assert user["username"] == "testuser"
37 assert user["type"] == "premium"
38
39 # Test add_magnet_link
40 mock_response.json.return_value = JSON_DATA["addMagnet"]
41
42 magnet = "magnet:?xt=urn:btih:123"
43 response = rd_client.add_magnet_link(magnet)
44 assert response["id"] == "NEWTORRENT456"
45
46 # Test get_user_torrent_list
47 mock_response.json.return_value = JSON_DATA["torrents"]
48 torrents = rd_client.get_user_torrent_list()
49 assert len(torrents) == 1
50 assert torrents[0]["status"] == "downloaded"
plugin.video.jacktook/tests/unit/test_realdebrid_helper.py +78 -0
……@@ -0,0 +1,78 @@
1import pytest
2from unittest.mock import MagicMock
3
4from lib.api.debrid.base import ProviderException
5from lib.clients.debrid.realdebrid import RealDebridHelper
6
7
8def test_get_link_multi_file_movie_uses_largest_selected_file():
9 helper = RealDebridHelper.__new__(RealDebridHelper)
10 helper.client = MagicMock()
11 helper.add_magnet = MagicMock(return_value="torrent-id")
12
13 helper.client.get_torrent_info.return_value = {
14 "links": ["link-small", "link-large", "link-medium"],
15 "files": [
16 {"selected": 1, "bytes": 100},
17 {"selected": 1, "bytes": 300},
18 {"selected": 1, "bytes": 200},
19 ],
20 }
21 helper.client.create_download_link.side_effect = lambda link: {
22 "download": "https://download/{}".format(link)
23 }
24
25 result = helper.get_link("info-hash", {})
26
27 assert result is not None
28 assert result["url"] == "https://download/link-large"
29 assert "is_pack" not in result
30
31
32def test_get_link_multi_file_movie_falls_back_to_pack_when_no_selected_files():
33 helper = RealDebridHelper.__new__(RealDebridHelper)
34 helper.client = MagicMock()
35 helper.add_magnet = MagicMock(return_value="torrent-id")
36
37 helper.client.get_torrent_info.return_value = {
38 "links": ["link-one", "link-two"],
39 "files": [{"selected": 0, "bytes": 100}, {"selected": 0, "bytes": 200}],
40 }
41
42 result = helper.get_link("info-hash", {})
43
44 assert result is not None
45 assert result["is_pack"] is True
46 assert "url" not in result
47
48
49def test_get_link_single_file_movie_returns_download_url():
50 helper = RealDebridHelper.__new__(RealDebridHelper)
51 helper.client = MagicMock()
52 helper.add_magnet = MagicMock(return_value="torrent-id")
53
54 helper.client.get_torrent_info.return_value = {
55 "links": ["link-movie"],
56 "files": [{"path": "/Movie.2025.1080p.mkv", "selected": 1, "bytes": 1000}],
57 }
58 helper.client.create_download_link.return_value = {"download": "https://download/movie"}
59
60 result = helper.get_link("info-hash", {})
61
62 assert result is not None
63 assert result["url"] == "https://download/movie"
64 assert "is_pack" not in result
65
66
67def test_get_link_single_file_archive_raises_packed_release_error():
68 helper = RealDebridHelper.__new__(RealDebridHelper)
69 helper.client = MagicMock()
70 helper.add_magnet = MagicMock(return_value="torrent-id")
71
72 helper.client.get_torrent_info.return_value = {
73 "links": ["link-rar"],
74 "files": [{"path": "/Movie.2025.1080p.rar", "selected": 1, "bytes": 1000}],
75 }
76
77 with pytest.raises(ProviderException, match="Real-Debrid cannot directly play packed releases"):
78 helper.get_link("info-hash", {})
plugin.video.jacktook/tests/unit/test_router.py +90 -0
……@@ -0,0 +1,90 @@
1import importlib
2import sys
3from unittest.mock import patch
4
5
6def _load_router_module():
7 if "lib.router" in sys.modules:
8 return importlib.reload(sys.modules["lib.router"])
9 return importlib.import_module("lib.router")
10
11
12def test_get_route_handler_returns_debrid_dispatcher():
13 router = _load_router_module()
14
15 assert router._get_route_handler("rd_auth") is router._route_debrid
16
17
18def test_get_route_handler_returns_cache_dispatcher():
19 router = _load_router_module()
20
21 assert router._get_route_handler("clear_tmdb_cache") is router._route_cache
22
23
24def test_get_route_handler_returns_download_dispatcher_for_handle_download_file():
25 router = _load_router_module()
26
27 assert router._get_route_handler("handle_download_file") is router._route_downloads
28
29
30def test_get_route_handler_falls_back_to_core_dispatcher():
31 router = _load_router_module()
32
33 assert router._get_route_handler("history_menu") is router._route_core
34 assert router._get_route_handler("play_trailer") is router._route_core
35
36
37def test_get_route_handler_routes_settings_backup_actions_to_core_dispatcher():
38 router = _load_router_module()
39
40 assert router._get_route_handler("export_settings_backup") is router._route_core
41 assert router._get_route_handler("restore_settings_backup") is router._route_core
42 assert router._get_route_handler("reset_all_settings") is router._route_core
43 assert router._get_route_handler("factory_reset") is router._route_core
44
45
46def test_addon_router_uses_grouped_dispatcher():
47 router = _load_router_module()
48
49 with patch.object(sys, "argv", ["plugin.video.jacktook", "1", "?action=rd_auth"]), patch(
50 "lib.router._route_debrid"
51 ) as route_debrid, patch(
52 "lib.router._get_route_handler", return_value=route_debrid
53 ) as get_route_handler, patch(
54 "lib.utils.tmdb_init.ensure_tmdb_init"
55 ):
56 router.addon_router()
57
58 get_route_handler.assert_called_once_with("rd_auth")
59 route_debrid.assert_called_once()
60
61
62def test_addon_router_without_params_opens_root_menu():
63 router = _load_router_module()
64
65 with patch.object(sys, "argv", ["plugin.video.jacktook", "1", ""]), patch(
66 "lib.navigation.root_menu"
67 ) as root_menu:
68 router.addon_router()
69
70 root_menu.assert_called_once_with()
71
72
73def test_route_trakt_dispatches_trakt_group_menu_action():
74 router = _load_router_module()
75
76 params = {"mode": "tv", "group": "library"}
77 with patch("lib.navigation.trakt_group_menu") as trakt_group_menu:
78 router._route_trakt("trakt_group_menu", params)
79
80 trakt_group_menu.assert_called_once_with(params)
81
82
83def test_route_downloads_dispatches_handle_download_file():
84 router = _load_router_module()
85
86 params = {"url": "https://example.com/video.mp4"}
87 with patch("lib.downloader.handle_download_file") as handle_download_file:
88 router._route_downloads("handle_download_file", params)
89
90 handle_download_file.assert_called_once_with(params)
plugin.video.jacktook/tests/unit/test_router_tmdb_search_modes.py +5 -0
……@@ -0,0 +1,5 @@
1from lib.router import _is_tmdb_action
2
3
4def test_is_tmdb_action_includes_tmdb_search_modes():
5 assert _is_tmdb_action("tmdb_search_modes") is True
plugin.video.jacktook/tests/unit/test_search.py +98 -0
……@@ -0,0 +1,98 @@
1from unittest.mock import patch
2
3from lib.api.tmdbv3api.as_obj import AsObj
4from lib.search import SearchVariant, _build_title_fallback_queries, show_source_select
5
6
7def test_search_variant_values():
8 assert SearchVariant.DEFAULT == "default"
9 assert SearchVariant.TITLE_YEAR == "title_year"
10 assert SearchVariant.ORIGINAL_TITLE == "original_title"
11 assert SearchVariant.ORIGINAL_TITLE_YEAR == "original_title_year"
12
13
14def test_build_title_fallback_queries_default_variant_uses_tmdb_titles():
15 details = AsObj(
16 {
17 "original_name": "Shingeki no Kyojin",
18 "translations": {
19 "translations": [
20 {"iso_639_1": "en", "data": {"name": "Attack on Titan"}},
21 ]
22 },
23 }
24 )
25
26 with patch(
27 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
28 ):
29 queries = _build_title_fallback_queries(
30 "Ataque a los Titanes",
31 {"tmdb_id": "1429"},
32 "tv",
33 )
34
35 assert queries == ["Ataque a los Titanes", "Attack on Titan", "Shingeki no Kyojin"]
36
37
38def test_build_title_fallback_queries_supports_variants_and_year():
39 details = AsObj(
40 {
41 "original_name": "Shingeki no Kyojin",
42 "translations": {
43 "translations": [
44 {"iso_639_1": "en", "data": {"name": "Attack on Titan"}},
45 ]
46 },
47 }
48 )
49
50 with patch(
51 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
52 ):
53 title_year_queries = _build_title_fallback_queries(
54 "Ataque a los Titanes",
55 {"tmdb_id": "1429"},
56 "tv",
57 variant=SearchVariant.TITLE_YEAR,
58 year=2013,
59 )
60 original_title_queries = _build_title_fallback_queries(
61 "Ataque a los Titanes",
62 {"tmdb_id": "1429"},
63 "tv",
64 variant=SearchVariant.ORIGINAL_TITLE,
65 )
66 original_title_year_queries = _build_title_fallback_queries(
67 "Ataque a los Titanes",
68 {"tmdb_id": "1429"},
69 "tv",
70 variant=SearchVariant.ORIGINAL_TITLE_YEAR,
71 year=2013,
72 )
73
74 assert title_year_queries == ["Ataque a los Titanes 2013"]
75 assert original_title_queries == ["Shingeki no Kyojin"]
76 assert original_title_year_queries == ["Shingeki no Kyojin 2013"]
77
78
79def test_show_source_select_passes_year_from_media_metadata():
80 results = [object()]
81 metadata = {"year": 2010, "title": "Inception", "original_title": "Inception"}
82
83 with patch("lib.search.build_media_metadata", return_value=metadata), patch(
84 "lib.search.source_select", return_value=True
85 ) as source_select_mock:
86 show_source_select(
87 results,
88 "movies",
89 {"tmdb_id": "27205"},
90 {},
91 "Inception",
92 "movie",
93 False,
94 )
95
96 item_info = source_select_mock.call_args[0][0]
97 assert item_info["year"] == 2010
98 assert item_info["query"] == "Inception"
plugin.video.jacktook/tests/unit/test_search_title_fallback.py +323 -0
……@@ -0,0 +1,323 @@
1from unittest.mock import patch
2
3from lib.api.tmdbv3api.as_obj import AsObj
4from lib.domain.torrent import TorrentStream
5from lib.search import (
6 _build_title_fallback_queries,
7 _perform_search_with_title_fallback,
8 SearchVariant,
9 TITLE_LANGUAGE_ENGLISH_FIRST,
10 TITLE_LANGUAGE_ENGLISH_ONLY,
11 TITLE_LANGUAGE_LOCALIZED_FIRST,
12)
13
14
15def test_search_variant_enum_values():
16 if not hasattr(SearchVariant, '__members__'):
17 return
18 assert SearchVariant.DEFAULT.value == "default"
19 assert SearchVariant.TITLE_YEAR.value == "title_year"
20 assert SearchVariant.ORIGINAL_TITLE.value == "original_title"
21 assert SearchVariant.ORIGINAL_TITLE_YEAR.value == "original_title_year"
22
23
24def test_build_title_fallback_queries_default_variant_uses_fallback_behavior():
25 details = AsObj(
26 {
27 "original_title": "The Intouchables",
28 "translations": {
29 "translations": [
30 {"iso_639_1": "en", "data": {"title": "The Intouchables"}},
31 ]
32 },
33 }
34 )
35
36 with patch(
37 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
38 ):
39 queries = _build_title_fallback_queries(
40 "Intocable",
41 {"tmdb_id": "77338"},
42 "movies",
43 variant=SearchVariant.DEFAULT,
44 )
45
46 assert "Intocable" in queries
47 assert "The Intouchables" in queries
48
49
50def test_build_title_fallback_queries_title_year_appends_year():
51 queries = _build_title_fallback_queries(
52 "Inception",
53 {"tmdb_id": "27205"},
54 "movies",
55 variant=SearchVariant.TITLE_YEAR,
56 year=2010,
57 )
58
59 assert queries == ["Inception 2010"]
60
61
62def test_build_title_fallback_queries_original_title_uses_tmdb_original():
63 details = AsObj(
64 {
65 "original_title": "千と千尋の神隠し",
66 "translations": {
67 "translations": [
68 {"iso_639_1": "en", "data": {"title": "Spirited Away"}},
69 ]
70 },
71 }
72 )
73
74 with patch(
75 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
76 ):
77 queries = _build_title_fallback_queries(
78 "Spirited Away",
79 {"tmdb_id": "129"},
80 "movies",
81 variant=SearchVariant.ORIGINAL_TITLE,
82 )
83
84 assert queries == ["千と千尋の神隠し"]
85
86
87def test_build_title_fallback_queries_original_title_year_appends_year():
88 details = AsObj(
89 {
90 "original_title": "千と千尋の神隠し",
91 "translations": {
92 "translations": [
93 {"iso_639_1": "en", "data": {"title": "Spirited Away"}},
94 ]
95 },
96 }
97 )
98
99 with patch(
100 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
101 ):
102 queries = _build_title_fallback_queries(
103 "Spirited Away",
104 {"tmdb_id": "129"},
105 "movies",
106 variant=SearchVariant.ORIGINAL_TITLE_YEAR,
107 year=2001,
108 )
109
110 assert queries == ["千と千尋の神隠し 2001"]
111
112
113def test_build_title_fallback_queries_title_year_without_ids():
114 queries = _build_title_fallback_queries(
115 "Inception",
116 {},
117 "movies",
118 variant=SearchVariant.TITLE_YEAR,
119 year=2010,
120 )
121
122 assert queries == ["Inception 2010"]
123
124
125def test_build_title_fallback_queries_original_title_fallbacks_to_query():
126 details = AsObj(
127 {
128 "original_title": "",
129 "translations": {
130 "translations": [
131 {"iso_639_1": "en", "data": {"title": "Some Movie"}},
132 ]
133 },
134 }
135 )
136
137 with patch(
138 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
139 ):
140 queries = _build_title_fallback_queries(
141 "Some Movie",
142 {"tmdb_id": "123"},
143 "movies",
144 variant=SearchVariant.ORIGINAL_TITLE,
145 )
146
147 assert queries == ["Some Movie"]
148
149
150def test_build_title_fallback_queries_prefers_english_translation_for_movies():
151 details = AsObj(
152 {
153 "original_title": "The Intouchables",
154 "translations": {
155 "translations": [
156 {"iso_639_1": "es", "data": {"title": "Intocable"}},
157 {
158 "iso_639_1": "en",
159 "data": {"title": "The Intouchables"},
160 },
161 ]
162 },
163 }
164 )
165
166 with patch(
167 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
168 ):
169 queries = _build_title_fallback_queries(
170 "Intocable",
171 {"tmdb_id": "77338"},
172 "movies",
173 title_language_mode=TITLE_LANGUAGE_LOCALIZED_FIRST,
174 )
175
176 assert queries == ["Intocable", "The Intouchables"]
177
178
179def test_build_title_fallback_queries_english_first_reorders_candidates():
180 details = AsObj(
181 {
182 "original_title": "The Intouchables",
183 "translations": {
184 "translations": [
185 {"iso_639_1": "es", "data": {"title": "Intocable"}},
186 {
187 "iso_639_1": "en",
188 "data": {"title": "The Intouchables"},
189 },
190 ]
191 },
192 }
193 )
194
195 with patch(
196 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
197 ):
198 queries = _build_title_fallback_queries(
199 "Intocable",
200 {"tmdb_id": "77338"},
201 "movies",
202 title_language_mode=TITLE_LANGUAGE_ENGLISH_FIRST,
203 )
204
205 assert queries == ["The Intouchables", "Intocable"]
206
207
208def test_build_title_fallback_queries_english_first_falls_back_to_original_before_localized():
209 details = AsObj(
210 {
211 "original_title": "Avatar: Fire and Ash",
212 "translations": {
213 "translations": [
214 {"iso_639_1": "es", "data": {"title": "Avatar: Fuego y ceniza"}},
215 ]
216 },
217 }
218 )
219
220 with patch(
221 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
222 ):
223 queries = _build_title_fallback_queries(
224 "Avatar: Fuego y ceniza",
225 {"tmdb_id": "123456"},
226 "movies",
227 title_language_mode=TITLE_LANGUAGE_ENGLISH_FIRST,
228 )
229
230 assert queries == ["Avatar: Fire and Ash", "Avatar: Fuego y ceniza"]
231
232
233def test_build_title_fallback_queries_english_only_skips_localized_query():
234 details = AsObj(
235 {
236 "original_title": "The Intouchables",
237 "translations": {
238 "translations": [
239 {"iso_639_1": "es", "data": {"title": "Intocable"}},
240 {
241 "iso_639_1": "en",
242 "data": {"title": "The Intouchables"},
243 },
244 ]
245 },
246 }
247 )
248
249 with patch(
250 "lib.clients.tmdb.utils.utils.get_tmdb_media_details", return_value=details
251 ):
252 queries = _build_title_fallback_queries(
253 "Intocable",
254 {"tmdb_id": "77338"},
255 "movies",
256 title_language_mode=TITLE_LANGUAGE_ENGLISH_ONLY,
257 )
258
259 assert queries == ["The Intouchables"]
260
261
262def test_build_title_fallback_queries_uses_original_name_when_needed_for_tv():
263 details = AsObj(
264 {
265 "original_name": "Shingeki no Kyojin",
266 "translations": {
267 "translations": [
268 {"iso_639_1": "de", "data": {"name": "Attack on Titan"}},
269 {
270 "iso_639_1": "en",
271 "data": {"name": "Attack on Titan"},
272 },
273 ]
274 },
275 }
276 )
277
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 )
284
285 assert queries == [
286 "Ataque a los Titanes",
287 "Attack on Titan",
288 "Shingeki no Kyojin",
289 ]
290
291
292def test_perform_search_with_title_fallback_retries_until_results_found():
293 expected = [TorrentStream(title="The Intouchables 2011")]
294
295 with patch(
296 "lib.search._build_title_fallback_queries",
297 return_value=["Intocable", "The Intouchables", "Untouchables"],
298 ), patch(
299 "lib.search._perform_search",
300 side_effect=[[], expected],
301 ) as mock_search:
302 results = _perform_search_with_title_fallback(
303 "Prowlarr", None, "Intocable", {"tmdb_id": "77338"}, "movies", None, None
304 )
305
306 assert results == expected
307 assert mock_search.call_count == 2
308 assert mock_search.call_args_list[0].args == (
309 "Prowlarr",
310 None,
311 "Intocable",
312 "movies",
313 None,
314 None,
315 )
316 assert mock_search.call_args_list[1].args == (
317 "Prowlarr",
318 None,
319 "The Intouchables",
320 "movies",
321 None,
322 None,
323 )
plugin.video.jacktook/tests/unit/test_settings_backup.py +484 -0
……@@ -0,0 +1,484 @@
1from lib.utils.kodi import settings_backup
2
3
4class FakeCache:
5 def __init__(self, initial=None):
6 self.store = dict(initial or {})
7
8 def get(self, key):
9 return self.store.get(key)
10
11 def set(self, key, value, expires=None):
12 self.store[key] = value
13
14 def clear_list(self, key):
15 self.store[key] = None
16
17
18def _write_settings_xml(tmp_path, body):
19 settings_path = tmp_path / "settings.xml"
20 settings_path.write_text(
21 "<?xml version='1.0' encoding='utf-8'?>\n"
22 "<settings version='1'>\n"
23 " <section id='plugin.video.jacktook'>\n"
24 f"{body}\n"
25 " </section>\n"
26 "</settings>\n",
27 encoding="utf-8",
28 )
29 return str(settings_path)
30
31
32def test_build_backup_payload_skips_action_settings_and_scrubs_sensitive_data(
33 monkeypatch, tmp_path
34):
35 settings_path = _write_settings_xml(
36 tmp_path,
37 """
38 <category id='general'>
39 <group id='backup'>
40 <setting id='safe_value' type='string'><default /></setting>
41 <setting id='jackett_apikey' type='string'><default /></setting>
42 <setting id='stremio_email' type='string'><default /></setting>
43 <setting id='is_trakt_auth' type='boolean'><default>false</default></setting>
44 <setting id='some_action' type='action' />
45 </group>
46 </category>
47 """,
48 )
49 values = {
50 "safe_value": "safe",
51 "jackett_apikey": "secret",
52 "stremio_email": "user@example.com",
53 "is_trakt_auth": "true",
54 }
55
56 monkeypatch.setattr(
57 settings_backup.ADDON,
58 "getSetting",
59 lambda setting_id: values.get(setting_id, ""),
60 )
61 monkeypatch.setattr(settings_backup, "cache", FakeCache())
62 monkeypatch.setattr(
63 settings_backup,
64 "get_cached_setting_property",
65 lambda setting_id: {
66 "trakt_token": "tok",
67 "trakt_refresh": "ref",
68 "trakt_expires": "123",
69 }.get(setting_id, ""),
70 )
71
72 payload = settings_backup.build_backup_payload(
73 strip_sensitive=True,
74 settings_xml_path=settings_path,
75 )
76
77 assert payload["settings"] == {"safe_value": "safe"}
78 assert payload["dynamic_settings"] == {}
79 assert payload["cache"] == {}
80 assert payload["strip_sensitive"] is True
81 assert payload["custom_stremio_addons_included"] is False
82
83
84def test_build_backup_payload_includes_custom_stremio_addons(monkeypatch, tmp_path):
85 settings_path = _write_settings_xml(
86 tmp_path,
87 """
88 <category id='general'>
89 <group id='backup'>
90 <setting id='safe_value' type='string'><default /></setting>
91 </group>
92 </category>
93 """,
94 )
95 custom_addon = {
96 "manifest": {"id": "custom.one", "name": "Custom One"},
97 "transportName": "custom",
98 "transportUrl": "https://example.com/manifest.json",
99 }
100 another_addon = {
101 "manifest": {"id": "builtin.one", "name": "Builtin"},
102 "transportName": "stremio-account",
103 "transportUrl": "https://account.example/manifest.json",
104 }
105 custom_key = "custom.one|https://example.com"
106 fake_cache = FakeCache(
107 {
108 settings_backup.STREMIO_USER_ADDONS: [custom_addon, another_addon],
109 settings_backup.STREMIO_ADDONS_KEY: settings_backup.encode_selected_ids(
110 [custom_key, "builtin.one|https://account.example"]
111 ),
112 settings_backup.STREMIO_ADDONS_CATALOGS_KEY: settings_backup.encode_selected_ids(
113 [custom_key]
114 ),
115 settings_backup.STREMIO_TV_ADDONS_KEY: settings_backup.encode_selected_ids(
116 ["builtin.one|https://account.example"]
117 ),
118 }
119 )
120
121 monkeypatch.setattr(settings_backup, "cache", fake_cache)
122 monkeypatch.setattr(settings_backup.ADDON, "getSetting", lambda setting_id: "value")
123 monkeypatch.setattr(
124 settings_backup,
125 "get_cached_setting_property",
126 lambda setting_id: {
127 "trakt_token": "tok",
128 "trakt_refresh": "ref",
129 "trakt_expires": "123",
130 }.get(setting_id, ""),
131 )
132
133 payload = settings_backup.build_backup_payload(
134 strip_sensitive=False,
135 settings_xml_path=settings_path,
136 )
137
138 assert payload["settings"] == {"safe_value": "value"}
139 assert payload["dynamic_settings"] == {
140 "trakt_token": "tok",
141 "trakt_refresh": "ref",
142 "trakt_expires": "123",
143 }
144 assert payload["cache"]["custom_stremio_addons"] == [custom_addon]
145 assert payload["cache"]["custom_stremio_selections"] == {
146 "stream": [custom_key],
147 "catalog": [custom_key],
148 "tv": [],
149 }
150
151
152def test_apply_backup_payload_replaces_custom_stremio_addons(monkeypatch, tmp_path):
153 settings_path = _write_settings_xml(
154 tmp_path,
155 """
156 <category id='general'>
157 <group id='backup'>
158 <setting id='safe_value' type='string'><default /></setting>
159 </group>
160 </category>
161 """,
162 )
163 old_custom = {
164 "manifest": {"id": "custom.old", "name": "Old Custom"},
165 "transportName": "custom",
166 "transportUrl": "https://old.example/manifest.json",
167 }
168 new_custom = {
169 "manifest": {"id": "custom.new", "name": "New Custom"},
170 "transportName": "custom",
171 "transportUrl": "https://new.example/manifest.json",
172 }
173 account_addon = {
174 "manifest": {"id": "account.one", "name": "Account"},
175 "transportName": "stremio-account",
176 "transportUrl": "https://account.example/manifest.json",
177 }
178 old_key = "custom.old|https://old.example"
179 new_key = "custom.new|https://new.example"
180 account_key = "account.one|https://account.example"
181 fake_cache = FakeCache(
182 {
183 settings_backup.STREMIO_USER_ADDONS: [old_custom, account_addon],
184 settings_backup.STREMIO_ADDONS_KEY: settings_backup.encode_selected_ids(
185 [old_key, account_key]
186 ),
187 settings_backup.STREMIO_ADDONS_CATALOGS_KEY: settings_backup.encode_selected_ids(
188 [old_key]
189 ),
190 settings_backup.STREMIO_TV_ADDONS_KEY: settings_backup.encode_selected_ids([]),
191 }
192 )
193 set_calls = []
194 property_calls = []
195
196 monkeypatch.setattr(settings_backup, "cache", fake_cache)
197 monkeypatch.setattr(
198 settings_backup,
199 "set_setting",
200 lambda setting_id, value: set_calls.append((setting_id, value)),
201 )
202 monkeypatch.setattr(
203 settings_backup,
204 "set_cached_setting_property",
205 lambda setting_id, value: property_calls.append((setting_id, value)),
206 )
207
208 payload = {
209 "settings": {"safe_value": "restored", "unknown": "ignored"},
210 "dynamic_settings": {
211 "trakt_token": "tok",
212 "trakt_refresh": "ref",
213 "trakt_expires": "123",
214 },
215 "cache": {
216 "custom_stremio_addons": [new_custom],
217 "custom_stremio_selections": {
218 "stream": [new_key],
219 "catalog": [new_key],
220 "tv": ["invalid"],
221 },
222 },
223 "strip_sensitive": False,
224 }
225
226 settings_backup.apply_backup_payload(payload, settings_xml_path=settings_path)
227
228 assert ("safe_value", "restored") in set_calls
229 assert ("trakt_token", "tok") in property_calls
230 assert ("trakt_refresh", "ref") in property_calls
231 assert ("trakt_expires", "123") in property_calls
232 assert fake_cache.store[settings_backup.STREMIO_USER_ADDONS] == [account_addon, new_custom]
233 assert settings_backup.decode_selected_ids(
234 fake_cache.store[settings_backup.STREMIO_ADDONS_KEY]
235 ) == [account_key, new_key]
236 assert settings_backup.decode_selected_ids(
237 fake_cache.store[settings_backup.STREMIO_ADDONS_CATALOGS_KEY]
238 ) == [new_key]
239 assert settings_backup.decode_selected_ids(
240 fake_cache.store[settings_backup.STREMIO_TV_ADDONS_KEY]
241 ) == []
242
243
244def test_apply_backup_payload_clears_scrubbed_fields_and_custom_addons(
245 monkeypatch, tmp_path
246):
247 settings_path = _write_settings_xml(
248 tmp_path,
249 """
250 <category id='general'>
251 <group id='backup'>
252 <setting id='safe_value' type='string'><default /></setting>
253 <setting id='jackett_apikey' type='string'><default /></setting>
254 <setting id='stremio_email' type='string'><default /></setting>
255 <setting id='is_trakt_auth' type='boolean'><default>false</default></setting>
256 <setting id='trakt_user' type='string'><default /></setting>
257 </group>
258 </category>
259 """,
260 )
261 old_custom = {
262 "manifest": {"id": "custom.old", "name": "Old Custom"},
263 "transportName": "custom",
264 "transportUrl": "https://old.example/manifest.json",
265 }
266 account_addon = {
267 "manifest": {"id": "account.one", "name": "Account"},
268 "transportName": "stremio-account",
269 "transportUrl": "https://account.example/manifest.json",
270 }
271 old_key = "custom.old|https://old.example"
272 account_key = "account.one|https://account.example"
273 fake_cache = FakeCache(
274 {
275 settings_backup.STREMIO_USER_ADDONS: [old_custom, account_addon],
276 settings_backup.STREMIO_ADDONS_KEY: settings_backup.encode_selected_ids(
277 [old_key, account_key]
278 ),
279 settings_backup.STREMIO_ADDONS_CATALOGS_KEY: settings_backup.encode_selected_ids(
280 [old_key]
281 ),
282 settings_backup.STREMIO_TV_ADDONS_KEY: settings_backup.encode_selected_ids([]),
283 }
284 )
285 set_calls = []
286 property_calls = []
287
288 monkeypatch.setattr(settings_backup, "cache", fake_cache)
289 monkeypatch.setattr(
290 settings_backup,
291 "set_setting",
292 lambda setting_id, value: set_calls.append((setting_id, value)),
293 )
294 monkeypatch.setattr(
295 settings_backup,
296 "set_cached_setting_property",
297 lambda setting_id, value: property_calls.append((setting_id, value)),
298 )
299
300 settings_backup.apply_backup_payload(
301 {
302 "settings": {"safe_value": "restored"},
303 "dynamic_settings": {"trakt_token": "tok"},
304 "cache": {},
305 "strip_sensitive": True,
306 },
307 settings_xml_path=settings_path,
308 )
309
310 assert ("safe_value", "restored") in set_calls
311 assert ("jackett_apikey", "") in set_calls
312 assert ("stremio_email", "") in set_calls
313 assert ("is_trakt_auth", "false") in set_calls
314 assert ("trakt_user", "unknown_user") in set_calls
315 assert ("trakt_token", "") in property_calls
316 assert ("trakt_refresh", "") in property_calls
317 assert ("trakt_expires", "") in property_calls
318 assert fake_cache.store[settings_backup.STREMIO_USER_ADDONS] == [account_addon]
319 assert settings_backup.decode_selected_ids(
320 fake_cache.store[settings_backup.STREMIO_ADDONS_KEY]
321 ) == [account_key]
322 assert settings_backup.decode_selected_ids(
323 fake_cache.store[settings_backup.STREMIO_ADDONS_CATALOGS_KEY]
324 ) == []
325
326
327def test_reset_all_settings_restores_defaults_and_clears_custom_addons(
328 monkeypatch, tmp_path
329):
330 settings_path = _write_settings_xml(
331 tmp_path,
332 """
333 <category id='general'>
334 <group id='backup'>
335 <setting id='safe_value' type='string'><default>default-safe</default></setting>
336 <setting id='is_trakt_auth' type='boolean'><default>false</default></setting>
337 <setting id='trakt_user' type='string'><default /></setting>
338 </group>
339 </category>
340 """,
341 )
342 old_custom = {
343 "manifest": {"id": "custom.old", "name": "Old Custom"},
344 "transportName": "custom",
345 "transportUrl": "https://old.example/manifest.json",
346 }
347 account_addon = {
348 "manifest": {"id": "account.one", "name": "Account"},
349 "transportName": "stremio-account",
350 "transportUrl": "https://account.example/manifest.json",
351 }
352 old_key = "custom.old|https://old.example"
353 account_key = "account.one|https://account.example"
354 fake_cache = FakeCache(
355 {
356 settings_backup.STREMIO_USER_ADDONS: [old_custom, account_addon],
357 settings_backup.STREMIO_ADDONS_KEY: settings_backup.encode_selected_ids(
358 [old_key, account_key]
359 ),
360 settings_backup.STREMIO_ADDONS_CATALOGS_KEY: settings_backup.encode_selected_ids(
361 [old_key]
362 ),
363 settings_backup.STREMIO_TV_ADDONS_KEY: settings_backup.encode_selected_ids([]),
364 }
365 )
366 set_calls = []
367 property_calls = []
368
369 monkeypatch.setattr(settings_backup, "cache", fake_cache)
370 monkeypatch.setattr(
371 settings_backup,
372 "set_setting",
373 lambda setting_id, value: set_calls.append((setting_id, value)),
374 )
375 monkeypatch.setattr(
376 settings_backup,
377 "set_cached_setting_property",
378 lambda setting_id, value: property_calls.append((setting_id, value)),
379 )
380
381 settings_backup.reset_all_settings(settings_xml_path=settings_path)
382
383 assert ("safe_value", "default-safe") in set_calls
384 assert ("is_trakt_auth", "false") in set_calls
385 assert ("trakt_user", "unknown_user") in set_calls
386 assert ("trakt_token", "") in property_calls
387 assert ("trakt_refresh", "") in property_calls
388 assert ("trakt_expires", "") in property_calls
389 assert fake_cache.store[settings_backup.STREMIO_USER_ADDONS] == [account_addon]
390 assert settings_backup.decode_selected_ids(
391 fake_cache.store[settings_backup.STREMIO_ADDONS_KEY]
392 ) == [account_key]
393 assert settings_backup.decode_selected_ids(
394 fake_cache.store[settings_backup.STREMIO_ADDONS_CATALOGS_KEY]
395 ) == []
396
397
398def test_factory_reset_clears_settings_caches_and_local_database(monkeypatch, tmp_path):
399 settings_path = _write_settings_xml(
400 tmp_path,
401 """
402 <category id='general'>
403 <group id='backup'>
404 <setting id='safe_value' type='string'><default>default-safe</default></setting>
405 </group>
406 </category>
407 """,
408 )
409 fake_cache = FakeCache()
410 set_calls = []
411 helper_calls = []
412 property_calls = []
413
414 class FakePickleDatabase:
415 def __init__(self):
416 self.calls = []
417
418 def set_key(self, key, value, commit=True):
419 self.calls.append((key, value, commit))
420
421 def commit(self):
422 self.calls.append(("commit", None, None))
423
424 fake_pickle_db = FakePickleDatabase()
425
426 monkeypatch.setattr(settings_backup, "cache", fake_cache)
427 monkeypatch.setattr(
428 settings_backup,
429 "set_setting",
430 lambda setting_id, value: set_calls.append((setting_id, value)),
431 )
432 monkeypatch.setattr(
433 settings_backup,
434 "set_cached_setting_property",
435 lambda setting_id, value: property_calls.append((setting_id, value)),
436 )
437 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"))
439 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"))
443 monkeypatch.setattr(settings_backup, "PickleDatabase", lambda: fake_pickle_db)
444
445 settings_backup.factory_reset(settings_xml_path=settings_path)
446
447 assert ("safe_value", "default-safe") in set_calls
448 assert ("trakt_token", "") in property_calls
449 assert ("trakt_refresh", "") in property_calls
450 assert ("trakt_expires", "") in property_calls
451 assert helper_calls == ["all", "trakt", "tmdb", "stremio", "debrid", "mdblist"]
452 assert fake_cache.store["multi"] is None
453 assert fake_cache.store["direct"] is None
454 assert ("jt:watch", {}, False) in fake_pickle_db.calls
455 assert ("jt:lth", {}, False) in fake_pickle_db.calls
456 assert ("jt:lfh", {}, False) in fake_pickle_db.calls
457 assert ("jt:lib", {}, False) in fake_pickle_db.calls
458 assert ("search_query", "", False) in fake_pickle_db.calls
459 assert ("search_catalog_query", "", False) in fake_pickle_db.calls
460 assert ("anime_query", "", False) in fake_pickle_db.calls
461 assert ("collection_search_query", "", False) in fake_pickle_db.calls
462 assert ("commit", None, None) in fake_pickle_db.calls
463
464
465def test_export_settings_backup_returns_silently_when_dialog_is_cancelled(monkeypatch):
466 notifications = []
467 build_calls = []
468
469 monkeypatch.setattr(settings_backup, "_get_export_path", lambda: None)
470 monkeypatch.setattr(
471 settings_backup,
472 "notification",
473 lambda message: notifications.append(message),
474 )
475 monkeypatch.setattr(
476 settings_backup,
477 "build_backup_payload",
478 lambda *args, **kwargs: build_calls.append(True),
479 )
480
481 settings_backup.export_settings_backup()
482
483 assert notifications == []
484 assert build_calls == []
plugin.video.jacktook/tests/unit/test_skip_intro_window.py +35 -0
……@@ -0,0 +1,35 @@
1from unittest.mock import MagicMock, patch
2
3from lib.gui.skip_intro_window import SkipIntroWindow
4
5
6@patch("lib.gui.skip_intro_window.Thread")
7def test_on_init_starts_background_monitor_thread(mock_thread_cls):
8 thread = MagicMock()
9 mock_thread_cls.return_value = thread
10
11 window = SkipIntroWindow(
12 "skip_intro.xml", "", segment_data={"end_sec": 30}, label="Skip Intro"
13 )
14 window.setProperty = MagicMock()
15
16 window.onInit()
17
18 window.setProperty.assert_called_once_with("skip_label", "Skip Intro")
19 mock_thread_cls.assert_called_once_with(target=window._background_monitor)
20 assert window.monitor_thread is thread
21 assert thread.daemon is True
22 thread.start.assert_called_once_with()
23
24
25def test_on_click_skip_seeks_to_segment_end():
26 window = SkipIntroWindow("skip_intro.xml", "", segment_data={"end_sec": 42})
27 window.player = MagicMock()
28 window.player.isPlaying.return_value = True
29 window.close = MagicMock()
30
31 window.onClick(4001)
32
33 assert window.action == "skip"
34 window.player.seekTime.assert_called_once_with(42)
35 window.close.assert_called_once_with()
plugin.video.jacktook/tests/unit/test_source_select_download.py +142 -0
……@@ -0,0 +1,142 @@
1import importlib
2import sys
3from unittest.mock import MagicMock, patch
4
5from lib.domain.torrent import TorrentStream
6from lib.utils.general.utils import DebridType, IndexerType
7
8
9def _load_source_select_module():
10 if "lib.gui.source_select" in sys.modules:
11 return importlib.reload(sys.modules["lib.gui.source_select"])
12 return importlib.import_module("lib.gui.source_select")
13
14
15def test_download_file_uses_episode_filename_when_tv_data_is_present():
16 source_select_module = _load_source_select_module()
17 source_select = source_select_module.SourceSelect.__new__(source_select_module.SourceSelect)
18 source_select.item_information = {
19 "title": "Game of Thrones",
20 "tv_data": {"season": "1", "episode": "1"},
21 }
22 source_select.playback_info = None
23
24 selected_source = MagicMock()
25 playback_info = {
26 "title": "Game of Thrones",
27 "url": "https://example.com/files/game.of.thrones.mkv",
28 "tv_data": {"season": "1", "episode": "1"},
29 }
30
31 with patch.object(
32 source_select_module.SourceSelect,
33 "_ensure_playback_info",
34 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(
38 source_select_module.xbmc, "executebuiltin"
39 ) as executebuiltin:
40 source_select._download_file(selected_source)
41
42 action_url_run.assert_called_once_with(
43 "handle_download_file",
44 file_name="Game of Thrones - S01E01",
45 url="https://example.com/files/game.of.thrones.mkv",
46 destination="/downloads",
47 )
48 executebuiltin.assert_called_once_with("builtin")
49
50
51def test_download_file_uses_year_for_movie_filename():
52 source_select_module = _load_source_select_module()
53 source_select = source_select_module.SourceSelect.__new__(source_select_module.SourceSelect)
54 source_select.item_information = {
55 "title": "Project Hail Mary",
56 "year": 2025,
57 }
58 source_select.playback_info = None
59
60 selected_source = MagicMock()
61 playback_info = {
62 "title": "Project Hail Mary",
63 "url": "https://example.com/files/project.hail.mary.mkv",
64 }
65
66 with patch.object(
67 source_select_module.SourceSelect,
68 "_ensure_playback_info",
69 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(
73 source_select_module.xbmc, "executebuiltin"
74 ) as executebuiltin:
75 source_select._download_file(selected_source)
76
77 action_url_run.assert_called_once_with(
78 "handle_download_file",
79 file_name="Project Hail Mary (2025)",
80 url="https://example.com/files/project.hail.mary.mkv",
81 destination="/downloads",
82 )
83 executebuiltin.assert_called_once_with("builtin")
84
85
86def test_populate_sources_list_shows_debrid_type_for_stremio_debrid_sources():
87 source_select_module = _load_source_select_module()
88 source_select = source_select_module.SourceSelect.__new__(source_select_module.SourceSelect)
89
90 class _FakeListItem:
91 def __init__(self, label=""):
92 self.label = label
93 self.properties = {}
94
95 def setProperty(self, key, value):
96 self.properties[key] = value
97
98 class _FakeDisplayList:
99 def __init__(self):
100 self.items = []
101
102 def reset(self):
103 self.items = []
104
105 def addItem(self, item):
106 self.items.append(item)
107
108 source_select.display_list = _FakeDisplayList()
109 source_select.filter_applied = False
110 source_select.filtered_sources = None
111 source_select.sources = [
112 TorrentStream(
113 title="Example.Source.1080p",
114 type=IndexerType.STREMIO_DEBRID,
115 debridType=DebridType.TB,
116 subindexer="Torrentio",
117 size=1024,
118 provider="ProviderX",
119 quality="1080p",
120 )
121 ]
122
123 with patch.object(source_select_module.xbmcgui, "ListItem", _FakeListItem), patch.object(
124 source_select_module,
125 "parse_title_info",
126 return_value={
127 "clean_title": "Example Source",
128 "codec": "",
129 "audio": "",
130 "badges": "",
131 "release_group": "",
132 },
133 ), patch.object(source_select_module, "bytes_to_human_readable", return_value="1 KB"), patch.object(
134 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 ):
140 source_select.populate_sources_list()
141
142 assert source_select.display_list.items[0].properties["type"] == DebridType.TB
plugin.video.jacktook/tests/unit/test_stremio_regressions.py +827 -0
……@@ -0,0 +1,827 @@
1import json
2from unittest.mock import MagicMock
3
4from lib.api.stremio.addon_manager import AddonManager
5from lib.api.stremio.models import Meta, MetaPreview
6from lib.clients.stremio import helpers
7from lib.clients.stremio.catalog_menus import CATALOG_PAGE_SIZE
8from lib.clients.stremio import catalog_menus
9from lib.domain.torrent import TorrentStream
10from lib import search
11
12
13def test_merge_addons_lists_keeps_same_manifest_id_with_different_urls():
14 custom_addon = {
15 "manifest": {"id": "org.example.addon", "name": "Example"},
16 "transportUrl": "https://example.com/custom/manifest.json",
17 "transportName": "custom",
18 }
19 account_addon = {
20 "manifest": {"id": "org.example.addon", "name": "Example"},
21 "transportUrl": "https://example.com/account/manifest.json",
22 "transportName": "stremio-account",
23 }
24
25 merged = helpers.merge_addons_lists([custom_addon], [account_addon])
26
27 assert merged == [custom_addon, account_addon]
28
29
30def test_get_selected_stream_addons_skips_ambiguous_legacy_ids(monkeypatch):
31 addon_manager = AddonManager(
32 [
33 {
34 "manifest": {
35 "id": "org.example.addon",
36 "name": "Example",
37 "resources": [{"name": "stream", "types": ["movie"], "idPrefixes": ["tt"]}],
38 "types": ["movie"],
39 },
40 "transportUrl": "https://example.com/one/manifest.json",
41 "transportName": "custom",
42 },
43 {
44 "manifest": {
45 "id": "org.example.addon",
46 "name": "Example",
47 "resources": [{"name": "stream", "types": ["movie"], "idPrefixes": ["tt"]}],
48 "types": ["movie"],
49 },
50 "transportUrl": "https://example.com/two/manifest.json",
51 "transportName": "stremio-account",
52 },
53 ]
54 )
55
56 fake_cache = MagicMock()
57 fake_cache.get.return_value = "org.example.addon"
58
59 monkeypatch.setattr(helpers, "get_addons", lambda: addon_manager)
60 monkeypatch.setattr(helpers, "cache", fake_cache)
61
62 selected = helpers.get_selected_stream_addons()
63
64 assert selected == []
65
66
67def test_process_search_results_bypasses_exact_addon_instance(monkeypatch):
68 bypass_result = TorrentStream(
69 title="Bypassed",
70 indexer="Torrentio",
71 addonKey="org.example.addon|https://example.com/one",
72 addonName="Torrentio",
73 )
74 native_result = TorrentStream(
75 title="Native",
76 indexer="Torrentio",
77 addonKey="org.example.addon|https://example.com/two",
78 addonName="Torrentio",
79 )
80
81 processed_batches = []
82
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(search, "pre_process_results", lambda results, *args, **kwargs: results)
88 monkeypatch.setattr(
89 search,
90 "process_results",
91 lambda results, *args, **kwargs: processed_batches.append(results) or results,
92 )
93
94 results = search._process_search_results(
95 [bypass_result, native_result],
96 "movies",
97 "",
98 1,
99 1,
100 "",
101 "query",
102 "movies",
103 False,
104 )
105
106 assert results == [bypass_result, native_result]
107 assert processed_batches == [[native_result]]
108
109
110def test_get_addons_uses_cached_account_addons_before_settings_refresh(monkeypatch):
111 custom_addon = {
112 "manifest": {"id": "custom.one", "name": "Custom One"},
113 "transportUrl": "https://custom.example/manifest.json",
114 "transportName": "custom",
115 }
116 account_addon = {
117 "manifest": {"id": "account.one", "name": "Account One"},
118 "transportUrl": "https://account.example/manifest.json",
119 "transportName": "stremio-account",
120 }
121
122 fake_cache = MagicMock()
123 fake_cache.get.return_value = [custom_addon, account_addon]
124
125 monkeypatch.setattr(helpers, "cache", fake_cache)
126 monkeypatch.setattr(helpers, "get_setting", lambda key: False)
127
128 addon_manager = helpers.get_addons()
129 addon_ids = [addon.manifest.id for addon in addon_manager.addons]
130
131 assert addon_ids == ["custom.one", "account.one"]
132
133
134def test_list_catalog_locally_paginates_when_catalog_has_no_skip(monkeypatch):
135 metas = [
136 MetaPreview(id=f"id-{i}", type="movie", name=f"Movie {i}", poster="")
137 for i in range(CATALOG_PAGE_SIZE + 5)
138 ]
139 added_pages = []
140 next_urls = []
141
142 monkeypatch.setattr(
143 catalog_menus,
144 "catalogs_get_cache",
145 lambda *args, **kwargs: {"metas": metas},
146 )
147 monkeypatch.setattr(
148 catalog_menus, "_catalog_supports_extra", lambda *args, **kwargs: False
149 )
150 monkeypatch.setattr(
151 catalog_menus,
152 "add_meta_items",
153 lambda chunk, params: added_pages.append([meta.id for meta in chunk]),
154 )
155 monkeypatch.setattr(catalog_menus, "setContent", lambda *args, **kwargs: None)
156 monkeypatch.setattr(catalog_menus, "end_of_directory", lambda *args, **kwargs: None)
157 monkeypatch.setattr(catalog_menus, "notification", lambda *args, **kwargs: None)
158 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": MagicMock())
159 monkeypatch.setattr(
160 catalog_menus,
161 "addDirectoryItem",
162 lambda handle, url, listitem, isFolder=True: next_urls.append(url),
163 )
164 monkeypatch.setattr(
165 catalog_menus,
166 "build_url",
167 lambda action, **kwargs: f"{action}:{kwargs.get('skip', 0)}",
168 )
169
170 params = {
171 "addon_url": "https://example.com/addon",
172 "menu_type": "movie",
173 "catalog_type": "movie",
174 "catalog_id": "user-movies",
175 }
176 catalog_menus.list_catalog(params)
177
178 assert len(added_pages) == 1
179 assert len(added_pages[0]) == CATALOG_PAGE_SIZE
180 assert added_pages[0][0] == "id-0"
181 assert added_pages[0][-1] == f"id-{CATALOG_PAGE_SIZE - 1}"
182 assert next_urls == [f"list_catalog:{CATALOG_PAGE_SIZE}"]
183
184
185def test_list_catalog_uses_server_skip_when_manifest_supports_it(monkeypatch):
186 captured_kwargs = {}
187
188 monkeypatch.setattr(
189 catalog_menus,
190 "catalogs_get_cache",
191 lambda path, params, **kwargs: captured_kwargs.update(kwargs) or {"metas": []},
192 )
193 monkeypatch.setattr(
194 catalog_menus, "_catalog_supports_extra", lambda *args, **kwargs: True
195 )
196 monkeypatch.setattr(catalog_menus, "setContent", lambda *args, **kwargs: None)
197 monkeypatch.setattr(catalog_menus, "end_of_directory", lambda *args, **kwargs: None)
198 monkeypatch.setattr(catalog_menus, "notification", lambda *args, **kwargs: None)
199
200 params = {
201 "addon_url": "https://example.com/addon",
202 "menu_type": "movie",
203 "catalog_type": "movie",
204 "catalog_id": "user-movies",
205 "skip": 50,
206 }
207 catalog_menus.list_catalog(params)
208
209 assert captured_kwargs == {"skip": 50}
210
211
212def test_search_catalog_cancel_shows_previous_search_terms(monkeypatch):
213 added_items = []
214
215 class _ListItem:
216 def __init__(self, label=""):
217 self.label = label
218
219 def setArt(self, *args, **kwargs):
220 pass
221
222 monkeypatch.setattr(catalog_menus, "show_keyboard", lambda *args, **kwargs: None)
223 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)))
225 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": _ListItem(label))
226 monkeypatch.setattr(
227 catalog_menus,
228 "addDirectoryItem",
229 lambda handle, url, listitem, isFolder=True: added_items.append((listitem.label, url, isFolder)),
230 )
231 monkeypatch.setattr(catalog_menus, "end_of_directory", lambda *args, **kwargs: None)
232 monkeypatch.setattr(
233 catalog_menus,
234 "build_url",
235 lambda action, **kwargs: "{}:{}:{}".format(action, kwargs.get("query", ""), kwargs.get("is_keyboard", True)),
236 )
237
238 catalog_menus.search_catalog(
239 {
240 "page": 1,
241 "addon_url": "https://anime-kitsu.strem.fun",
242 "catalog_type": "anime",
243 "catalog_id": "kitsu-search",
244 }
245 )
246
247 assert added_items == [
248 ("Search", "search_catalog::True", True),
249 ("[I]Naruto[/I]", "search_catalog:Naruto:False", True),
250 ("[I]Bleach[/I]", "search_catalog:Bleach:False", True),
251 ("Clear All Search History", "clear_stremio_search_history::True", True),
252 ]
253
254
255def test_search_catalog_uses_stremio_routes_when_catalog_has_meta(monkeypatch):
256 added_urls = []
257
258 monkeypatch.setattr(
259 catalog_menus,
260 "catalogs_get_cache",
261 lambda *args, **kwargs: {
262 "metas": [
263 MetaPreview(
264 id="kitsu:hell-paradise-2",
265 type="series",
266 name="Jigokuraku 2nd Season",
267 poster="",
268 description="desc",
269 imdb_id="tt123",
270 )
271 ]
272 },
273 )
274 monkeypatch.setattr(catalog_menus, "setContent", lambda *args, **kwargs: None)
275 monkeypatch.setattr(catalog_menus, "end_of_directory", lambda *args, **kwargs: None)
276 monkeypatch.setattr(catalog_menus, "notification", lambda *args, **kwargs: None)
277 monkeypatch.setattr(catalog_menus, "add_next_button", lambda *args, **kwargs: None)
278 monkeypatch.setattr(catalog_menus, "show_keyboard", lambda *args, **kwargs: "paradise")
279 monkeypatch.setattr(catalog_menus.cache, "add_to_list", lambda *args, **kwargs: None)
280 monkeypatch.setattr(catalog_menus.PickleDatabase, "set_key", lambda self, key, value: None)
281 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": MagicMock())
282 monkeypatch.setattr(
283 catalog_menus,
284 "build_url",
285 lambda action, **kwargs: f"plugin://{action}",
286 )
287 monkeypatch.setattr(
288 catalog_menus,
289 "addDirectoryItem",
290 lambda handle, url, listitem, isFolder=True: added_urls.append(url),
291 )
292 monkeypatch.setattr(catalog_menus.cache, "get", lambda key: None)
293 monkeypatch.setattr(catalog_menus, "get_addon_by_base_url", lambda *args, **kwargs: None)
294 monkeypatch.setattr(catalog_menus, "add_tmdb_show_context_menu", lambda *args, **kwargs: [])
295
296 catalog_menus.search_catalog(
297 {
298 "page": 1,
299 "addon_url": "https://anime-kitsu.strem.fun",
300 "catalog_type": "anime",
301 "catalog_id": "kitsu-anime-list",
302 "menu_type": "anime",
303 "sub_menu_type": "series",
304 "has_meta_resource": True,
305 }
306 )
307
308 assert added_urls == ["plugin://list_stremio_seasons"]
309
310
311def test_list_stremio_catalogs_uses_batch_add(monkeypatch):
312 added_batches = []
313
314 class _ListItem:
315 def __init__(self, label=""):
316 self.label = label
317
318 def setArt(self, *args, **kwargs):
319 pass
320
321 class _Catalog:
322 def __init__(self, name, catalog_id, catalog_type, extra=None):
323 self.name = name
324 self.id = catalog_id
325 self.type = catalog_type
326 self.extra = extra or []
327
328 class _Manifest:
329 name = "Addon One"
330 logo = "logo.png"
331 types = ["series"]
332 catalogs = [
333 _Catalog("Trending", "trending", "series", extra=[{"name": "search"}]),
334 _Catalog("Popular", "popular", "series"),
335 ]
336
337 class _Addon:
338 manifest = _Manifest()
339
340 def url(self):
341 return "https://example.com/addon"
342
343 monkeypatch.setattr(catalog_menus, "get_selected_catalogs_addons", lambda: [_Addon()])
344 monkeypatch.setattr(catalog_menus, "translation", lambda value: {90006: "Search"}.get(value, str(value)))
345 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))
347 monkeypatch.setattr(
348 catalog_menus,
349 "build_url",
350 lambda action=None, **kwargs: f"{action}:{kwargs.get('catalog_id', '')}:{kwargs.get('page', '')}",
351 )
352 monkeypatch.setattr(
353 catalog_menus,
354 "add_directory_items_batch",
355 lambda items: added_batches.append(items),
356 )
357 monkeypatch.setattr(
358 catalog_menus,
359 "addDirectoryItem",
360 lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not use addDirectoryItem")),
361 )
362
363 catalog_menus.list_stremio_catalogs(menu_type="series", sub_menu_type="series")
364
365 assert len(added_batches) == 1
366 assert len(added_batches[0]) == 3
367 labels = [item[1].label for item in added_batches[0]]
368 assert labels == ["Search Trending", "Trending", "Popular"]
369
370
371def test_run_search_entry_preserves_stremio_route_in_library_payload(monkeypatch):
372 captured = {}
373
374 monkeypatch.setattr(search, "_handle_super_quick_play", lambda params: False)
375 monkeypatch.setattr(search, "set_content_type", lambda *args, **kwargs: None)
376 monkeypatch.setattr(search, "search_client", lambda *args, **kwargs: [object()])
377 monkeypatch.setattr(search, "_process_search_results", lambda *args, **kwargs: [object()])
378 monkeypatch.setattr(search, "auto_play_enabled", lambda: False)
379 monkeypatch.setattr(search, "show_source_select", lambda *args, **kwargs: True)
380
381 def _set_watched_title(title, ids, mode, tg_data="", media_type="", library_data=None):
382 captured["title"] = title
383 captured["ids"] = ids
384 captured["mode"] = mode
385 captured["media_type"] = media_type
386 captured["library_data"] = library_data
387
388 monkeypatch.setattr(search, "set_watched_title", _set_watched_title)
389
390 search.run_search_entry(
391 {
392 "query": "Jigokuraku",
393 "mode": "tv",
394 "media_type": "tv",
395 "ids": json.dumps({"imdb_id": "tt123", "original_id": "kitsu:jigokuraku-2"}),
396 "tv_data": json.dumps({"name": "Jigokuraku", "season": 1, "episode": 1}),
397 "scoped_addon_url": "https://anime-kitsu.strem.fun",
398 "stremio_addon_url": "https://anime-kitsu.strem.fun",
399 "stremio_catalog_type": "anime",
400 "stremio_meta_id": "kitsu:jigokuraku-2",
401 }
402 )
403
404 assert captured["library_data"] == {
405 "source": "stremio_catalog",
406 "addon_url": "https://anime-kitsu.strem.fun",
407 "catalog_type": "anime",
408 "meta_id": "kitsu:jigokuraku-2",
409 }
410
411
412def test_search_catalog_backfills_missing_menu_type(monkeypatch):
413 monkeypatch.setattr(
414 catalog_menus,
415 "catalogs_get_cache",
416 lambda *args, **kwargs: {"metas": []},
417 )
418 monkeypatch.setattr(catalog_menus, "add_meta_items", lambda metas, params: (_ for _ in ()).throw(AssertionError(params)))
419 monkeypatch.setattr(catalog_menus, "show_keyboard", lambda *args, **kwargs: "paradise")
420 monkeypatch.setattr(catalog_menus.cache, "add_to_list", lambda *args, **kwargs: None)
421 monkeypatch.setattr(catalog_menus.PickleDatabase, "set_key", lambda self, key, value: None)
422 monkeypatch.setattr(catalog_menus, "add_next_button", lambda *args, **kwargs: None)
423 monkeypatch.setattr(catalog_menus, "end_of_directory", lambda *args, **kwargs: None)
424
425 captured = {}
426
427 def _add_meta_items(metas, params):
428 captured.update(params)
429
430 monkeypatch.setattr(catalog_menus, "add_meta_items", _add_meta_items)
431
432 catalog_menus.search_catalog(
433 {
434 "page": 1,
435 "addon_url": "https://anime-kitsu.strem.fun",
436 "catalog_type": "anime",
437 "catalog_id": "kitsu-anime-list",
438 }
439 )
440
441 assert captured["menu_type"] == "anime"
442 assert captured["sub_menu_type"] == ""
443
444
445def test_clear_stremio_search_history_clears_only_current_catalog(monkeypatch):
446 cleared = []
447 shown = []
448
449 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))
451
452 params = {
453 "addon_url": "https://anime-kitsu.strem.fun",
454 "catalog_type": "anime",
455 "catalog_id": "kitsu-search",
456 }
457
458 catalog_menus.clear_stremio_search_history(params)
459
460 assert cleared == ["stremio_search_catalog|https://anime-kitsu.strem.fun|anime|kitsu-search"]
461 assert shown == [params]
462
463
464def test_list_stremio_episodes_uses_safe_title_fallback(monkeypatch):
465 meta_data = Meta.from_dict(
466 {
467 "id": "tt123",
468 "type": "series",
469 "name": "Fallback Show",
470 "imdb_id": "tt123",
471 "videos": [
472 {
473 "id": "tt123:1:2",
474 "title": "",
475 "overview": "Overview",
476 "imdbSeason": 1,
477 "imdbEpisode": 2,
478 "season": 1,
479 "episode": 2,
480 "released": "2024-01-01",
481 "thumbnail": "",
482 }
483 ],
484 }
485 )
486 added = []
487
488 class _InfoTag:
489 def setUniqueID(self, *args, **kwargs):
490 pass
491
492 def setTitle(self, *args, **kwargs):
493 pass
494
495 def setPlot(self, *args, **kwargs):
496 pass
497
498 def setRating(self, *args, **kwargs):
499 pass
500
501 def setSeason(self, *args, **kwargs):
502 pass
503
504 def setEpisode(self, *args, **kwargs):
505 pass
506
507 def setTvShowTitle(self, *args, **kwargs):
508 pass
509
510 def setMediaType(self, *args, **kwargs):
511 pass
512
513 def setPremiered(self, *args, **kwargs):
514 pass
515
516 def __getattr__(self, name):
517 return lambda *args, **kwargs: None
518
519 class _ListItem:
520 def __init__(self, label=""):
521 self.label = label
522
523 def setLabel(self, label):
524 self.label = label
525
526 def getVideoInfoTag(self):
527 return _InfoTag()
528
529 def setArt(self, *args, **kwargs):
530 pass
531
532 def setProperty(self, *args, **kwargs):
533 pass
534
535 def addContextMenuItems(self, *args, **kwargs):
536 pass
537
538 monkeypatch.setattr(
539 catalog_menus,
540 "catalogs_get_cache",
541 lambda *args, **kwargs: {"meta": meta_data},
542 )
543 monkeypatch.setattr(catalog_menus.cache, "get", lambda key: None)
544 monkeypatch.setattr(catalog_menus, "tmdb_get", lambda *args, **kwargs: None)
545 monkeypatch.setattr(catalog_menus, "addon_has_stream", lambda *args, **kwargs: False)
546 monkeypatch.setattr(catalog_menus, "build_url", lambda *args, **kwargs: "plugin://test")
547 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": _ListItem(label))
548 monkeypatch.setattr(
549 catalog_menus,
550 "add_directory_items_batch",
551 lambda items: added.extend(listitem.label for _, listitem, _ in items),
552 )
553 monkeypatch.setattr(catalog_menus, "setContent", lambda *args, **kwargs: None)
554 monkeypatch.setattr(catalog_menus, "end_of_directory", lambda *args, **kwargs: None)
555 monkeypatch.setattr(catalog_menus, "notification", lambda *args, **kwargs: None)
556
557 catalog_menus.list_stremio_episodes(
558 {
559 "addon_url": "https://example.com/addon",
560 "catalog_type": "series",
561 "meta_id": "tt123",
562 "season": "1",
563 }
564 )
565
566 assert added == ["1x2. Fallback Show"]
567
568
569def test_list_stremio_episodes_main_search_url_preserves_stremio_route(monkeypatch):
570 captured_urls = []
571
572 meta_data = Meta.from_dict(
573 {
574 "id": "kitsu:jigokuraku-2",
575 "type": "series",
576 "name": "Jigokuraku 2nd Season",
577 "poster": "poster.jpg",
578 "background": "fanart.jpg",
579 "genres": ["Anime"],
580 "videos": [
581 {
582 "id": "ep1",
583 "title": "Episode 1",
584 "season": 1,
585 "episode": 1,
586 "overview": "Episode plot",
587 }
588 ],
589 }
590 )
591
592 monkeypatch.setattr(
593 catalog_menus,
594 "catalogs_get_cache",
595 lambda *args, **kwargs: {"meta": meta_data},
596 )
597 monkeypatch.setattr(catalog_menus, "tmdb_get", lambda *args, **kwargs: None)
598 monkeypatch.setattr(catalog_menus, "get_addon_by_base_url", lambda *args, **kwargs: None)
599 monkeypatch.setattr(catalog_menus, "addon_has_stream", lambda *args, **kwargs: False)
600 monkeypatch.setattr(catalog_menus, "notification", lambda *args, **kwargs: None)
601 monkeypatch.setattr(catalog_menus, "end_of_directory", lambda *args, **kwargs: None)
602 monkeypatch.setattr(catalog_menus, "setContent", lambda *args, **kwargs: None)
603 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": MagicMock())
604 monkeypatch.setattr(
605 catalog_menus,
606 "build_url",
607 lambda action, **kwargs: captured_urls.append((action, kwargs)) or f"plugin://{action}",
608 )
609 monkeypatch.setattr(catalog_menus, "addDirectoryItem", lambda *args, **kwargs: None)
610 monkeypatch.setattr(catalog_menus, "add_tmdb_episode_context_menu", lambda *args, **kwargs: [])
611
612 catalog_menus.list_stremio_episodes(
613 {
614 "addon_url": "https://anime-kitsu.strem.fun",
615 "catalog_type": "anime",
616 "meta_id": "kitsu:jigokuraku-2",
617 "season": "1",
618 }
619 )
620
621 search_calls = [kwargs for action, kwargs in captured_urls if action == "search"]
622 assert len(search_calls) == 1
623 assert search_calls[0]["stremio_addon_url"] == "https://anime-kitsu.strem.fun"
624 assert search_calls[0]["stremio_catalog_type"] == "anime"
625 assert search_calls[0]["stremio_meta_id"] == "kitsu:jigokuraku-2"
626
627
628def test_add_meta_items_uses_tmdb_context_menu_only_for_reliable_tmdb_ids(monkeypatch):
629 added_items = []
630
631 class _InfoTag:
632 def __getattr__(self, name):
633 return lambda *args, **kwargs: None
634
635 class _ListItem:
636 def __init__(self, label=""):
637 self.label = label
638 self.context_menu = []
639
640 def getVideoInfoTag(self):
641 return _InfoTag()
642
643 def setArt(self, *args, **kwargs):
644 pass
645
646 def setProperty(self, *args, **kwargs):
647 pass
648
649 def addContextMenuItems(self, items, replaceItems=False):
650 self.context_menu.extend(items)
651
652 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": _ListItem(label))
653 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)))
655 monkeypatch.setattr(catalog_menus, "get_addon_by_base_url", lambda *args, **kwargs: None)
656 monkeypatch.setattr(catalog_menus, "addon_has_meta", lambda *args, **kwargs: False)
657 monkeypatch.setattr(catalog_menus, "addon_has_stream", lambda *args, **kwargs: False)
658 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)")
661 monkeypatch.setattr(
662 catalog_menus,
663 "add_tmdb_movie_context_menu",
664 lambda **kwargs: [("Extras", "extras"), ("Play Trailer", "trailer"), ("Add to Library", "tmdb-lib")],
665 )
666 monkeypatch.setattr(
667 catalog_menus,
668 "addDirectoryItem",
669 lambda handle, url, listitem, isFolder=False: added_items.append(listitem),
670 )
671
672 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"),
675 ]
676
677 catalog_menus.add_meta_items(
678 metas,
679 {
680 "addon_url": "https://example.com/addon",
681 "menu_type": "movie",
682 "catalog_type": "movie",
683 },
684 )
685
686 reliable_menu = [label for label, _ in added_items[0].context_menu]
687 custom_menu = [label for label, _ in added_items[1].context_menu]
688
689 assert "Extras" in reliable_menu
690 assert "Play Trailer" in reliable_menu
691 assert reliable_menu.count("Add to Library") == 1
692 assert "Open Settings" in reliable_menu
693 assert "Extras" not in custom_menu
694 assert "Play Trailer" not in custom_menu
695 assert custom_menu == ["Add to Library", "Open Settings"]
696
697
698def test_add_meta_items_resolves_tmdb_id_from_imdb_for_context_menu(monkeypatch):
699 added_items = []
700 captured_ids = {}
701
702 class _InfoTag:
703 def __getattr__(self, name):
704 return lambda *args, **kwargs: None
705
706 class _ListItem:
707 def __init__(self, label=""):
708 self.label = label
709 self.context_menu = []
710
711 def getVideoInfoTag(self):
712 return _InfoTag()
713
714 def setArt(self, *args, **kwargs):
715 pass
716
717 def setProperty(self, *args, **kwargs):
718 pass
719
720 def addContextMenuItems(self, items, replaceItems=False):
721 self.context_menu.extend(items)
722
723 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": _ListItem(label))
724 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)))
726 monkeypatch.setattr(catalog_menus, "get_addon_by_base_url", lambda *args, **kwargs: None)
727 monkeypatch.setattr(catalog_menus, "addon_has_meta", lambda *args, **kwargs: False)
728 monkeypatch.setattr(catalog_menus, "addon_has_stream", lambda *args, **kwargs: False)
729 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)")
732 monkeypatch.setattr(
733 catalog_menus.cache,
734 "get",
735 lambda key: type("FindResult", (), {"movie_results": [{"id": 550}], "tv_results": []})()
736 if key == "find_by_imdb_id|tt0133093"
737 else None,
738 )
739
740 def _movie_context_menu(**kwargs):
741 captured_ids.update(kwargs["ids"])
742 return [("Extras", "extras"), ("Play Trailer", "trailer")]
743
744 monkeypatch.setattr(catalog_menus, "add_tmdb_movie_context_menu", _movie_context_menu)
745 monkeypatch.setattr(
746 catalog_menus,
747 "addDirectoryItem",
748 lambda handle, url, listitem, isFolder=False: added_items.append(listitem),
749 )
750
751 metas = [
752 MetaPreview(id="tt0133093", type="movie", name="The Matrix", poster="", description="desc", imdb_id="tt0133093"),
753 ]
754
755 catalog_menus.add_meta_items(
756 metas,
757 {
758 "addon_url": "https://example.com/addon",
759 "menu_type": "movie",
760 "catalog_type": "movie",
761 },
762 )
763
764 menu_labels = [label for label, _ in added_items[0].context_menu]
765 assert captured_ids["tmdb_id"] == "550"
766 assert "Extras" in menu_labels
767 assert "Play Trailer" in menu_labels
768 assert menu_labels.count("Open Settings") == 1
769
770
771def test_list_stremio_movie_builds_enriched_play_media_payload(monkeypatch):
772 captured = {}
773
774 monkeypatch.setattr(
775 catalog_menus,
776 "catalogs_get_cache",
777 lambda *args, **kwargs: {
778 "streams": [
779 type(
780 "Stream",
781 (),
782 {"title": "Movie Stream", "description": "Stream plot", "url": "https://video", "infoHash": ""},
783 )()
784 ]
785 },
786 )
787 monkeypatch.setattr(catalog_menus, "notification", lambda *args, **kwargs: None)
788 monkeypatch.setattr(catalog_menus, "end_of_directory", lambda *args, **kwargs: None)
789 monkeypatch.setattr(catalog_menus, "make_list_item", lambda label="", path="": MagicMock())
790 monkeypatch.setattr(catalog_menus, "addDirectoryItem", lambda *args, **kwargs: None)
791
792 def _build_url(action, **kwargs):
793 captured["action"] = action
794 captured["data"] = json.loads(kwargs["data"])
795 return "plugin://test"
796
797 monkeypatch.setattr(catalog_menus, "build_url", _build_url)
798
799 catalog_menus.list_stremio_movie(
800 {
801 "addon_url": "https://example.com/addon",
802 "catalog_type": "movie",
803 "meta_id": "custom:movie",
804 "ids": json.dumps({"tmdb_id": "100", "imdb_id": "tt100", "original_id": "custom:movie"}),
805 "poster": "poster.jpg",
806 "fanart": "fanart.jpg",
807 "genres": json.dumps(["Drama"]),
808 "overview": "Catalog overview",
809 }
810 )
811
812 assert captured["action"] == "play_media"
813 assert captured["data"] == {
814 "mode": "movie",
815 "source": "stremio_catalog",
816 "title": "Movie Stream",
817 "overview": "Stream plot",
818 "poster": "poster.jpg",
819 "fanart": "fanart.jpg",
820 "genres": ["Drama"],
821 "ids": {"tmdb_id": "100", "imdb_id": "tt100", "original_id": "custom:movie"},
822 "addon_url": "https://example.com/addon",
823 "catalog_type": "movie",
824 "meta_id": "custom:movie",
825 "url": "https://video",
826 "type": catalog_menus.IndexerType.DIRECT,
827 }
plugin.video.jacktook/tests/unit/test_title_parser.py +16 -0
……@@ -0,0 +1,16 @@
1import pytest
2from lib.utils.parsers.title_parser import parse_title_info
3
4def test_parse_title_info_complex():
5 title = "Movie.Name.2024.2160p.REPACK.HEVC.DV.HDR10.Atmos.TrueHD.7.1-GROUP"
6 info = parse_title_info(title)
7 assert "HEVC" in info["codec"]
8 assert "ATMOS" in info["audio"]
9 assert "DV" in info["hdr"]
10 assert "GROUP" in info["release_group"]
11
12def test_parse_title_info_simple():
13 title = "Show.S01E01.1080p.H264-EVO"
14 info = parse_title_info(title)
15 assert "H.264" in info["codec"]
16 assert info["release_group"] == "EVO"
plugin.video.jacktook/tests/unit/test_tmdb_multi_context_actions.py +144 -0
……@@ -0,0 +1,144 @@
1import json
2from types import SimpleNamespace
3from urllib.parse import parse_qs, urlparse
4from unittest.mock import MagicMock, patch
5
6from lib.clients.tmdb.people_client import PeopleClient
7from lib.clients.tmdb.tmdb import TmdbClient
8from lib.clients.tmdb.utils.utils import add_tmdb_movie_context_menu
9
10
11def _parse_container_update_command(command):
12 url = command[len("Container.Update(") : -1]
13 query = parse_qs(urlparse(url).query)
14 return {key: values[0] for key, values in query.items()}
15
16
17def _get_menu_item(menu, label):
18 for item_label, command in menu:
19 if item_label == label:
20 return item_label, command
21 return None
22
23
24def test_movie_multi_context_menu_passes_media_type_to_context_actions():
25 with patch(
26 "lib.clients.tmdb.utils.utils.translation",
27 side_effect=lambda value: f"t-{value}",
28 ):
29 menu = add_tmdb_movie_context_menu(
30 mode="multi",
31 media_type="movie",
32 title="Demo Movie",
33 ids={"tmdb_id": 550},
34 )
35
36 recommendations = _get_menu_item(menu, "t-90050")
37 similar = _get_menu_item(menu, "t-90051")
38 people = _get_menu_item(menu, "t-90081")
39
40 assert recommendations is not None
41 assert similar is not None
42 assert people is not None
43
44 recommendations_query = _parse_container_update_command(recommendations[1])
45 similar_query = _parse_container_update_command(similar[1])
46 people_query = _parse_container_update_command(people[1])
47
48 assert recommendations_query["action"] == "search_tmdb_recommendations"
49 assert recommendations_query["mode"] == "multi"
50 assert recommendations_query["media_type"] == "movie"
51
52 assert similar_query["action"] == "search_tmdb_similar"
53 assert similar_query["mode"] == "multi"
54 assert similar_query["media_type"] == "movie"
55
56 assert people_query["action"] == "search_people_by_id"
57 assert people_query["mode"] == "multi"
58 assert people_query["media_type"] == "movie"
59
60
61def 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")])
63
64 with patch("lib.clients.tmdb.tmdb.tmdb_get", return_value=results) as tmdb_get, patch(
65 "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(
71 "lib.clients.tmdb.tmdb.BaseTmdbClient.add_media_directory_item",
72 return_value=("url", MagicMock(), False),
73 ), patch("lib.clients.tmdb.tmdb.add_directory_items_batch"), patch(
74 "lib.clients.tmdb.tmdb.add_next_button"
75 ) as add_next_button, patch("lib.clients.tmdb.tmdb.end_of_directory"), patch(
76 "lib.clients.tmdb.tmdb.TmdbClient._get_cached_tmdb_item_metadata",
77 return_value={},
78 ), patch("lib.clients.tmdb.tmdb._apply_tmdb_view") as apply_view:
79 TmdbClient.search_tmdb_recommendations(
80 {
81 "ids": json.dumps({"tmdb_id": 550}),
82 "mode": "multi",
83 "media_type": "movie",
84 "page": "1",
85 }
86 )
87
88 assert tmdb_get.call_args[0][0] == "movie_recommendations"
89 set_content_type.assert_called_once_with("movies")
90 apply_view.assert_called_once_with("movies")
91 assert add_next_button.call_args.kwargs["media_type"] == "movie"
92
93
94def 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")])
96
97 with patch("lib.clients.tmdb.tmdb.tmdb_get", return_value=results) as tmdb_get, patch(
98 "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(
104 "lib.clients.tmdb.tmdb.BaseTmdbClient.add_media_directory_item",
105 return_value=("url", MagicMock(), False),
106 ), patch("lib.clients.tmdb.tmdb.add_directory_items_batch"), patch(
107 "lib.clients.tmdb.tmdb.add_next_button"
108 ) as add_next_button, patch("lib.clients.tmdb.tmdb.end_of_directory"), patch(
109 "lib.clients.tmdb.tmdb.TmdbClient._get_cached_tmdb_item_metadata",
110 return_value={},
111 ), patch("lib.clients.tmdb.tmdb._apply_tmdb_view") as apply_view:
112 TmdbClient.search_tmdb_similar(
113 {
114 "ids": json.dumps({"tmdb_id": 550}),
115 "mode": "multi",
116 "media_type": "movie",
117 "page": "1",
118 }
119 )
120
121 assert tmdb_get.call_args[0][0] == "movie_similar"
122 set_content_type.assert_called_once_with("movies")
123 apply_view.assert_called_once_with("movies")
124 assert add_next_button.call_args.kwargs["media_type"] == "movie"
125
126
127def test_search_people_by_id_uses_movie_credits_for_multi_movie():
128 credits = SimpleNamespace(cast=[])
129
130 with patch("lib.clients.tmdb.people_client.tmdb_get", return_value=credits) as tmdb_get, patch(
131 "lib.clients.tmdb.people_client.set_content_type"
132 ), patch("lib.clients.tmdb.people_client.set_pluging_category"), patch(
133 "lib.clients.tmdb.people_client.execute_thread_pool"
134 ), patch("lib.clients.tmdb.people_client.end_of_directory"):
135 PeopleClient.search_people_by_id(
136 {
137 "mode": "multi",
138 "media_type": "movie",
139 "ids": json.dumps({"tmdb_id": 550}),
140 }
141 )
142
143 assert tmdb_get.call_args[0][0] == "movie_credits"
144 assert tmdb_get.call_args.kwargs["params"] == 550
plugin.video.jacktook/tests/unit/test_tmdb_search_modes.py +340 -0
……@@ -0,0 +1,340 @@
1import json
2from unittest.mock import MagicMock, patch
3
4from lib.clients.tmdb.tmdb import TmdbClient
5
6
7def test_tmdb_search_modes_runs_title_year_search_for_movies():
8 tmdb_obj = {
9 "external_ids": {"imdb_id": "tt1375666", "tvdb_id": ""},
10 }
11
12 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
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:
15 dialog_cls.return_value.select.return_value = 0
16 dialog_cls.return_value.numeric.return_value = "2010"
17
18 TmdbClient.tmdb_search_modes(
19 {
20 "mode": "movies",
21 "media_type": "movie",
22 "tmdb_id": "27205",
23 "query": "Inception",
24 }
25 )
26
27 payload = run_search_entry.call_args[0][0]
28 assert payload["query"] == "Inception Alt"
29 assert payload["mode"] == "movies"
30 assert payload["search_variant"] == "title_year"
31 assert payload["year"] == "2010"
32 assert payload["rescrape"] is True
33 assert payload["force_select"] is True
34 assert json.loads(payload["ids"]) == {
35 "tmdb_id": "27205",
36 "imdb_id": "tt1375666",
37 "tvdb_id": "",
38 }
39
40
41def test_tmdb_search_modes_runs_original_title_search_for_movies():
42 tmdb_obj = {
43 "external_ids": {"imdb_id": "tt0245429", "tvdb_id": ""},
44 }
45
46 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
47 "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:
49 dialog_cls.return_value.select.return_value = 1
50
51 TmdbClient.tmdb_search_modes(
52 {
53 "mode": "movies",
54 "media_type": "movie",
55 "tmdb_id": "129",
56 "query": "Spirited Away",
57 }
58 )
59
60 payload = run_search_entry.call_args[0][0]
61 assert payload["search_variant"] == "original_title"
62 assert payload["query"] == "Sen to Chihiro no kamikakushi"
63 assert payload["year"] == ""
64 dialog_cls.return_value.numeric.assert_not_called()
65
66
67def test_tmdb_search_modes_returns_early_when_dialog_cancelled():
68 tmdb_obj = {
69 "external_ids": {"imdb_id": "tt1375666", "tvdb_id": ""},
70 }
71
72 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
73 "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:
75 dialog_cls.return_value.select.return_value = -1
76
77 TmdbClient.tmdb_search_modes(
78 {
79 "mode": "movies",
80 "media_type": "movie",
81 "tmdb_id": "27205",
82 "query": "Inception",
83 }
84 )
85
86 run_search_entry.assert_not_called()
87
88
89def test_tmdb_search_modes_returns_early_when_year_input_cancelled():
90 tmdb_obj = {
91 "external_ids": {"imdb_id": "tt1375666", "tvdb_id": ""},
92 }
93
94 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
95 "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:
97 dialog_cls.return_value.select.return_value = 0
98 dialog_cls.return_value.numeric.return_value = ""
99
100 TmdbClient.tmdb_search_modes(
101 {
102 "mode": "movies",
103 "media_type": "movie",
104 "tmdb_id": "27205",
105 "query": "Inception",
106 }
107 )
108
109 run_search_entry.assert_not_called()
110
111
112def test_tmdb_search_modes_rejects_invalid_manual_year():
113 tmdb_obj = {
114 "external_ids": {"imdb_id": "tt1375666", "tvdb_id": ""},
115 }
116
117 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
118 "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:
122 dialog_cls.return_value.select.return_value = 2
123 dialog_cls.return_value.numeric.return_value = "20ab"
124
125 TmdbClient.tmdb_search_modes(
126 {
127 "mode": "movies",
128 "media_type": "movie",
129 "tmdb_id": "27205",
130 "query": "Inception",
131 }
132 )
133
134 notification.assert_called_once()
135 run_search_entry.assert_not_called()
136
137
138def test_tmdb_search_modes_returns_early_when_title_edit_cancelled():
139 tmdb_obj = {
140 "external_ids": {"imdb_id": "tt1375666", "tvdb_id": ""},
141 }
142
143 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
144 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
145 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value=""), patch(
146 "lib.search.run_search_entry"
147 ) as run_search_entry:
148 dialog_cls.return_value.select.return_value = 0
149
150 TmdbClient.tmdb_search_modes(
151 {
152 "mode": "movies",
153 "media_type": "movie",
154 "tmdb_id": "27205",
155 "query": "Inception",
156 }
157 )
158
159 dialog_cls.return_value.numeric.assert_not_called()
160 run_search_entry.assert_not_called()
161
162
163def test_tmdb_episode_search_modes_runs_search_with_edited_title_season_episode():
164 tmdb_obj = MagicMock()
165 tmdb_obj.original_name = "Breaking Bad"
166
167 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
168 "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:
172 dialog_cls.return_value.select.return_value = 0
173 dialog_cls.return_value.numeric.side_effect = ["2", "5"]
174
175 TmdbClient.tmdb_episode_search_modes(
176 {
177 "mode": "tv",
178 "media_type": "tv",
179 "query": "Breaking Bad",
180 "ids": json.dumps({"tmdb_id": "1396", "imdb_id": "tt0903747", "tvdb_id": "81189"}),
181 "tv_data": json.dumps({"name": "Episode 5", "season": 1, "episode": 5}),
182 }
183 )
184
185 payload = run_search_entry.call_args[0][0]
186 assert payload["query"] == "Breaking Bad Alt"
187 assert payload["mode"] == "tv"
188 assert payload["rescrape"] is True
189 assert payload["force_select"] is True
190 tv_data = json.loads(payload["tv_data"])
191 assert tv_data["season"] == 2
192 assert tv_data["episode"] == 5
193 assert json.loads(payload["ids"]) == {
194 "tmdb_id": "1396",
195 "imdb_id": "tt0903747",
196 "tvdb_id": "81189",
197 }
198
199
200def test_tmdb_episode_search_modes_runs_search_with_original_title():
201 tmdb_obj = MagicMock()
202 tmdb_obj.original_name = "Breaking Bad Original"
203
204 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
205 "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:
209 dialog_cls.return_value.select.return_value = 1
210 dialog_cls.return_value.numeric.side_effect = ["3", "8"]
211
212 TmdbClient.tmdb_episode_search_modes(
213 {
214 "mode": "tv",
215 "media_type": "tv",
216 "query": "Breaking Bad",
217 "ids": json.dumps({"tmdb_id": "1396"}),
218 "tv_data": json.dumps({"name": "Episode 5", "season": 1, "episode": 5}),
219 }
220 )
221
222 payload = run_search_entry.call_args[0][0]
223 assert payload["query"] == "Breaking Bad Original"
224 tv_data = json.loads(payload["tv_data"])
225 assert tv_data["season"] == 3
226 assert tv_data["episode"] == 8
227
228
229def test_tmdb_episode_search_modes_returns_early_when_selector_cancelled():
230 tmdb_obj = MagicMock()
231 tmdb_obj.original_name = "Breaking Bad"
232
233 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
234 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
235 ) as dialog_cls, patch("lib.search.run_search_entry") as run_search_entry:
236 dialog_cls.return_value.select.return_value = -1
237
238 TmdbClient.tmdb_episode_search_modes(
239 {
240 "mode": "tv",
241 "media_type": "tv",
242 "query": "Breaking Bad",
243 "ids": json.dumps({"tmdb_id": "1396"}),
244 "tv_data": json.dumps({"name": "Episode 5", "season": 1, "episode": 5}),
245 }
246 )
247
248 dialog_cls.return_value.numeric.assert_not_called()
249 run_search_entry.assert_not_called()
250
251
252def test_tmdb_episode_search_modes_returns_early_when_title_cancelled():
253 tmdb_obj = MagicMock()
254 tmdb_obj.original_name = "Breaking Bad"
255
256 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
257 "lib.clients.tmdb.tmdb.xbmcgui.Dialog"
258 ) as dialog_cls, patch("lib.clients.tmdb.tmdb.show_keyboard", return_value=""), patch(
259 "lib.search.run_search_entry"
260 ) as run_search_entry:
261 dialog_cls.return_value.select.return_value = 0
262
263 TmdbClient.tmdb_episode_search_modes(
264 {
265 "mode": "tv",
266 "media_type": "tv",
267 "query": "Breaking Bad",
268 "ids": json.dumps({"tmdb_id": "1396"}),
269 "tv_data": json.dumps({"name": "Episode 5", "season": 1, "episode": 5}),
270 }
271 )
272
273 dialog_cls.return_value.numeric.assert_not_called()
274 run_search_entry.assert_not_called()
275
276
277def test_tmdb_episode_search_modes_returns_early_when_season_cancelled():
278 tmdb_obj = MagicMock()
279 tmdb_obj.original_name = "Breaking Bad"
280
281 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
282 "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:
286 dialog_cls.return_value.select.return_value = 0
287 dialog_cls.return_value.numeric.return_value = ""
288
289 TmdbClient.tmdb_episode_search_modes(
290 {
291 "mode": "tv",
292 "media_type": "tv",
293 "query": "Breaking Bad",
294 "ids": json.dumps({"tmdb_id": "1396"}),
295 "tv_data": json.dumps({"name": "Episode 5", "season": 1, "episode": 5}),
296 }
297 )
298
299 run_search_entry.assert_not_called()
300
301
302def test_tmdb_episode_search_modes_rejects_invalid_season():
303 tmdb_obj = MagicMock()
304 tmdb_obj.original_name = "Breaking Bad"
305
306 with patch.object(TmdbClient, "_get_tmdb_metadata", return_value=tmdb_obj), patch(
307 "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:
311 dialog_cls.return_value.select.return_value = 0
312 dialog_cls.return_value.numeric.return_value = "abc"
313
314 TmdbClient.tmdb_episode_search_modes(
315 {
316 "mode": "tv",
317 "media_type": "tv",
318 "query": "Breaking Bad",
319 "ids": json.dumps({"tmdb_id": "1396"}),
320 "tv_data": json.dumps({"name": "Episode 5", "season": 1, "episode": 5}),
321 }
322 )
323
324 notification.assert_called_once()
325 run_search_entry.assert_not_called()
326
327
328def test_tmdb_episode_search_modes_returns_early_for_non_tv_mode():
329 with patch("lib.search.run_search_entry") as run_search_entry:
330 TmdbClient.tmdb_episode_search_modes(
331 {
332 "mode": "movies",
333 "media_type": "movie",
334 "query": "Inception",
335 "ids": json.dumps({"tmdb_id": "27205"}),
336 "tv_data": json.dumps({}),
337 }
338 )
339
340 run_search_entry.assert_not_called()
plugin.video.jacktook/tests/unit/test_tmdb_trailers.py +255 -0
……@@ -0,0 +1,255 @@
1import os
2from unittest.mock import patch
3
4from lib.api.tmdbv3api.as_obj import AsObj
5from lib.clients.tmdb.trailers import (
6 _normalize_media_type,
7 choose_tmdb_trailer,
8 resolve_tmdb_trailer,
9)
10
11
12def test_normalize_media_type_supports_movie_and_tv_variants():
13 assert _normalize_media_type("movie") == "movie"
14 assert _normalize_media_type("movies") == "movie"
15 assert _normalize_media_type("tv") == "tv"
16 assert _normalize_media_type("shows") == "tv"
17
18
19def test_normalize_media_type_returns_none_for_unsupported_values():
20 assert _normalize_media_type(None) is None
21 assert _normalize_media_type("documentary") is None
22
23
24def test_choose_tmdb_trailer_prefers_trailer_over_teaser_then_official_flag():
25 videos = [
26 {
27 "site": "YouTube",
28 "type": "Teaser",
29 "official": True,
30 "key": "teaser12345A",
31 },
32 {
33 "site": "YouTube",
34 "type": "Trailer",
35 "official": False,
36 "key": "trailer1234",
37 },
38 {
39 "site": "YouTube",
40 "type": "Trailer",
41 "official": True,
42 "key": "official1234",
43 },
44 ]
45
46 assert choose_tmdb_trailer(videos) == {
47 "yt_id": "official1234",
48 "youtube_url": "https://www.youtube.com/watch?v=official1234",
49 }
50
51
52def test_choose_tmdb_trailer_ignores_non_youtube_and_unrelated_types():
53 videos = [
54 {"site": "Vimeo", "type": "Trailer", "official": True, "key": "vimeo123456"},
55 {"site": "YouTube", "type": "Behind the Scenes", "official": True, "key": "bts12345678"},
56 ]
57
58 assert choose_tmdb_trailer(videos) is None
59
60
61def test_choose_tmdb_trailer_accepts_clip_as_lower_priority_fallback():
62 videos = [
63 {"site": "YouTube", "type": "Clip", "official": True, "key": "clip1234567"},
64 {"site": "YouTube", "type": "Teaser", "official": False, "key": "teaser12345"},
65 ]
66
67 assert choose_tmdb_trailer(videos) == {
68 "yt_id": "teaser12345",
69 "youtube_url": "https://www.youtube.com/watch?v=teaser12345",
70 }
71
72
73def test_choose_tmdb_trailer_uses_clip_when_no_trailer_or_teaser_exists():
74 videos = [
75 {"site": "YouTube", "type": "Clip", "official": True, "key": "clip1234567"},
76 ]
77
78 assert choose_tmdb_trailer(videos) == {
79 "yt_id": "clip1234567",
80 "youtube_url": "https://www.youtube.com/watch?v=clip1234567",
81 }
82
83
84def test_resolve_tmdb_trailer_uses_movie_wrapper(monkeypatch):
85 class FakeMovie:
86 def videos(self, tmdb_id, page=1):
87 assert tmdb_id == 550
88 assert page == 1
89 return [
90 {
91 "site": "YouTube",
92 "type": "Trailer",
93 "official": True,
94 "key": "movie123456",
95 }
96 ]
97
98 monkeypatch.setattr("lib.clients.tmdb.trailers.Movie", FakeMovie)
99
100 assert resolve_tmdb_trailer(550, "movies") == {
101 "yt_id": "movie123456",
102 "youtube_url": "https://www.youtube.com/watch?v=movie123456",
103 }
104
105
106def test_resolve_tmdb_trailer_uses_tv_wrapper(monkeypatch):
107 class FakeTV:
108 def videos(self, tmdb_id, include_video_language=None, page=1):
109 assert tmdb_id == 1399
110 assert include_video_language is None
111 assert page == 1
112 return [
113 {
114 "site": "YouTube",
115 "type": "Teaser",
116 "official": False,
117 "key": "tv123456789",
118 }
119 ]
120
121 monkeypatch.setattr("lib.clients.tmdb.trailers.TV", FakeTV)
122
123 assert resolve_tmdb_trailer(1399, "tv") == {
124 "yt_id": "tv123456789",
125 "youtube_url": "https://www.youtube.com/watch?v=tv123456789",
126 }
127
128
129def test_resolve_tmdb_trailer_handles_tv_wrapper_results_dict(monkeypatch):
130 class FakeTV:
131 def videos(self, tmdb_id, include_video_language=None, page=1):
132 assert tmdb_id == 1416
133 return {
134 "id": tmdb_id,
135 "results": [
136 {
137 "site": "YouTube",
138 "type": "Trailer",
139 "official": True,
140 "key": "tvresults01",
141 }
142 ],
143 }
144
145 monkeypatch.setattr("lib.clients.tmdb.trailers.TV", FakeTV)
146
147 assert resolve_tmdb_trailer(1416, "tv") == {
148 "yt_id": "tvresults01",
149 "youtube_url": "https://www.youtube.com/watch?v=tvresults01",
150 }
151
152
153def test_resolve_tmdb_trailer_handles_tv_wrapper_asobj_with_results(monkeypatch):
154 class FakeTV:
155 def videos(self, tmdb_id, include_video_language=None, page=1):
156 return AsObj(
157 {
158 "id": tmdb_id,
159 "results": [
160 {
161 "site": "YouTube",
162 "type": "Trailer",
163 "official": True,
164 "key": "tvasobj001",
165 }
166 ],
167 }
168 )
169
170 monkeypatch.setattr("lib.clients.tmdb.trailers.TV", FakeTV)
171
172 assert resolve_tmdb_trailer(1622, "tv") == {
173 "yt_id": "tvasobj001",
174 "youtube_url": "https://www.youtube.com/watch?v=tvasobj001",
175 }
176
177
178def test_resolve_tmdb_trailer_returns_none_for_invalid_media_type():
179 assert resolve_tmdb_trailer(1, "person") is None
180
181
182def test_resolve_tmdb_trailer_logs_when_no_candidate_found(monkeypatch):
183 class FakeMovie:
184 def videos(self, tmdb_id, page=1):
185 return []
186
187 monkeypatch.setattr("lib.clients.tmdb.trailers.Movie", FakeMovie)
188
189 with patch("lib.clients.tmdb.trailers.kodilog") as log_mock:
190 assert resolve_tmdb_trailer(550, "movie") is None
191
192 log_mock.assert_called()
193
194
195def test_resolve_tmdb_trailer_retries_tv_lookup_with_language_fallback(monkeypatch):
196 calls = []
197
198 class FakeTV:
199 def __init__(self):
200 self.language = None
201
202 def videos(self, tmdb_id, include_video_language=None, page=1):
203 calls.append((self.language, include_video_language))
204 if len(calls) == 1:
205 return []
206 return [
207 {
208 "site": "YouTube",
209 "type": "Trailer",
210 "official": True,
211 "key": "fallbacktv01",
212 }
213 ]
214
215 monkeypatch.setattr("lib.clients.tmdb.trailers.TV", FakeTV)
216 monkeypatch.setenv("TMDB_LANGUAGE", "es-ES")
217
218 assert resolve_tmdb_trailer(4604, "tv") == {
219 "yt_id": "fallbacktv01",
220 "youtube_url": "https://www.youtube.com/watch?v=fallbacktv01",
221 }
222 assert calls == [
223 ("es-ES", "es-ES,null"),
224 ("en-US", "en-US,null"),
225 ]
226
227
228def test_resolve_tmdb_trailer_retries_movie_lookup_with_language_fallback(monkeypatch):
229 calls = []
230
231 class FakeMovie:
232 def __init__(self):
233 self.language = None
234
235 def videos(self, tmdb_id, page=1):
236 calls.append(self.language)
237 if len(calls) == 1:
238 return []
239 return [
240 {
241 "site": "YouTube",
242 "type": "Clip",
243 "official": False,
244 "key": "fallbackmv1",
245 }
246 ]
247
248 monkeypatch.setattr("lib.clients.tmdb.trailers.Movie", FakeMovie)
249 monkeypatch.setenv("TMDB_LANGUAGE", "fr-FR")
250
251 assert resolve_tmdb_trailer(550, "movie") == {
252 "yt_id": "fallbackmv1",
253 "youtube_url": "https://www.youtube.com/watch?v=fallbackmv1",
254 }
255 assert calls == ["fr-FR", "en-US"]
plugin.video.jacktook/tests/unit/test_tmdb_trakt_integration.py +47 -0
……@@ -0,0 +1,47 @@
1from unittest.mock import MagicMock, patch
2
3from lib.clients.tmdb.base import BaseTmdbClient
4
5
6def test_movie_item_adds_trakt_context_actions_when_authenticated():
7 list_item = MagicMock()
8 ids = {"tmdb_id": 10, "imdb_id": "tt0010", "tvdb_id": ""}
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
12 ), patch(
13 "lib.clients.tmdb.base.add_trakt_watchlist_context_menu", return_value=[("watchlist", "cmd")]
14 ), patch(
15 "lib.clients.tmdb.base.add_trakt_watched_context_menu", return_value=[("watched", "cmd")]
16 ), patch(
17 "lib.clients.tmdb.base.add_trakt_collection_context_menu", return_value=[("collection", "cmd")]
18 ), 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")]
22 ), patch("lib.clients.tmdb.base.add_kodi_dir_item"):
23 BaseTmdbClient.add_media_directory_item(list_item, "movies", "Demo Movie", ids)
24
25 list_item.addContextMenuItems.assert_called_once_with(
26 [
27 ("tmdb", "cmd"),
28 ("watchlist", "cmd"),
29 ("watched", "cmd"),
30 ("collection", "cmd"),
31 ("favorites", "cmd"),
32 ("customlist", "cmd"),
33 ]
34 )
35 list_item.setProperty.assert_called_once_with("IsPlayable", "true")
36
37
38def test_tv_item_skips_trakt_context_actions_when_not_authenticated():
39 list_item = MagicMock()
40 ids = {"tmdb_id": 20, "imdb_id": "tt0020", "tvdb_id": 200}
41
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"):
45 BaseTmdbClient.add_media_directory_item(list_item, "tv", "Demo Show", ids)
46
47 list_item.addContextMenuItems.assert_called_once_with([("tmdb-tv", "cmd")])
plugin.video.jacktook/tests/unit/test_trailer_context_menu.py +212 -0
……@@ -0,0 +1,212 @@
1from urllib.parse import parse_qs, urlparse
2from unittest.mock import MagicMock, patch
3
4from lib.clients.trakt.trakt import BaseTraktClient
5from lib.clients.tmdb.base import BaseTmdbClient
6from lib.clients.tmdb.utils.utils import (
7 add_tmdb_movie_context_menu,
8 add_tmdb_show_context_menu,
9)
10
11
12TRAILER_LABEL = "Play Trailer"
13
14
15def _get_trailer_menu_item(menu):
16 for label, command in menu:
17 if label == TRAILER_LABEL:
18 return label, command
19 return None
20
21
22def _parse_playmedia_command(command):
23 url = command[len("PlayMedia(") : -1]
24 query = parse_qs(urlparse(url).query)
25 return {
26 "action": query["action"][0],
27 "media_type": query["media_type"][0],
28 "tmdb_id": query.get("tmdb_id", [None])[0],
29 "yt_id": query.get("yt_id", [None])[0],
30 "youtube_url": query.get("youtube_url", [None])[0],
31 "title": query.get("title", [None])[0],
32 }
33
34
35def _get_menu_item(menu, label):
36 for item_label, command in menu:
37 if item_label == label:
38 return item_label, command
39 return None
40
41
42def _parse_runplugin_command(command):
43 url = command[len("RunPlugin(") : -1]
44 query = parse_qs(urlparse(url).query)
45 return {key: values[0] for key, values in query.items()}
46
47
48def 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}"):
50 menu = add_tmdb_movie_context_menu(
51 mode="movies",
52 media_type="movie",
53 title="Demo Movie",
54 ids={"tmdb_id": 550},
55 )
56
57 trailer_item = _get_trailer_menu_item(menu)
58
59 assert trailer_item is not None
60 assert _parse_playmedia_command(trailer_item[1]) == {
61 "action": "play_trailer",
62 "media_type": "movie",
63 "tmdb_id": "550",
64 "yt_id": None,
65 "youtube_url": None,
66 "title": "Demo Movie",
67 }
68
69
70def 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}"):
72 menu = add_tmdb_movie_context_menu(
73 mode="movies",
74 media_type="movie",
75 title="Demo Movie",
76 ids={"imdb_id": "tt00550"},
77 )
78
79 assert _get_trailer_menu_item(menu) is None
80
81
82def test_movie_context_menu_contains_search_modes_entry():
83 search_modes_label = "Search modes"
84
85 with patch(
86 "lib.clients.tmdb.utils.utils.translation",
87 side_effect=lambda value: search_modes_label if value == 90733 else f"t-{value}",
88 ):
89 menu = add_tmdb_movie_context_menu(
90 mode="movies",
91 media_type="movie",
92 title="Demo Movie",
93 ids={"tmdb_id": 550},
94 )
95
96 item = _get_menu_item(menu, search_modes_label)
97
98 assert item is not None
99 assert _parse_runplugin_command(item[1]) == {
100 "action": "tmdb_search_modes",
101 "mode": "movies",
102 "media_type": "movie",
103 "query": "Demo Movie",
104 "tmdb_id": "550",
105 }
106
107
108def 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}"):
110 menu = add_tmdb_show_context_menu(
111 mode="tv",
112 title="Demo Show",
113 ids={"tmdb_id": 1399},
114 )
115
116 trailer_item = _get_trailer_menu_item(menu)
117
118 assert trailer_item is not None
119 assert _parse_playmedia_command(trailer_item[1]) == {
120 "action": "play_trailer",
121 "media_type": "tv",
122 "tmdb_id": "1399",
123 "yt_id": None,
124 "youtube_url": None,
125 "title": "Demo Show",
126 }
127
128
129def test_base_tmdb_client_passes_show_title_to_context_menu_builder():
130 list_item = MagicMock()
131
132 with patch(
133 "lib.clients.tmdb.base.add_tmdb_show_context_menu",
134 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"):
138 BaseTmdbClient.add_media_directory_item(
139 list_item=list_item,
140 mode="tv",
141 title="Demo Show",
142 ids={"tmdb_id": 1399},
143 )
144
145 add_context_menu.assert_called_once_with(
146 "tv", ids={"tmdb_id": 1399}, title="Demo Show"
147 )
148
149
150def test_trakt_movie_context_menu_contains_play_trailer_when_tmdb_id_exists():
151 list_item = MagicMock()
152
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(
156 "lib.clients.tmdb.utils.utils.translation",
157 side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}",
158 ):
159 BaseTraktClient._add_media_directory_item(
160 list_item=list_item,
161 mode="movies",
162 title="Demo Movie",
163 ids={"tmdb_id": 550},
164 media_type="movie",
165 )
166
167 context_menu = list_item.addContextMenuItems.call_args[0][0]
168 trailer_item = _get_trailer_menu_item(context_menu)
169
170 assert trailer_item is not None
171 assert _parse_playmedia_command(trailer_item[1]) == {
172 "action": "play_trailer",
173 "media_type": "movie",
174 "tmdb_id": "550",
175 "yt_id": None,
176 "youtube_url": None,
177 "title": "Demo Movie",
178 }
179
180
181def test_trakt_show_context_menu_contains_play_trailer_when_tmdb_id_exists():
182 list_item = MagicMock()
183
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")],
189 ), patch("lib.clients.trakt.trakt.add_kodi_dir_item"), patch(
190 "lib.clients.tmdb.utils.utils.translation",
191 side_effect=lambda value: TRAILER_LABEL if value == 90672 else f"t-{value}",
192 ):
193 BaseTraktClient._add_media_directory_item(
194 list_item=list_item,
195 mode="tv",
196 title="Demo Show",
197 ids={"tmdb_id": 1399},
198 media_type="tv",
199 )
200
201 context_menu = list_item.addContextMenuItems.call_args[0][0]
202 trailer_item = _get_trailer_menu_item(context_menu)
203
204 assert trailer_item is not None
205 assert _parse_playmedia_command(trailer_item[1]) == {
206 "action": "play_trailer",
207 "media_type": "tv",
208 "tmdb_id": "1399",
209 "yt_id": None,
210 "youtube_url": None,
211 "title": "Demo Show",
212 }
plugin.video.jacktook/tests/unit/test_trailer_navigation.py +310 -0
……@@ -0,0 +1,310 @@
1from unittest.mock import MagicMock, patch
2
3
4def test_play_trailer_resolves_and_hands_off_to_kodi():
5 params = {
6 "yt_id": "dQw4w9WgXcQ",
7 "youtube_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
8 "tmdb_id": "550",
9 "media_type": "movie",
10 "title": "Demo Movie",
11 }
12 list_item = MagicMock()
13
14 with patch(
15 "lib.navigation.resolve_item_trailer",
16 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"},
19 },
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:
23 from lib.navigation import play_trailer
24
25 play_trailer(params)
26
27 resolve_item_trailer.assert_called_once_with(
28 yt_id="dQw4w9WgXcQ",
29 youtube_url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
30 tmdb_id="550",
31 media_type="movie",
32 )
33 list_item_cls.assert_called_once_with(
34 label="Demo Movie",
35 path="https://video.example/trailer.m3u8",
36 )
37 list_item.setPath.assert_called_once_with("https://video.example/trailer.m3u8")
38 list_item.setProperty.assert_called_once_with("IsPlayable", "true")
39 set_resolved_url.assert_called_once_with(1, True, list_item)
40 notification.assert_not_called()
41
42
43def test_play_trailer_handles_adaptive_source_without_falling_back_to_youtube_addon():
44 list_item = MagicMock()
45
46 with patch(
47 "lib.navigation.resolve_item_trailer",
48 return_value={
49 "trailer": {"yt_id": "dQw4w9WgXcQ"},
50 "playback": {
51 "video_url": "https://video.example/video-1080.mp4",
52 "audio_url": "https://video.example/audio-160.m4a",
53 "source_type": "adaptive",
54 },
55 },
56 ), 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:
59 from lib.navigation import play_trailer
60
61 play_trailer({"tmdb_id": "550", "media_type": "movie", "title": "Demo Movie"})
62
63 list_item_cls.assert_called_once_with(
64 label="Demo Movie",
65 path="https://video.example/video-1080.mp4",
66 )
67 list_item.setPath.assert_called_once_with("https://video.example/video-1080.mp4")
68 assert list_item.setProperty.call_args_list == [
69 (("IsPlayable", "true"),),
70 (("inputstream.adaptive.manifest_type", "mpd"),),
71 ]
72 set_resolved_url.assert_called_once_with(1, True, list_item)
73 execute_builtin.assert_not_called()
74
75
76def test_play_trailer_notifies_when_no_resolved_video_url():
77 with patch(
78 "lib.navigation.resolve_item_trailer",
79 return_value=None,
80 ) as resolve_item_trailer, patch(
81 "lib.navigation.translation",
82 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:
86 from lib.navigation import play_trailer
87
88 play_trailer({"tmdb_id": "1399", "media_type": "tv", "title": "Demo Show"})
89
90 resolve_item_trailer.assert_called_once_with(
91 yt_id=None,
92 youtube_url=None,
93 tmdb_id="1399",
94 media_type="tv",
95 )
96 notification.assert_called_once_with("Trailer unavailable")
97 set_resolved_url.assert_not_called()
98
99
100def test_play_trailer_uses_mode_when_media_type_missing():
101 with patch(
102 "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:
105 from lib.navigation import play_trailer
106
107 play_trailer({"tmdb_id": "1399", "mode": "tv", "title": "Demo Show"})
108
109 resolve_item_trailer.assert_called_once_with(
110 yt_id=None,
111 youtube_url=None,
112 tmdb_id="1399",
113 media_type="tv",
114 )
115 notification.assert_called_once_with("Trailer unavailable")
116
117
118def test_play_trailer_logs_when_resolution_fails():
119 with patch(
120 "lib.navigation.resolve_item_trailer",
121 return_value=None,
122 ), patch("lib.navigation.kodilog") as log_mock, patch(
123 "lib.navigation.translation",
124 side_effect=lambda value: "Trailer unavailable" if value == 90673 else f"t-{value}",
125 ), patch(
126 "lib.navigation.notification"
127 ):
128 from lib.navigation import play_trailer
129
130 play_trailer({"tmdb_id": "1399", "media_type": "tv", "title": "Demo Show"})
131
132 log_mock.assert_called()
133
134
135def test_play_trailer_falls_back_to_youtube_addon_when_direct_playback_unavailable():
136 with patch(
137 "lib.navigation.resolve_item_trailer",
138 return_value={"trailer": {"yt_id": "dQw4w9WgXcQ"}, "playback": None},
139 ) as resolve_item_trailer, patch(
140 "lib.navigation.is_youtube_addon_enabled",
141 return_value=True,
142 ) as is_youtube_addon_enabled, patch(
143 "lib.navigation.execute_builtin"
144 ) as execute_builtin, patch("lib.navigation.notification") as notification, patch(
145 "lib.navigation.setResolvedUrl"
146 ) as set_resolved_url:
147 from lib.navigation import play_trailer
148
149 play_trailer(
150 {
151 "tmdb_id": "550",
152 "media_type": "movie",
153 "title": "Demo Movie",
154 "yt_id": "dQw4w9WgXcQ",
155 }
156 )
157
158 resolve_item_trailer.assert_called_once_with(
159 yt_id="dQw4w9WgXcQ",
160 youtube_url=None,
161 tmdb_id="550",
162 media_type="movie",
163 )
164 is_youtube_addon_enabled.assert_called_once_with()
165 execute_builtin.assert_called_once_with(
166 "PlayMedia(plugin://plugin.video.youtube/play/?video_id=dQw4w9WgXcQ)"
167 )
168 notification.assert_not_called()
169 set_resolved_url.assert_not_called()
170
171
172def test_play_trailer_notifies_when_direct_playback_unavailable_and_no_youtube_id():
173 with patch(
174 "lib.navigation.resolve_item_trailer",
175 return_value=None,
176 ) as resolve_item_trailer, patch(
177 "lib.navigation.is_youtube_addon_enabled",
178 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:
182 from lib.navigation import play_trailer
183
184 play_trailer(
185 {
186 "tmdb_id": "550",
187 "media_type": "movie",
188 "title": "Demo Movie",
189 }
190 )
191
192 resolve_item_trailer.assert_called_once_with(
193 yt_id=None,
194 youtube_url=None,
195 tmdb_id="550",
196 media_type="movie",
197 )
198 is_youtube_addon_enabled.assert_not_called()
199 execute_builtin.assert_not_called()
200 notification.assert_called_once_with("Trailer unavailable")
201
202
203def test_play_trailer_falls_back_to_tmdb_derived_youtube_addon_playback():
204 with patch(
205 "lib.navigation.resolve_item_trailer",
206 return_value={
207 "trailer": {
208 "yt_id": "tmdb1234567",
209 "youtube_url": "https://www.youtube.com/watch?v=tmdb1234567",
210 },
211 "playback": None,
212 },
213 ) as resolve_item_trailer, patch(
214 "lib.navigation.is_youtube_addon_enabled",
215 return_value=True,
216 ) as is_youtube_addon_enabled, patch(
217 "lib.navigation.execute_builtin"
218 ) as execute_builtin, patch("lib.navigation.notification") as notification:
219 from lib.navigation import play_trailer
220
221 play_trailer(
222 {
223 "tmdb_id": "550",
224 "media_type": "movie",
225 "title": "Demo Movie",
226 }
227 )
228
229 resolve_item_trailer.assert_called_once_with(
230 yt_id=None,
231 youtube_url=None,
232 tmdb_id="550",
233 media_type="movie",
234 )
235 is_youtube_addon_enabled.assert_called_once_with()
236 execute_builtin.assert_called_once_with(
237 "PlayMedia(plugin://plugin.video.youtube/play/?video_id=tmdb1234567)"
238 )
239 notification.assert_not_called()
240
241
242def test_play_trailer_notifies_when_youtube_addon_is_missing_for_fallback():
243 with patch(
244 "lib.navigation.resolve_item_trailer",
245 return_value={
246 "trailer": {"youtube_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
247 "playback": None,
248 },
249 ) as resolve_item_trailer, patch(
250 "lib.navigation.is_youtube_addon_enabled",
251 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:
255 from lib.navigation import play_trailer
256
257 play_trailer(
258 {
259 "tmdb_id": "550",
260 "media_type": "movie",
261 "title": "Demo Movie",
262 "youtube_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
263 }
264 )
265
266 resolve_item_trailer.assert_called_once_with(
267 yt_id=None,
268 youtube_url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
269 tmdb_id="550",
270 media_type="movie",
271 )
272 is_youtube_addon_enabled.assert_called_once_with()
273 execute_builtin.assert_not_called()
274 notification.assert_called_once_with("Trailer unavailable")
275
276
277def test_play_trailer_logs_unavailable_youtube_addon_fallback_details():
278 with patch(
279 "lib.navigation.resolve_item_trailer",
280 return_value={
281 "trailer": {
282 "yt_id": "tmdb1234567",
283 "youtube_url": "https://www.youtube.com/watch?v=tmdb1234567",
284 },
285 "playback": None,
286 },
287 ), patch(
288 "lib.navigation.is_youtube_addon_enabled",
289 return_value=False,
290 ), patch(
291 "lib.navigation.translation",
292 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:
296 from lib.navigation import play_trailer
297
298 play_trailer(
299 {
300 "tmdb_id": "550",
301 "media_type": "movie",
302 "title": "Demo Movie",
303 }
304 )
305
306 logged_messages = [call.args[0] for call in log_mock.call_args_list]
307 assert (
308 "Trailer: youtube addon unavailable title='Demo Movie' media_type='movie' tmdb_id='550' fallback_yt_id='tmdb1234567'"
309 in logged_messages
310 )
plugin.video.jacktook/tests/unit/test_trakt_calendar.py +42 -0
……@@ -0,0 +1,42 @@
1from unittest.mock import MagicMock, patch
2
3from lib.clients.trakt.trakt import TraktPresentation
4
5
6def test_show_calendar_items_resolves_tmdb_id_from_tvdb_and_formats_title():
7 fake_item = MagicMock()
8 fake_info_tag = MagicMock()
9 fake_item.getVideoInfoTag.return_value = fake_info_tag
10
11 with patch("lib.clients.trakt.trakt.make_list_item", return_value=fake_item), patch(
12 "lib.clients.trakt.trakt.TraktPresentation._resolve_media_ids",
13 return_value={"tmdb_id": "123", "tvdb_id": "456", "imdb_id": "tt789"},
14 ), patch("lib.clients.trakt.trakt.tmdb_get", return_value={}), patch(
15 "lib.clients.trakt.trakt.set_media_infoTag"
16 ), patch("lib.clients.trakt.trakt.add_kodi_dir_item") as add_dir_item:
17 TraktPresentation.show_calendar_items(
18 {
19 "show": {"title": "Demo Show", "ids": {"tvdb": 456}},
20 "episode": {"title": "Pilot", "season": 1, "number": 2},
21 "first_aired": "2026-03-13T20:00:00.000Z",
22 }
23 )
24
25 assert fake_item.setLabel.call_args.args[0] == "2026-03-13 | Demo Show - S01E02 - Pilot"
26 assert "show_seasons_details" not in add_dir_item.call_args.kwargs["url"]
27 assert add_dir_item.call_args.kwargs["is_folder"] is True
28
29
30def test_show_calendar_items_skips_incomplete_entries():
31 with patch("lib.clients.trakt.trakt.make_list_item") as list_item, patch(
32 "lib.clients.trakt.trakt.add_kodi_dir_item"
33 ) as add_dir_item:
34 TraktPresentation.show_calendar_items(
35 {
36 "show": {"title": "Demo Show", "ids": {"tmdb": 123}},
37 "episode": {"title": "Pilot", "season": None, "number": 2},
38 }
39 )
40
41 list_item.assert_not_called()
42 add_dir_item.assert_not_called()
plugin.video.jacktook/tests/unit/test_trakt_group_menu.py +29 -0
……@@ -0,0 +1,29 @@
1from unittest.mock import patch
2
3from lib import navigation
4
5
6def test_trakt_group_menu_renders_tv_library_group():
7 with patch("lib.navigation.render_menu") as render_menu, patch(
8 "lib.navigation.set_pluging_category"
9 ) as set_category, patch("lib.navigation.translation", return_value="Trakt Library"):
10 navigation.trakt_group_menu({"mode": "tv", "group": "library"})
11
12 set_category.assert_called_once_with("Trakt Library")
13 items = render_menu.call_args.args[0]
14 assert items
15 assert all(item["action"] == "search_item" for item in items)
16 assert render_menu.call_args.kwargs["cache"] is False
17
18
19def test_trakt_group_menu_renders_movie_discovery_group():
20 with patch("lib.navigation.render_menu") as render_menu, patch(
21 "lib.navigation.set_pluging_category"
22 ) as set_category, patch("lib.navigation.translation", return_value="Trakt Discovery"):
23 navigation.trakt_group_menu({"mode": "movies", "group": "discovery"})
24
25 set_category.assert_called_once_with("Trakt Discovery")
26 items = render_menu.call_args.args[0]
27 assert items
28 assert all(item["params"]["api"] == "trakt" for item in items)
29 assert render_menu.call_args.kwargs["cache"] is False
plugin.video.jacktook/tests/unit/test_trakt_phase4.py +167 -0
……@@ -0,0 +1,167 @@
1import datetime
2from types import SimpleNamespace
3
4from lib.api.trakt.trakt import TraktTV
5from lib.clients.trakt.trakt import TraktPresentation
6
7
8def test_build_up_next_entries_prefers_resume_progress_items():
9 watched_shows = [
10 {
11 "show": {"title": "Show One", "ids": {"tmdb": 1}},
12 "seasons": [{"number": 1, "episodes": [{"number": 1}]}],
13 }
14 ]
15 progress_items = [
16 {
17 "show": {"title": "Show One", "ids": {"tmdb": 1}},
18 "episode": {"season": 1, "number": 1, "title": "Pilot"},
19 "progress": 42,
20 "paused_at": "2026-03-13T18:00:00.000Z",
21 }
22 ]
23
24 entries = TraktTV._build_up_next_entries(
25 watched_shows,
26 progress_items,
27 fetcher=lambda *_args, **_kwargs: None,
28 now_dt=datetime.datetime(2026, 3, 13),
29 )
30
31 assert len(entries) == 1
32 assert entries[0]["type"] == "resume"
33 assert entries[0]["episode"]["number"] == 1
34 assert entries[0]["progress"] == 42
35
36
37def test_build_up_next_entries_skips_specials_and_unaired_progress():
38 progress_items = [
39 {
40 "show": {"title": "Special Show", "ids": {"tmdb": 1}},
41 "episode": {
42 "season": 0,
43 "number": 1,
44 "title": "Special",
45 "first_aired": "2026-03-01",
46 },
47 "progress": 10,
48 "paused_at": "2026-03-13T18:00:00.000Z",
49 },
50 {
51 "show": {"title": "Future Show", "ids": {"tmdb": 2}},
52 "episode": {
53 "season": 1,
54 "number": 1,
55 "title": "Future Episode",
56 "first_aired": "2999-03-01",
57 },
58 "progress": 20,
59 "paused_at": "2026-03-13T18:00:00.000Z",
60 },
61 ]
62
63 entries = TraktTV._build_up_next_entries(
64 [],
65 progress_items,
66 fetcher=lambda *_args, **_kwargs: None,
67 now_dt=datetime.datetime(2026, 3, 13),
68 )
69
70 assert entries == []
71
72
73def test_build_up_next_entries_orders_resumes_by_activity_and_progress():
74 progress_items = [
75 {
76 "show": {"title": "Show One", "ids": {"tmdb": 1}},
77 "episode": {"season": 1, "number": 2, "title": "Ep2"},
78 "progress": 20,
79 "paused_at": "2026-03-13T10:00:00.000Z",
80 },
81 {
82 "show": {"title": "Show Two", "ids": {"tmdb": 2}},
83 "episode": {"season": 1, "number": 3, "title": "Ep3"},
84 "progress": 85,
85 "paused_at": "2026-03-13T11:00:00.000Z",
86 },
87 ]
88
89 entries = TraktTV._build_up_next_entries(
90 [],
91 progress_items,
92 fetcher=lambda *_args, **_kwargs: None,
93 now_dt=datetime.datetime(2026, 3, 13),
94 )
95
96 assert [item["show"]["title"] for item in entries] == ["Show Two", "Show One"]
97
98
99def test_build_up_next_entries_finds_next_aired_episode():
100 watched_shows = [
101 {
102 "show": {"title": "Show Two", "ids": {"tmdb": 22}},
103 "seasons": [
104 {
105 "number": 1,
106 "episodes": [
107 {"number": 1, "last_watched_at": "2026-03-10T18:00:00.000Z"}
108 ],
109 }
110 ],
111 }
112 ]
113
114 def fetcher(name, payload):
115 if name == "tv_details":
116 return SimpleNamespace(number_of_seasons=1)
117 return SimpleNamespace(
118 episodes=[
119 SimpleNamespace(episode_number=1, name="Ep1", air_date="2026-03-01"),
120 SimpleNamespace(episode_number=2, name="Ep2", air_date="2026-03-05"),
121 SimpleNamespace(episode_number=3, name="Ep3", air_date="2026-03-20"),
122 ]
123 )
124
125 entries = TraktTV._build_up_next_entries(
126 watched_shows,
127 [],
128 fetcher=fetcher,
129 now_dt=datetime.datetime(2026, 3, 13),
130 )
131
132 assert len(entries) == 1
133 assert entries[0]["type"] == "next"
134 assert entries[0]["episode"]["number"] == 2
135 assert entries[0]["episode"]["title"] == "Ep2"
136
137
138def test_format_account_info_includes_profile_and_stats():
139 content = TraktPresentation._format_account_info(
140 {
141 "settings": {
142 "user": {
143 "username": "demo_user",
144 "name": "Demo",
145 "joined_at": "2024-01-01T00:00:00.000Z",
146 "vip": True,
147 },
148 "account": {
149 "private": False,
150 "timezone": "Europe/Madrid",
151 "locale": "es",
152 },
153 "connections": {"google": True},
154 },
155 "stats": {
156 "movies": {"watched": 12, "collected": 4},
157 "shows": {"watched": 3, "collected": 1},
158 "episodes": {"watched": 40},
159 "lists": {"count": 2},
160 },
161 }
162 )
163
164 assert "demo_user" in content
165 assert "Movies Watched: 12" in content
166 assert "Episodes Watched: 40" in content
167 assert "Timezone: Europe/Madrid" in content
plugin.video.jacktook/tests/unit/test_trakt_phase7.py +117 -0
……@@ -0,0 +1,117 @@
1from unittest.mock import MagicMock, patch
2
3from lib.api.trakt.trakt import TraktLists
4from lib.clients.trakt.trakt import TraktPresentation
5
6
7def test_create_list_calls_trakt_and_clears_my_lists_cache():
8 trakt_lists = TraktLists()
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(
13 "lib.api.trakt.trakt.clear_trakt_list_contents_data"
14 ) as clear_list_contents:
15 trakt_lists.create_list("My List", "Description")
16
17 call_trakt.assert_called_once_with(
18 "users/me/lists",
19 data={
20 "name": "My List",
21 "description": "Description",
22 "privacy": "private",
23 "display_numbers": True,
24 "allow_comments": True,
25 },
26 with_auth=True,
27 pagination=False,
28 )
29 clear_list_data.assert_called_once_with("my_lists")
30 clear_list_contents.assert_called_once_with("my_lists")
31
32
33def test_like_list_calls_expected_endpoint():
34 trakt_lists = TraktLists()
35
36 with patch.object(trakt_lists, "call_trakt", return_value={}) as call_trakt, patch(
37 "lib.api.trakt.trakt.clear_trakt_list_data"
38 ):
39 trakt_lists.like_list("tester", 77)
40
41 call_trakt.assert_called_once_with(
42 "users/tester/lists/77/like",
43 data={},
44 with_auth=True,
45 pagination=False,
46 )
47
48
49def test_show_user_lists_adds_delete_context_for_my_lists():
50 fake_item = MagicMock()
51 fake_info_tag = MagicMock()
52 fake_item.getVideoInfoTag.return_value = fake_info_tag
53
54 with patch("lib.clients.trakt.trakt.make_list_item", return_value=fake_item), patch(
55 "lib.clients.trakt.trakt.add_kodi_dir_item"
56 ):
57 TraktPresentation.show_user_lists(
58 {
59 "name": "Owned List",
60 "description": "Demo",
61 "slug": "owned-list",
62 "trakt_id": 55,
63 "user_slug": "me",
64 "can_delete": True,
65 "list_type": "my_lists",
66 },
67 "tv",
68 )
69
70 fake_item.addContextMenuItems.assert_called_once()
71 labels = [entry[0] for entry in fake_item.addContextMenuItems.call_args[0][0]]
72 assert "Delete Trakt List" in labels
73
74
75def test_show_create_list_entry_builds_plugin_action():
76 fake_item = MagicMock()
77
78 with patch("lib.clients.trakt.trakt.make_list_item", return_value=fake_item), patch(
79 "lib.clients.trakt.trakt.add_kodi_dir_item"
80 ) as add_dir_item:
81 TraktPresentation.show_create_list_entry("movies")
82
83 assert add_dir_item.call_args.kwargs["is_folder"] is False
84 assert "trakt_create_list" in add_dir_item.call_args.kwargs["url"]
85
86
87def test_add_item_to_list_posts_expected_payload():
88 trakt_lists = TraktLists()
89
90 with patch.object(trakt_lists, "call_trakt", return_value={}) as call_trakt, patch(
91 "lib.api.trakt.trakt.clear_trakt_list_contents_data"
92 ) as clear_contents:
93 trakt_lists.add_item_to_list(10, "movies", {"tmdb": 44, "imdb": "tt44"})
94
95 call_trakt.assert_called_once_with(
96 "users/me/lists/10/items",
97 data={"movies": [{"ids": {"tmdb": 44, "imdb": "tt44"}}]},
98 with_auth=True,
99 pagination=False,
100 )
101 clear_contents.assert_called_once_with("my_lists")
102
103
104def test_remove_item_from_list_posts_expected_payload():
105 trakt_lists = TraktLists()
106
107 with patch.object(trakt_lists, "call_trakt", return_value={}) as call_trakt, patch(
108 "lib.api.trakt.trakt.clear_trakt_list_contents_data"
109 ):
110 trakt_lists.remove_item_from_list(12, "shows", {"tmdb": 55, "tvdb": 66})
111
112 call_trakt.assert_called_once_with(
113 "users/me/lists/12/items/remove",
114 data={"shows": [{"ids": {"tmdb": 55, "tvdb": 66}}]},
115 with_auth=True,
116 pagination=False,
117 )
plugin.video.jacktook/tests/unit/test_trakt_phase8.py +295 -0
……@@ -0,0 +1,295 @@
1from unittest.mock import MagicMock, patch
2
3from lib.api.trakt.trakt import TraktLists
4from lib.api.trakt.trakt_utils import (
5 add_trakt_favorites_context_menu,
6 add_trakt_watchlist_context_menu,
7)
8from lib.clients.trakt.trakt import TraktPresentation
9
10
11def test_add_to_favorites_posts_expected_payload():
12 trakt_lists = TraktLists()
13
14 with patch.object(trakt_lists, "call_trakt", return_value={}) as call_trakt, patch(
15 "lib.api.trakt.trakt.clear_trakt_favorites"
16 ) as clear_favorites:
17 trakt_lists.add_to_favorites("movies", {"tmdb": 11, "imdb": "tt11"})
18
19 call_trakt.assert_called_once_with(
20 "sync/favorites",
21 data={"movies": [{"ids": {"tmdb": 11, "imdb": "tt11"}}]},
22 with_auth=True,
23 pagination=False,
24 )
25 clear_favorites.assert_called_once_with()
26
27
28def test_remove_from_favorites_posts_expected_payload():
29 trakt_lists = TraktLists()
30
31 with patch.object(trakt_lists, "call_trakt", return_value={}) as call_trakt, patch(
32 "lib.api.trakt.trakt.clear_trakt_favorites"
33 ):
34 trakt_lists.remove_from_favorites("shows", {"tmdb": 22, "tvdb": 33})
35
36 call_trakt.assert_called_once_with(
37 "sync/favorites/remove",
38 data={"shows": [{"ids": {"tmdb": 22, "tvdb": 33}}]},
39 with_auth=True,
40 pagination=False,
41 )
42
43
44def test_watchlist_context_menu_can_hide_add_action():
45 menu = add_trakt_watchlist_context_menu(
46 "movies", {"tmdb_id": 1}, include_add=False, include_remove=True
47 )
48
49 labels = [label for label, _cmd in menu]
50 assert labels == ["Remove from Trakt Watchlist"]
51
52
53def test_favorites_context_menu_can_hide_remove_action():
54 menu = add_trakt_favorites_context_menu(
55 "shows", {"tmdb_id": 1}, include_add=True, include_remove=False
56 )
57
58 labels = [label for label, _cmd in menu]
59 assert labels == ["Add to Trakt Favorites"]
60
61
62def test_show_favorites_uses_state_aware_context_flags():
63 fake_item = MagicMock()
64
65 with patch("lib.clients.trakt.trakt.make_list_item", return_value=fake_item), patch(
66 "lib.clients.trakt.trakt.tmdb_get", return_value={}
67 ), patch("lib.clients.trakt.trakt.set_media_infoTag"), patch(
68 "lib.clients.trakt.trakt.BaseTraktClient._add_media_directory_item"
69 ) as add_item:
70 TraktPresentation.show_favorites(
71 {"title": "Demo", "media_ids": {"tmdb": 1, "imdb": "tt1"}},
72 "movies",
73 )
74
75 from lib.clients.trakt.trakt import BaseTraktClient
76
77 assert add_item.call_args.kwargs["context_flags"] == BaseTraktClient._exclusive_context_flags(
78 "favorites"
79 )
80
81
82def test_exclusive_context_flags_only_enable_requested_remove_action():
83 from lib.clients.trakt.trakt import BaseTraktClient
84
85 flags = BaseTraktClient._exclusive_context_flags("watchlist")
86
87 assert flags["watchlist_remove"] is True
88 assert flags["watchlist_add"] is False
89 assert flags["favorites_remove"] is False
90 assert flags["collection_remove"] is False
91 assert flags["custom_list_remove"] is False
92
93
94def test_add_to_collection_clears_collection_caches_for_movies():
95 trakt_lists = TraktLists()
96
97 with patch.object(trakt_lists, "call_trakt", return_value={}) as call_trakt, patch(
98 "lib.api.trakt.trakt.lists_cache.delete_prefix"
99 ) as delete_prefix, patch(
100 "lib.api.trakt.trakt.clear_trakt_collection_watchlist_data"
101 ) as clear_collection:
102 trakt_lists.add_to_collection("movies", {"tmdb": 44})
103
104 call_trakt.assert_called_once()
105 delete_prefix.assert_called_once_with("trakt_movies_collection_")
106 clear_collection.assert_called_once_with("collection", "movies")
107
108
109def test_remove_from_collection_uses_all_known_ids_for_shows():
110 trakt_lists = TraktLists()
111
112 with patch.object(trakt_lists, "call_trakt", return_value={}) as call_trakt, patch(
113 "lib.api.trakt.trakt.lists_cache.delete_prefix"
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 )
118
119 call_trakt.assert_called_once_with(
120 "sync/collection/remove",
121 data={"shows": [{"ids": {"tmdb": 44, "tvdb": 55, "imdb": "tt66"}}]},
122 with_auth=True,
123 pagination=False,
124 )
125
126
127def test_build_show_collection_payload_uses_show_level_ids():
128 with patch(
129 "lib.clients.trakt.trakt.TraktPresentation._resolve_media_ids",
130 return_value={"tmdb_id": "99", "tvdb_id": "77", "imdb_id": "tt99"},
131 ), patch("lib.clients.trakt.trakt.TraktAPI") as trakt_api, patch(
132 "lib.clients.trakt.trakt.tmdb_get",
133 return_value={"name": "Demo Show", "first_air_date": "2024-01-01"},
134 ):
135 trakt_api.return_value.lists.get_trakt_object_by_tmdb.return_value = {
136 "title": "Demo Show",
137 "year": 2024,
138 "ids": {"trakt": 123, "slug": "demo-show"},
139 }
140 from lib.clients.trakt.trakt import TraktClient
141
142 payload = TraktClient._build_show_collection_payload({"tmdb_id": "99"})
143
144 assert payload == {
145 "shows": [
146 {
147 "title": "Demo Show",
148 "year": 2024,
149 "ids": {
150 "tmdb": 99,
151 "tvdb": 77,
152 "imdb": "tt99",
153 "trakt": 123,
154 "slug": "demo-show",
155 },
156 }
157 ]
158 }
159
160
161def test_build_show_collection_episode_payload_includes_aired_episodes_only():
162 tv_details = {"number_of_seasons": 1}
163 season_details = {
164 "episodes": [
165 {"episode_number": 1, "air_date": "2024-01-01"},
166 {"episode_number": 2, "air_date": "2999-01-01"},
167 ]
168 }
169
170 with patch("lib.clients.trakt.trakt.tmdb_get", side_effect=[tv_details, season_details]), patch(
171 "lib.clients.trakt.trakt.TraktPresentation._resolve_media_ids",
172 return_value={"tmdb_id": "99", "tvdb_id": "77", "imdb_id": "tt99"},
173 ), patch("lib.clients.trakt.trakt.TraktAPI") as trakt_api:
174 trakt_api.return_value.lists.get_trakt_object_by_tmdb.return_value = {
175 "title": "Demo Show",
176 "year": 2024,
177 "ids": {"trakt": 123, "slug": "demo-show"},
178 }
179 from lib.clients.trakt.trakt import TraktClient
180
181 payload = TraktClient._build_show_collection_episode_payload({"tmdb_id": "99"})
182
183 assert payload == {
184 "shows": [
185 {
186 "title": "Demo Show",
187 "year": 2024,
188 "ids": {
189 "tmdb": 99,
190 "tvdb": 77,
191 "imdb": "tt99",
192 "trakt": 123,
193 "slug": "demo-show",
194 },
195 "seasons": [{"number": 1, "episodes": [{"number": 1}]}],
196 }
197 ]
198 }
199
200
201def test_build_show_collection_season_payload_uses_season_numbers_only():
202 episode_payload = {
203 "shows": [
204 {
205 "ids": {"tmdb": 99, "tvdb": 77},
206 "title": "Demo Show",
207 "year": 2024,
208 "seasons": [
209 {"number": 1, "episodes": [{"number": 1}]},
210 {"number": 2, "episodes": [{"number": 1}]},
211 ],
212 }
213 ]
214 }
215
216 with patch(
217 "lib.clients.trakt.trakt.TraktClient._build_show_collection_episode_payload",
218 return_value=episode_payload,
219 ):
220 from lib.clients.trakt.trakt import TraktClient
221
222 payload = TraktClient._build_show_collection_season_payload({"tmdb_id": "99"})
223
224 assert payload == {
225 "shows": [
226 {
227 "ids": {"tmdb": 99, "tvdb": 77},
228 "title": "Demo Show",
229 "year": 2024,
230 "seasons": [{"number": 1}, {"number": 2}],
231 }
232 ]
233 }
234
235
236def test_normalize_trakt_media_type_prefers_show_when_tvdb_present():
237 from lib.clients.trakt.trakt import TraktClient
238
239 result = TraktClient._normalize_trakt_media_type(
240 "movies", {"tmdb": 44, "tvdb": 55}
241 )
242
243 assert result == "shows"
244
245
246def test_normalize_trakt_media_type_uses_tmdb_details_for_tv_ids():
247 with patch("lib.clients.trakt.trakt.tmdb_get", side_effect=[{"name": "TV Show"}, None]):
248 from lib.clients.trakt.trakt import TraktClient
249
250 result = TraktClient._normalize_trakt_media_type("movies", {"tmdb": 999})
251
252 assert result == "shows"
253
254
255def test_build_collection_remove_payload_uses_existing_collection_entry():
256 trakt_lists = TraktLists()
257
258 with patch.object(
259 trakt_lists,
260 "get_collection_items",
261 return_value=[
262 {
263 "show": {
264 "title": "Demo Show",
265 "year": 2024,
266 "ids": {"tmdb": 99, "tvdb": 77, "imdb": "tt99", "trakt": 123},
267 },
268 "seasons": [{"number": 1, "episodes": [{"number": 1}, {"number": 2}]}],
269 }
270 ],
271 ):
272 payload = trakt_lists.build_collection_remove_payload(
273 "shows", {"tmdb": 99, "tvdb": 77}
274 )
275
276 assert payload == {
277 "shows": [
278 {
279 "title": "Demo Show",
280 "year": 2024,
281 "ids": {"tmdb": 99, "tvdb": 77, "imdb": "tt99", "trakt": 123},
282 "seasons": [{"number": 1, "episodes": [{"number": 1}, {"number": 2}]}],
283 }
284 ]
285 }
286
287
288def test_result_has_collection_change_for_show_add_when_episodes_added():
289 from lib.clients.trakt.trakt import TraktClient
290
291 result = TraktClient._result_has_collection_change(
292 {"added": {"movies": 0, "episodes": 3}}, "add"
293 )
294
295 assert result is True
plugin.video.jacktook/tests/unit/test_trakt_sync_service.py +94 -0
……@@ -0,0 +1,94 @@
1from copy import deepcopy
2from unittest.mock import MagicMock, patch
3
4from lib.api.trakt.trakt_cache import default_activities
5from lib.services.trakt_sync import TraktSyncService
6
7
8def test_get_changed_buckets_detects_expected_activity_groups():
9 service = TraktSyncService(api=MagicMock(), monitor=MagicMock())
10 previous = default_activities()
11 latest = deepcopy(previous)
12 latest["movies"]["watched_at"] = "2026-03-13T18:00:00.000Z"
13 latest["episodes"]["paused_at"] = "2026-03-13T18:01:00.000Z"
14 latest["lists"]["updated_at"] = "2026-03-13T18:02:00.000Z"
15
16 changes = service._get_changed_buckets(previous, latest)
17
18 assert "watched_movies" in changes
19 assert "progress_episodes" in changes
20 assert "lists" in changes
21
22
23def test_sync_activities_applies_changed_buckets():
24 api = MagicMock()
25 api.sync.get_last_activities.return_value = {"movies": {"watched_at": "new"}}
26 service = TraktSyncService(api=api, monitor=MagicMock())
27
28 with patch("lib.services.trakt_sync.reset_activity", return_value={"movies": {"watched_at": "old"}}), patch.object(
29 service, "_get_changed_buckets", return_value={"watched_movies", "lists"}
30 ) as changed_buckets, patch.object(service, "_apply_activity_changes") as apply_changes:
31 result = service.sync_activities()
32
33 changed_buckets.assert_called_once()
34 apply_changes.assert_called_once_with({"watched_movies", "lists"})
35 assert result == {"watched_movies", "lists"}
36
37
38def test_sync_activities_logs_changed_buckets():
39 api = MagicMock()
40 api.sync.get_last_activities.return_value = {"movies": {"watched_at": "new"}}
41 service = TraktSyncService(api=api, monitor=MagicMock())
42
43 with patch("lib.services.trakt_sync.reset_activity", return_value={"movies": {"watched_at": "old"}}), patch.object(
44 service, "_get_changed_buckets", return_value={"watched_movies", "lists"}
45 ), patch.object(service, "_apply_activity_changes"), patch(
46 "lib.services.trakt_sync.kodilog"
47 ) as kodilog:
48 service.sync_activities()
49
50 assert any(
51 "changed buckets = lists, watched_movies" in str(call.args[0])
52 for call in kodilog.call_args_list
53 )
54
55
56def test_invalidate_cached_buckets_targets_expected_caches():
57 service = TraktSyncService(api=MagicMock(), monitor=MagicMock())
58
59 with patch("lib.services.trakt_sync.lists_cache.delete_prefix") as delete_prefix, patch(
60 "lib.services.trakt_sync.clear_trakt_favorites"
61 ) as clear_favorites, patch(
62 "lib.services.trakt_sync.clear_trakt_list_data"
63 ) as clear_list_data, patch(
64 "lib.services.trakt_sync.clear_trakt_list_contents_data"
65 ) as clear_list_contents:
66 service.invalidate_cached_buckets(
67 {"collection_movies", "favorites_movies", "recommendations_shows", "lists"}
68 )
69
70 delete_prefix.assert_any_call("trakt_movies_collection_")
71 delete_prefix.assert_any_call("trakt_recommendations_shows")
72 clear_favorites.assert_called_once_with()
73 clear_list_data.assert_any_call("my_lists")
74 clear_list_data.assert_any_call("liked_lists")
75 clear_list_contents.assert_any_call("my_lists")
76 clear_list_contents.assert_any_call("liked_lists")
77
78
79def test_sync_watched_movies_replaces_cache_data():
80 api = MagicMock()
81 api.movies.get_watched_movies.return_value = [
82 {
83 "movie": {"ids": {"tmdb": 10}, "title": "Movie"},
84 "last_watched_at": "2026-03-13T18:03:00.000Z",
85 }
86 ]
87 service = TraktSyncService(api=api, monitor=MagicMock())
88
89 with patch("lib.services.trakt_sync.trakt_watched_cache.set_bulk_movie_watched") as set_bulk:
90 service.sync_watched_movies()
91
92 set_bulk.assert_called_once_with(
93 [("movie", "10", None, None, "2026-03-13T18:03:00.000Z", "Movie")]
94 )
plugin.video.jacktook/tests/unit/test_transfer_actions.py +199 -0
……@@ -0,0 +1,199 @@
1from unittest.mock import MagicMock, patch
2
3from lib.clients.debrid.torbox import TorboxHelper
4from lib.nav import debrid as debrid_navigation
5from lib.utils.general.utils import DebridType
6from lib.utils.debrid import debrid_utils
7from lib.utils.torrent import torrserver_utils
8
9
10def test_add_source_to_torrserver_uses_magnet_when_available():
11 api = MagicMock()
12 api.add_magnet.return_value = "abc123"
13
14 with patch.object(torrserver_utils, "JACKTORR_ADDON", True), patch.object(
15 torrserver_utils, "get_torrserver_api", return_value=api
16 ), patch.object(torrserver_utils, "notification") as notification:
17 result = torrserver_utils.add_source_to_torrserver(
18 magnet="magnet:?xt=urn:btih:abc123", title="Test"
19 )
20
21 assert result == "abc123"
22 api.add_magnet.assert_called_once_with(
23 "magnet:?xt=urn:btih:abc123", title="Test", poster=""
24 )
25 notification.assert_called_once()
26
27
28def test_add_source_to_torrserver_uploads_torrent_file_from_url():
29 api = MagicMock()
30 api.add_torrent_obj.return_value = "def456"
31 response = MagicMock()
32 response.content = b"torrent-bytes"
33 response.raise_for_status.return_value = None
34
35 with patch.object(torrserver_utils, "JACKTORR_ADDON", True), patch.object(
36 torrserver_utils, "get_torrserver_api", return_value=api
37 ), patch.object(torrserver_utils.requests, "get", return_value=response) as http_get:
38 result = torrserver_utils.add_source_to_torrserver(url="https://example.com/test.torrent")
39
40 assert result == "def456"
41 http_get.assert_called_once()
42 api.add_torrent_obj.assert_called_once()
43
44
45def test_add_source_to_torrserver_prefers_torrent_url_over_magnet():
46 api = MagicMock()
47 api.add_torrent_obj.return_value = "urlhash"
48 response = MagicMock()
49 response.content = b"torrent-bytes"
50 response.raise_for_status.return_value = None
51
52 with patch.object(torrserver_utils, "JACKTORR_ADDON", True), patch.object(
53 torrserver_utils, "get_torrserver_api", return_value=api
54 ), patch.object(torrserver_utils.requests, "get", return_value=response):
55 result = torrserver_utils.add_source_to_torrserver(
56 url="https://filelist.io/download.php?id=1",
57 magnet="magnet:?xt=urn:btih:abc123",
58 )
59
60 assert result == "urlhash"
61 api.add_torrent_obj.assert_called_once()
62 api.add_magnet.assert_not_called()
63
64
65def test_add_source_to_torrserver_falls_back_to_magnet_when_url_fails():
66 api = MagicMock()
67 api.add_magnet.return_value = "abc123"
68
69 with patch.object(torrserver_utils, "JACKTORR_ADDON", True), patch.object(
70 torrserver_utils, "get_torrserver_api", return_value=api
71 ), patch.object(
72 torrserver_utils.requests, "get", side_effect=Exception("network")
73 ):
74 result = torrserver_utils.add_source_to_torrserver(
75 url="https://example.com/details/1",
76 magnet="magnet:?xt=urn:btih:abc123",
77 )
78
79 assert result == "abc123"
80 api.add_magnet.assert_called_once_with(
81 "magnet:?xt=urn:btih:abc123", title="", poster=""
82 )
83
84
85def test_add_source_to_debrid_uses_preferred_enabled_service():
86 helper = MagicMock()
87
88 with patch.object(
89 debrid_utils, "get_enabled_cloud_transfer_debrids", return_value=[DebridType.RD]
90 ), patch.object(debrid_utils, "RealDebridHelper", return_value=helper), patch.object(
91 debrid_utils, "notification"
92 ) as notification:
93 result = debrid_utils.add_source_to_debrid("abc123", DebridType.RD)
94
95 assert result == DebridType.RD
96 helper.add_magnet.assert_called_once_with("abc123")
97 notification.assert_called_once()
98
99
100def test_choose_debrid_for_transfer_prompts_when_multiple_supported_services_enabled():
101 dialog = MagicMock()
102 dialog.select.return_value = 1
103
104 with patch.object(
105 debrid_utils,
106 "get_enabled_cloud_transfer_debrids",
107 return_value=[DebridType.RD, DebridType.AD],
108 ), patch.object(debrid_utils, "Dialog", return_value=dialog):
109 selected = debrid_utils.choose_debrid_for_transfer()
110
111 assert selected == DebridType.AD
112 dialog.select.assert_called_once()
113
114
115def test_torbox_helper_get_cloud_downloads_returns_playable_items():
116 helper = TorboxHelper()
117 helper.client = MagicMock()
118 helper.client.get_user_torrent_list.return_value = {
119 "data": [
120 {
121 "id": "torrent-1",
122 "name": "Torrent One",
123 "download_present": True,
124 "files": [
125 {"id": "file-1", "name": "sample.txt", "size": 1},
126 {"id": "file-2", "name": "video.mkv", "size": 10},
127 ],
128 }
129 ]
130 }
131 downloads = helper.get_cloud_downloads()
132
133 assert downloads == [
134 {
135 "name": "video.mkv",
136 "torrent_id": "torrent-1",
137 "file_id": "file-2",
138 "info_hash": "",
139 "created_at": "",
140 "updated_at": "",
141 }
142 ]
143
144
145def test_torbox_helper_get_link_uses_cloud_ids_when_present():
146 helper = TorboxHelper()
147 helper.client = MagicMock()
148 helper.client.create_download_link.return_value = {"data": "https://download"}
149
150 with patch("lib.clients.debrid.torbox.get_public_ip", return_value="127.0.0.1"):
151 data = helper.get_link(
152 "",
153 {"torrent_id": "torrent-1", "file_id": "file-2", "title": "video.mkv"},
154 )
155
156 assert data is not None
157 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 )
161
162
163def test_get_tb_downloads_builds_playable_cloud_entries():
164 with patch.object(
165 debrid_navigation.TorboxHelper,
166 "get_cloud_downloads",
167 return_value=[
168 {
169 "name": "older.mkv",
170 "torrent_id": "torrent-1",
171 "file_id": "file-1",
172 "info_hash": "oldhash",
173 "created_at": "2026-03-22T10:00:00Z",
174 "updated_at": "2026-03-22T10:00:00Z",
175 },
176 {
177 "name": "video.mkv",
178 "torrent_id": "torrent-2",
179 "file_id": "file-2",
180 "info_hash": "abc123",
181 "created_at": "2026-03-23T10:00:00Z",
182 "updated_at": "2026-03-23T10:00:00Z",
183 },
184 ],
185 ), patch.object(debrid_navigation.cache, "get", return_value=None), patch.object(
186 debrid_navigation.cache, "set"
187 ), patch.object(debrid_navigation, "addDirectoryItem") as add_directory_item, patch.object(
188 debrid_navigation, "end_of_directory"
189 ) as end_of_directory:
190 debrid_navigation.get_tb_downloads({})
191
192 assert add_directory_item.call_count == 2
193 first_url = add_directory_item.call_args_list[0].args[1]
194 second_url = add_directory_item.call_args_list[1].args[1]
195 assert "action=play_media" in first_url
196 assert "mode%22%3A+%22movie%22" in first_url
197 assert "torrent-2" in first_url
198 assert "torrent-1" in second_url
199 end_of_directory.assert_called_once()
plugin.video.jacktook/tests/unit/test_updater.py +152 -0
……@@ -0,0 +1,152 @@
1import io
2import os
3import shutil
4import zipfile
5
6from lib import updater
7
8
9def _write_addon_xml(path, version):
10 os.makedirs(path, exist_ok=True)
11 with open(os.path.join(path, "addon.xml"), "w", encoding="utf-8") as handle:
12 handle.write(
13 "<?xml version='1.0' encoding='utf-8'?>\n"
14 f"<addon id='plugin.video.jacktook' name='Jacktook' version='{version}' provider-name='Sam-Max'>\n"
15 "</addon>\n"
16 )
17
18
19def _build_release_zip_bytes(version):
20 buffer = io.BytesIO()
21 with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
22 archive.writestr(
23 "plugin.video.jacktook/addon.xml",
24 "<?xml version='1.0' encoding='utf-8'?>\n"
25 f"<addon id='plugin.video.jacktook' name='Jacktook' version='{version}' provider-name='Sam-Max'>\n"
26 "</addon>\n",
27 )
28 archive.writestr("plugin.video.jacktook/resources/readme.txt", "ok")
29 buffer.seek(0)
30 return buffer.getvalue()
31
32
33def _configure_updater_paths(monkeypatch, tmp_path):
34 packages_dir = tmp_path / "packages"
35 destination_dir = tmp_path / "addons" / updater.ADDON_ID
36 packages_dir.mkdir(parents=True)
37 destination_dir.parent.mkdir(parents=True)
38
39 monkeypatch.setattr(updater, "PACKAGES_DIR", str(packages_dir))
40 monkeypatch.setattr(updater, "HOME_ADDONS_DIR", str(destination_dir.parent))
41 monkeypatch.setattr(updater, "DESTINATION_DIR", str(destination_dir))
42 monkeypatch.setattr(
43 updater,
44 "delete_file",
45 lambda path: os.path.exists(path) and os.remove(path),
46 )
47 monkeypatch.setattr(updater, "close_all_dialog", lambda: None)
48 monkeypatch.setattr(updater, "execute_builtin", lambda *args, **kwargs: None)
49 monkeypatch.setattr(updater, "notification", lambda *args, **kwargs: None)
50 monkeypatch.setattr(updater, "show_busy_dialog", lambda: None)
51 monkeypatch.setattr(updater, "close_busy_dialog", lambda: None)
52 monkeypatch.setattr(updater, "dialogyesno", lambda *args, **kwargs: False)
53 monkeypatch.setattr(updater, "update_local_addons", lambda: None)
54 monkeypatch.setattr(updater, "disable_enable_addon", lambda: None)
55 monkeypatch.setattr(updater, "update_kodi_addons_db", lambda: None)
56 monkeypatch.setattr(updater, "kodilog", lambda *args, **kwargs: None)
57 monkeypatch.setattr(updater, "translation", lambda string_id: f"string-{string_id}")
58 monkeypatch.setattr(updater.xbmc, "getInfoLabel", lambda *args, **kwargs: "Default")
59
60 return packages_dir, destination_dir
61
62
63def test_downgrade_menu_only_lists_versions_older_than_current(monkeypatch):
64 class Response:
65 def raise_for_status(self):
66 return None
67
68 def json(self):
69 return [
70 {"name": "plugin.video.jacktook-1.8.0.zip"},
71 {"name": "plugin.video.jacktook-1.7.2.zip"},
72 {"name": "plugin.video.jacktook-1.6.0.zip"},
73 {"name": "plugin.video.jacktook-1.9.0.zip"},
74 ]
75
76 selections = []
77
78 monkeypatch.setattr(updater, "ADDON_VERSION", "1.8.0")
79 monkeypatch.setattr(updater.requests, "get", lambda *args, **kwargs: Response())
80 monkeypatch.setattr(
81 updater,
82 "dialog_select",
83 lambda heading, _list: selections.append((heading, _list)) or -1,
84 )
85 monkeypatch.setattr(updater, "dialog_ok", lambda *args, **kwargs: None)
86 monkeypatch.setattr(updater, "kodilog", lambda *args, **kwargs: None)
87 monkeypatch.setattr(updater, "translation", lambda string_id: f"string-{string_id}")
88
89 updater.downgrade_addon_menu()
90
91 assert selections == [("string-90585", ["1.7.2", "1.6.0"])]
92
93
94def test_update_addon_keeps_current_install_when_zip_is_invalid(monkeypatch, tmp_path):
95 _, destination_dir = _configure_updater_paths(monkeypatch, tmp_path)
96 _write_addon_xml(destination_dir, "1.8.0")
97
98 dialog_calls = []
99 monkeypatch.setattr(updater, "dialog_ok", lambda *args, **kwargs: dialog_calls.append(kwargs))
100 monkeypatch.setattr(updater, "http_get", lambda *args, **kwargs: io.BytesIO(b"not-a-zip"))
101
102 updater.update_addon("1.7.2")
103
104 assert updater._read_addon_version_from_xml(os.path.join(destination_dir, "addon.xml")) == "1.8.0"
105 assert dialog_calls
106
107
108def test_update_addon_restores_backup_when_install_move_fails(monkeypatch, tmp_path):
109 packages_dir, destination_dir = _configure_updater_paths(monkeypatch, tmp_path)
110 _write_addon_xml(destination_dir, "1.8.0")
111
112 monkeypatch.setattr(
113 updater,
114 "http_get",
115 lambda *args, **kwargs: io.BytesIO(_build_release_zip_bytes("1.7.2")),
116 )
117 monkeypatch.setattr(updater, "dialog_ok", lambda *args, **kwargs: None)
118
119 real_move = shutil.move
120
121 def failing_move(src, dst, *args, **kwargs):
122 staging_addon_dir = os.path.join(
123 str(packages_dir), f"{updater.ADDON_ID}-staging", updater.ADDON_ID
124 )
125 if src == staging_addon_dir and dst == str(destination_dir):
126 raise OSError("install failed")
127 return real_move(src, dst, *args, **kwargs)
128
129 monkeypatch.setattr(updater.shutil, "move", failing_move)
130
131 updater.update_addon("1.7.2")
132
133 assert updater._read_addon_version_from_xml(os.path.join(destination_dir, "addon.xml")) == "1.8.0"
134 assert not os.path.exists(os.path.join(str(packages_dir), f"{updater.ADDON_ID}-backup"))
135
136
137def test_update_addon_installs_valid_downgrade(monkeypatch, tmp_path):
138 packages_dir, destination_dir = _configure_updater_paths(monkeypatch, tmp_path)
139 _write_addon_xml(destination_dir, "1.8.0")
140
141 monkeypatch.setattr(
142 updater,
143 "http_get",
144 lambda *args, **kwargs: io.BytesIO(_build_release_zip_bytes("1.7.2")),
145 )
146 monkeypatch.setattr(updater, "dialog_ok", lambda *args, **kwargs: None)
147
148 updater.update_addon("1.7.2")
149
150 assert updater._read_addon_version_from_xml(os.path.join(destination_dir, "addon.xml")) == "1.7.2"
151 assert not os.path.exists(os.path.join(str(packages_dir), f"{updater.ADDON_ID}-backup"))
152 assert not os.path.exists(os.path.join(str(packages_dir), f"{updater.ADDON_ID}-staging"))
plugin.video.jacktook/tests/unit/test_webserver.py +39 -0
……@@ -0,0 +1,39 @@
1from unittest.mock import MagicMock
2
3
4from lib.services.webserver import _addon_capabilities
5
6
7def test_addon_capabilities_requires_supported_movie_prefixes():
8 manifest = {
9 "types": ["movie", "series"],
10 "idPrefixes": ["kitsu:"],
11 "resources": [{"name": "stream", "types": ["movie", "series"]}],
12 }
13
14 capabilities = _addon_capabilities(manifest)
15
16 assert capabilities == {"stream": False, "catalog": False, "tv": False}
17
18
19def test_addon_capabilities_accepts_tmdb_stream_prefixes():
20 manifest = {
21 "types": ["movie", "series"],
22 "idPrefixes": ["tmdb:"],
23 "resources": ["stream"],
24 }
25
26 capabilities = _addon_capabilities(manifest)
27
28 assert capabilities == {"stream": True, "catalog": False, "tv": False}
29
30
31def test_addon_capabilities_keeps_tv_streams_without_movie_prefixes():
32 manifest = {
33 "types": ["tv"],
34 "resources": [{"name": "stream", "types": ["tv"]}],
35 }
36
37 capabilities = _addon_capabilities(manifest)
38
39 assert capabilities == {"stream": False, "catalog": False, "tv": True}
plugin.video.jacktook/tests/unit/test_youtube_resolver.py +697 -0
……@@ -0,0 +1,697 @@
1import pytest
2from unittest.mock import MagicMock, patch
3
4
5from lib.clients.youtube_resolver import (
6 extract_video_id,
7 parse_watch_config,
8 resolve_item_trailer_metadata,
9 resolve_item_trailer_playback,
10 resolve_trailer_playback,
11 select_best_stream,
12)
13
14
15WATCH_HTML = """
16<html>
17 <script>
18 var ytcfg = {
19 "INNERTUBE_API_KEY": "test-api-key",
20 "VISITOR_DATA": "visitor-token"
21 };
22 </script>
23</html>
24"""
25
26
27class FakeResponse:
28 def __init__(self, text="", payload=None, status_code=200):
29 self.text = text
30 self._payload = payload if payload is not None else {}
31 self.status_code = status_code
32
33 def json(self):
34 return self._payload
35
36 def raise_for_status(self):
37 if self.status_code >= 400:
38 raise RuntimeError("http error")
39
40
41class FakeSession:
42 def __init__(self, watch_response, player_responses):
43 self.watch_response = watch_response
44 self.player_responses = list(player_responses)
45 self.get_calls = []
46 self.post_calls = []
47
48 def get(self, url, **kwargs):
49 self.get_calls.append((url, kwargs))
50 return self.watch_response
51
52 def post(self, url, **kwargs):
53 self.post_calls.append((url, kwargs))
54 if not self.player_responses:
55 raise AssertionError("unexpected player call")
56 return self.player_responses.pop(0)
57
58
59@pytest.fixture(autouse=True)
60def clear_resolver_cache():
61 from lib.clients import youtube_resolver
62
63 youtube_resolver._RESOLVER_CACHE.clear()
64 yield
65 youtube_resolver._RESOLVER_CACHE.clear()
66
67
68@pytest.mark.parametrize(
69 "value",
70 [
71 "dQw4w9WgXcQ",
72 "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
73 "https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s",
74 "https://youtu.be/dQw4w9WgXcQ",
75 "https://www.youtube.com/embed/dQw4w9WgXcQ",
76 "https://www.youtube.com/shorts/dQw4w9WgXcQ",
77 "https://www.youtube.com/live/dQw4w9WgXcQ",
78 ],
79)
80def test_extract_video_id_supports_common_youtube_inputs(value):
81 assert extract_video_id(value) == "dQw4w9WgXcQ"
82
83
84def test_extract_video_id_rejects_unknown_values():
85 assert extract_video_id("https://example.com/watch?v=invalid") is None
86
87
88def test_parse_watch_config_extracts_api_key_and_visitor_data():
89 assert parse_watch_config(WATCH_HTML) == {
90 "api_key": "test-api-key",
91 "visitor_data": "visitor-token",
92 }
93
94
95def test_parse_watch_config_requires_api_key():
96 assert parse_watch_config("<html>missing config</html>") is None
97
98
99def test_select_best_stream_returns_hls_manifest_url_as_last_resort():
100 payload = {
101 "streamingData": {
102 "hlsManifestUrl": "https://video.example/hls.m3u8",
103 }
104 }
105
106 assert select_best_stream(payload) == {
107 "video_url": "https://video.example/hls.m3u8",
108 "audio_url": None,
109 "source_type": "hls",
110 }
111
112
113def test_select_best_stream_prefers_adaptive_pair_over_hls_for_max_quality():
114 payload = {
115 "streamingData": {
116 "hlsManifestUrl": "https://video.example/master.m3u8",
117 "adaptiveFormats": [
118 {
119 "url": "https://video.example/video-1080.mp4",
120 "mimeType": 'video/mp4; codecs="avc1"',
121 "height": 1080,
122 "fps": 30,
123 "bitrate": 4000000,
124 },
125 {
126 "url": "https://video.example/audio-160.m4a",
127 "mimeType": 'audio/mp4; codecs="mp4a"',
128 "bitrate": 160000,
129 "audioSampleRate": "48000",
130 },
131 ],
132 }
133 }
134
135 assert select_best_stream(payload) == {
136 "video_url": "https://video.example/video-1080.mp4",
137 "audio_url": "https://video.example/audio-160.m4a",
138 "source_type": "adaptive",
139 }
140
141
142def test_select_best_stream_prefers_mp4_adaptive_video_over_similar_webm_when_quality_close():
143 payload = {
144 "streamingData": {
145 "adaptiveFormats": [
146 {
147 "url": "https://video.example/video-1080.webm",
148 "mimeType": 'video/webm; codecs="vp9"',
149 "height": 1080,
150 "fps": 30,
151 "bitrate": 4050000,
152 },
153 {
154 "url": "https://video.example/video-1080.mp4",
155 "mimeType": 'video/mp4; codecs="avc1"',
156 "height": 1080,
157 "fps": 30,
158 "bitrate": 4000000,
159 },
160 {
161 "url": "https://video.example/audio-160.m4a",
162 "mimeType": 'audio/mp4; codecs="mp4a"',
163 "bitrate": 160000,
164 "audioSampleRate": "48000",
165 },
166 ],
167 }
168 }
169
170 result = select_best_stream(payload)
171
172 assert result["video_url"] == "https://video.example/video-1080.mp4"
173 assert result["source_type"] == "adaptive"
174
175
176def test_select_best_stream_prefers_materially_better_webm_over_mp4():
177 payload = {
178 "streamingData": {
179 "adaptiveFormats": [
180 {
181 "url": "https://video.example/video-1080.webm",
182 "mimeType": 'video/webm; codecs="vp9"',
183 "height": 1080,
184 "fps": 30,
185 "bitrate": 5200000,
186 },
187 {
188 "url": "https://video.example/video-1080.mp4",
189 "mimeType": 'video/mp4; codecs="avc1"',
190 "height": 1080,
191 "fps": 30,
192 "bitrate": 4000000,
193 },
194 {
195 "url": "https://video.example/audio-160.m4a",
196 "mimeType": 'audio/mp4; codecs="mp4a"',
197 "bitrate": 160000,
198 "audioSampleRate": "48000",
199 },
200 ],
201 }
202 }
203
204 result = select_best_stream(payload)
205
206 assert result["video_url"] == "https://video.example/video-1080.webm"
207 assert result["source_type"] == "adaptive"
208
209
210def test_select_best_stream_uses_highest_progressive_format_when_hls_missing():
211 payload = {
212 "streamingData": {
213 "formats": [
214 {
215 "url": "https://video.example/360.mp4",
216 "mimeType": 'video/mp4; codecs="avc1"',
217 "height": 360,
218 },
219 {
220 "url": "https://video.example/720.mp4",
221 "mimeType": 'video/mp4; codecs="avc1, mp4a"',
222 "qualityLabel": "720p",
223 "height": 720,
224 },
225 ]
226 }
227 }
228
229 assert select_best_stream(payload) == {
230 "video_url": "https://video.example/720.mp4",
231 "audio_url": None,
232 "source_type": "progressive",
233 }
234
235
236def test_select_best_stream_prefers_progressive_when_no_hls_and_no_adaptive_pair():
237 payload = {
238 "streamingData": {
239 "formats": [
240 {
241 "url": "https://video.example/720.mp4",
242 "mimeType": 'video/mp4; codecs="avc1, mp4a"',
243 "height": 720,
244 "bitrate": 2000000,
245 }
246 ],
247 "adaptiveFormats": [
248 {
249 "url": "https://video.example/video-only.webm",
250 "mimeType": 'video/webm; codecs="vp9"',
251 "height": 1080,
252 "bitrate": 3000000,
253 }
254 ],
255 }
256 }
257
258 assert select_best_stream(payload) == {
259 "video_url": "https://video.example/720.mp4",
260 "audio_url": None,
261 "source_type": "progressive",
262 }
263
264
265def test_select_best_stream_returns_adaptive_video_and_audio_pair_when_needed():
266 payload = {
267 "streamingData": {
268 "formats": [],
269 "adaptiveFormats": [
270 {
271 "url": "https://video.example/video-only.mp4",
272 "mimeType": 'video/mp4; codecs="avc1"',
273 "height": 1080,
274 "bitrate": 3000000,
275 },
276 {
277 "url": "https://video.example/audio-only.m4a",
278 "mimeType": 'audio/mp4; codecs="mp4a"',
279 "bitrate": 128000,
280 "audioSampleRate": "48000",
281 },
282 ],
283 }
284 }
285
286 assert select_best_stream(payload) == {
287 "video_url": "https://video.example/video-only.mp4",
288 "audio_url": "https://video.example/audio-only.m4a",
289 "source_type": "adaptive",
290 }
291
292
293def test_select_best_stream_uses_scoring_to_pick_best_adaptive_video_and_audio():
294 payload = {
295 "streamingData": {
296 "formats": [],
297 "adaptiveFormats": [
298 {
299 "url": "https://video.example/video-720.mp4",
300 "mimeType": 'video/mp4; codecs="avc1"',
301 "height": 720,
302 "fps": 30,
303 "bitrate": 2000000,
304 },
305 {
306 "url": "https://video.example/video-1080-lowfps.mp4",
307 "mimeType": 'video/mp4; codecs="avc1"',
308 "height": 1080,
309 "fps": 24,
310 "bitrate": 2500000,
311 },
312 {
313 "url": "https://video.example/audio-128.m4a",
314 "mimeType": 'audio/mp4; codecs="mp4a"',
315 "bitrate": 128000,
316 "audioSampleRate": "44100",
317 },
318 {
319 "url": "https://video.example/audio-160.m4a",
320 "mimeType": 'audio/mp4; codecs="mp4a"',
321 "bitrate": 160000,
322 "audioSampleRate": "48000",
323 },
324 ],
325 }
326 }
327
328 assert select_best_stream(payload) == {
329 "video_url": "https://video.example/video-1080-lowfps.mp4",
330 "audio_url": "https://video.example/audio-160.m4a",
331 "source_type": "adaptive",
332 }
333
334
335def test_resolve_trailer_playback_fetches_watch_page_then_player_api_and_caches_result():
336 first_player_payload = {
337 "playabilityStatus": {"status": "ERROR"},
338 }
339 second_player_payload = {
340 "playabilityStatus": {"status": "OK"},
341 "streamingData": {
342 "hlsManifestUrl": "https://video.example/master.m3u8",
343 },
344 }
345 session = FakeSession(
346 watch_response=FakeResponse(text=WATCH_HTML),
347 player_responses=[
348 FakeResponse(payload=first_player_payload),
349 FakeResponse(payload=second_player_payload),
350 ],
351 )
352
353 resolved = resolve_trailer_playback(
354 "https://youtu.be/dQw4w9WgXcQ",
355 session=session,
356 )
357
358 assert resolved == {
359 "video_url": "https://video.example/master.m3u8",
360 "audio_url": None,
361 "source_type": "hls",
362 "video_id": "dQw4w9WgXcQ",
363 }
364 assert len(session.get_calls) == 1
365 assert len(session.post_calls) == 2
366 first_post_url, first_post_kwargs = session.post_calls[0]
367 assert first_post_url == "https://www.youtube.com/youtubei/v1/player"
368 assert first_post_kwargs["params"] == {"key": "test-api-key"}
369 assert first_post_kwargs["headers"]["X-Goog-Visitor-Id"] == "visitor-token"
370 assert first_post_kwargs["json"]["videoId"] == "dQw4w9WgXcQ"
371
372 cached = resolve_trailer_playback("dQw4w9WgXcQ", session=session)
373 assert cached == resolved
374 assert len(session.get_calls) == 1
375 assert len(session.post_calls) == 2
376
377
378def test_resolve_trailer_playback_skips_unplayable_stream_payloads():
379 session = FakeSession(
380 watch_response=FakeResponse(text=WATCH_HTML),
381 player_responses=[
382 FakeResponse(
383 payload={
384 "playabilityStatus": {"status": "ERROR"},
385 "streamingData": {
386 "hlsManifestUrl": "https://video.example/blocked.m3u8",
387 },
388 }
389 ),
390 FakeResponse(
391 payload={
392 "playabilityStatus": {"status": "OK"},
393 "streamingData": {
394 "hlsManifestUrl": "https://video.example/playable.m3u8",
395 },
396 }
397 ),
398 ],
399 )
400
401 assert resolve_trailer_playback("dQw4w9WgXcQ", session=session) == {
402 "video_url": "https://video.example/playable.m3u8",
403 "audio_url": None,
404 "source_type": "hls",
405 "video_id": "dQw4w9WgXcQ",
406 }
407
408
409def test_resolve_trailer_playback_accepts_named_youtube_url_argument():
410 session = FakeSession(
411 watch_response=FakeResponse(text=WATCH_HTML),
412 player_responses=[
413 FakeResponse(
414 payload={
415 "playabilityStatus": {"status": "OK"},
416 "streamingData": {
417 "hlsManifestUrl": "https://video.example/named-input.m3u8",
418 },
419 }
420 )
421 ],
422 )
423
424 assert resolve_trailer_playback(
425 youtube_url="https://www.youtube.com/watch?v=dQw4w9WgXcQ",
426 session=session,
427 ) == {
428 "video_url": "https://video.example/named-input.m3u8",
429 "audio_url": None,
430 "source_type": "hls",
431 "video_id": "dQw4w9WgXcQ",
432 }
433
434
435def test_resolve_trailer_playback_tries_android_vr_android_then_ios_clients():
436 session = FakeSession(
437 watch_response=FakeResponse(text=WATCH_HTML),
438 player_responses=[
439 FakeResponse(payload={"playabilityStatus": {"status": "ERROR"}}),
440 FakeResponse(payload={"playabilityStatus": {"status": "ERROR"}}),
441 FakeResponse(
442 payload={
443 "playabilityStatus": {"status": "OK"},
444 "streamingData": {
445 "hlsManifestUrl": "https://video.example/final.m3u8",
446 },
447 }
448 ),
449 ],
450 )
451
452 resolved = resolve_trailer_playback("dQw4w9WgXcQ", session=session)
453
454 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"]
459 assert [call[1]["headers"]["User-Agent"] for call in session.post_calls] == [
460 (
461 "com.google.android.apps.youtube.vr.oculus/1.56.21 "
462 "(Linux; U; Android 12; en_US; Quest 3; Build/SQ3A.220605.009.A1) gzip"
463 ),
464 "com.google.android.youtube/20.10.35 (Linux; U; Android 14; en_US) gzip",
465 "com.google.ios.youtube/20.10.1 (iPhone16,2; U; CPU iOS 17_4 like Mac OS X)",
466 ]
467 assert [call[1]["headers"]["X-YouTube-Client-Name"] for call in session.post_calls] == [
468 "28",
469 "3",
470 "5",
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"]
475 assert [call[1]["headers"]["X-Goog-Visitor-Id"] for call in session.post_calls] == [
476 "visitor-token",
477 "visitor-token",
478 "visitor-token",
479 ]
480 assert [call[1]["headers"]["Origin"] for call in session.post_calls] == [
481 "https://www.youtube.com",
482 "https://www.youtube.com",
483 "https://www.youtube.com",
484 ]
485 assert [
486 call[1]["json"]["playbackContext"]["contentPlaybackContext"]["html5Preference"]
487 for call in session.post_calls
488 ] == ["HTML5_PREF_WANTS", "HTML5_PREF_WANTS", "HTML5_PREF_WANTS"]
489
490
491def test_call_player_api_sends_origin_and_html5_preference():
492 session = FakeSession(
493 watch_response=FakeResponse(text=WATCH_HTML),
494 player_responses=[
495 FakeResponse(
496 payload={
497 "playabilityStatus": {"status": "OK"},
498 "streamingData": {
499 "hlsManifestUrl": "https://video.example/request-shape.m3u8",
500 },
501 }
502 )
503 ],
504 )
505
506 resolve_trailer_playback("dQw4w9WgXcQ", session=session)
507
508 _, post_kwargs = session.post_calls[0]
509 assert post_kwargs["headers"]["Origin"] == "https://www.youtube.com"
510 assert (
511 post_kwargs["headers"]["User-Agent"]
512 == "com.google.android.apps.youtube.vr.oculus/1.56.21 "
513 "(Linux; U; Android 12; en_US; Quest 3; Build/SQ3A.220605.009.A1) gzip"
514 )
515 assert post_kwargs["headers"]["X-YouTube-Client-Name"] == "28"
516 assert post_kwargs["headers"]["X-YouTube-Client-Version"] == "1.56.21"
517 assert post_kwargs["headers"]["X-Goog-Visitor-Id"] == "visitor-token"
518 assert (
519 post_kwargs["json"]["playbackContext"]["contentPlaybackContext"][
520 "html5Preference"
521 ]
522 == "HTML5_PREF_WANTS"
523 )
524
525
526def test_resolve_trailer_playback_returns_none_for_unplayable_video():
527 session = FakeSession(
528 watch_response=FakeResponse(text=WATCH_HTML),
529 player_responses=[
530 FakeResponse(payload={"playabilityStatus": {"status": "ERROR"}}),
531 FakeResponse(payload={"playabilityStatus": {"status": "LOGIN_REQUIRED"}}),
532 ],
533 )
534
535 assert resolve_trailer_playback("dQw4w9WgXcQ", session=session) is None
536
537
538def test_resolve_trailer_playback_logs_player_api_error_details():
539 session = FakeSession(
540 watch_response=FakeResponse(text=WATCH_HTML),
541 player_responses=[
542 FakeResponse(
543 text='{"error":{"message":"bad client"}}',
544 payload={"error": {"message": "bad client"}},
545 status_code=400,
546 )
547 ],
548 )
549
550 with patch("lib.clients.youtube_resolver.kodilog") as log_mock:
551 assert resolve_trailer_playback("dQw4w9WgXcQ", session=session) is None
552
553 assert any(
554 "bad client" in str(call) and "preview=" in str(call)
555 for call in log_mock.call_args_list
556 )
557
558
559def test_resolve_item_trailer_playback_prefers_direct_youtube_values(monkeypatch):
560 playback_calls = []
561
562 def fake_resolve_trailer_playback(video_id=None, youtube_url=None, session=None, cache_ttl=300):
563 playback_calls.append(
564 {
565 "video_id": video_id,
566 "youtube_url": youtube_url,
567 "session": session,
568 "cache_ttl": cache_ttl,
569 }
570 )
571 return {"video_url": "https://video.example/direct.m3u8"}
572
573 def fail_resolve_tmdb_trailer(*args, **kwargs):
574 raise AssertionError("tmdb fallback should not be used")
575
576 monkeypatch.setattr(
577 "lib.clients.youtube_resolver.resolve_trailer_playback",
578 fake_resolve_trailer_playback,
579 )
580 monkeypatch.setattr(
581 "lib.clients.youtube_resolver.resolve_tmdb_trailer",
582 fail_resolve_tmdb_trailer,
583 )
584
585 resolved = resolve_item_trailer_playback(
586 yt_id="dQw4w9WgXcQ",
587 youtube_url="https://www.youtube.com/watch?v=ignored12345A",
588 tmdb_id=550,
589 media_type="movie",
590 session="session-token",
591 )
592
593 assert resolved == {"video_url": "https://video.example/direct.m3u8"}
594 assert playback_calls == [
595 {
596 "video_id": "dQw4w9WgXcQ",
597 "youtube_url": "https://www.youtube.com/watch?v=ignored12345A",
598 "session": "session-token",
599 "cache_ttl": 300,
600 }
601 ]
602
603
604def test_resolve_item_trailer_playback_falls_back_to_tmdb_trailer(monkeypatch):
605 tmdb_calls = []
606 playback_calls = []
607
608 def fake_resolve_tmdb_trailer(tmdb_id, media_type):
609 tmdb_calls.append((tmdb_id, media_type))
610 return {
611 "yt_id": "tmdb1234567",
612 "youtube_url": "https://www.youtube.com/watch?v=tmdb1234567",
613 }
614
615 def fake_resolve_trailer_playback(video_id=None, youtube_url=None, session=None, cache_ttl=300):
616 playback_calls.append(
617 {
618 "video_id": video_id,
619 "youtube_url": youtube_url,
620 "session": session,
621 }
622 )
623 return {"video_url": "https://video.example/fallback.m3u8"}
624
625 monkeypatch.setattr(
626 "lib.clients.youtube_resolver.resolve_tmdb_trailer",
627 fake_resolve_tmdb_trailer,
628 )
629 monkeypatch.setattr(
630 "lib.clients.youtube_resolver.resolve_trailer_playback",
631 fake_resolve_trailer_playback,
632 )
633
634 resolved = resolve_item_trailer_playback(
635 tmdb_id=1399,
636 media_type="tv",
637 session="session-token",
638 )
639
640 assert resolved == {"video_url": "https://video.example/fallback.m3u8"}
641 assert tmdb_calls == [(1399, "tv")]
642 assert playback_calls == [
643 {
644 "video_id": "tmdb1234567",
645 "youtube_url": "https://www.youtube.com/watch?v=tmdb1234567",
646 "session": "session-token",
647 }
648 ]
649
650
651def test_resolve_item_trailer_playback_returns_none_when_no_direct_or_tmdb_trailer(monkeypatch):
652 monkeypatch.setattr(
653 "lib.clients.youtube_resolver.resolve_tmdb_trailer",
654 lambda tmdb_id, media_type: None,
655 )
656 monkeypatch.setattr(
657 "lib.clients.youtube_resolver.resolve_trailer_playback",
658 lambda *args, **kwargs: (_ for _ in ()).throw(
659 AssertionError("playback should not be resolved")
660 ),
661 )
662
663 assert resolve_item_trailer_playback(tmdb_id=42, media_type="movie") is None
664
665
666def test_resolve_item_trailer_metadata_falls_back_to_tmdb_trailer(monkeypatch):
667 tmdb_calls = []
668
669 def fake_resolve_tmdb_trailer(tmdb_id, media_type):
670 tmdb_calls.append((tmdb_id, media_type))
671 return {
672 "yt_id": "tmdb1234567",
673 "youtube_url": "https://www.youtube.com/watch?v=tmdb1234567",
674 }
675
676 monkeypatch.setattr(
677 "lib.clients.youtube_resolver.resolve_tmdb_trailer",
678 fake_resolve_tmdb_trailer,
679 )
680
681 resolved = resolve_item_trailer_metadata(tmdb_id=1399, media_type="tv")
682
683 assert resolved == {
684 "yt_id": "tmdb1234567",
685 "youtube_url": "https://www.youtube.com/watch?v=tmdb1234567",
686 }
687 assert tmdb_calls == [(1399, "tv")]
688
689
690def test_resolve_trailer_playback_logs_when_watch_request_fails():
691 session = MagicMock()
692 session.get.side_effect = RuntimeError("boom")
693
694 with patch("lib.clients.youtube_resolver.kodilog") as log_mock:
695 assert resolve_trailer_playback(video_id="dQw4w9WgXcQ", session=session) is None
696
697 log_mock.assert_called()
plugin.video.jacktook/tests/verify_debrid.py +107 -0
……@@ -0,0 +1,107 @@
1import sys
2import os
3from unittest.mock import MagicMock
4
5if "pytest" in sys.modules:
6 import pytest
7
8 pytest.skip("manual script not intended for pytest collection", allow_module_level=True)
9
10# 1. Mock Kodi Modules before and during imports
11mock_xbmc = MagicMock()
12mock_xbmcgui = MagicMock()
13mock_xbmcaddon = MagicMock()
14mock_xbmcvfs = MagicMock()
15mock_xbmcplugin = MagicMock()
16
17sys.modules["xbmc"] = mock_xbmc
18sys.modules["xbmcgui"] = mock_xbmcgui
19sys.modules["xbmcaddon"] = mock_xbmcaddon
20sys.modules["xbmcvfs"] = mock_xbmcvfs
21sys.modules["xbmcplugin"] = mock_xbmcplugin
22
23# 2. Add current directory to path so we can import 'lib'
24sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
25
26
27# 3. Create a mock for kodi utils and DB
28def mock_kodilog(message, level=None):
29 print(f"[LOG] {message}")
30
31
32def mock_notification(message, header="Jacktook", time=5000, icon=""):
33 print(f"[NOTIFICATION] {header}: {message}")
34
35
36def mock_translation(id):
37 return f"String_{id}"
38
39
40def mock_get_setting(id, default=""):
41 return default
42
43
44# Mock the entire utils module
45utils_mock = MagicMock()
46utils_mock.kodilog = mock_kodilog
47utils_mock.notification = mock_notification
48utils_mock.translation = mock_translation
49utils_mock.get_setting = mock_get_setting
50sys.modules["lib.utils.kodi.utils"] = utils_mock
51
52# Mock the cache module to prevent SQL errors
53cache_mock = MagicMock()
54sys.modules["lib.db.cached"] = cache_mock
55
56# Mock TVDBAPI as well since it triggers the imports
57sys.modules["lib.api.tvdbapi.tvdbapi"] = MagicMock()
58sys.modules["lib.api.tvdbapi"] = MagicMock()
59
60# 4. Now import the clients
61from lib.api.debrid.realdebrid import RealDebrid
62from lib.api.debrid.alldebrid import AllDebrid
63from lib.api.debrid.torbox import Torbox
64from lib.api.debrid.premiumize import Premiumize
65from lib.api.debrid.debrider import Debrider
66
67# === CONFIGURATION: ADD YOUR TOKENS HERE ===
68TOKENS = {
69 "REALDEBRID": "", # Real-Debrid API Key
70 "ALLDEBRID": "", # AllDebrid API Key
71 "TORBOX": "", # Torbox API Key
72 "PREMIUMIZE": "", # Premiumize API Key
73 "DEBRIDER": "", # Debrider API Key
74}
75# ===========================================
76
77
78def test_service(name, client_class, token):
79 print(f"\n--- Testing {name} ---")
80 if not token:
81 print(f"Skipping {name}: No token provided.")
82 return
83
84 try:
85 # Special case for Premiumize which might need different params if it changed,
86 # but let's try standard first based on current code
87 client = client_class(token)
88 days = client.days_remaining()
89 if days is not None:
90 print(f"SUCCESS: {name} reports {days} days remaining.")
91 else:
92 print(
93 f"FAILED: {name} returned None. Check if token is valid or if account has premium."
94 )
95 except Exception as e:
96 print(f"ERROR testing {name}: {str(e)}")
97
98
99if __name__ == "__main__":
100 print("Jacktook Debrid Expiration Verification Tool")
101 print("============================================")
102
103 test_service("Real-Debrid", RealDebrid, TOKENS["REALDEBRID"])
104 test_service("AllDebrid", AllDebrid, TOKENS["ALLDEBRID"])
105 test_service("Torbox", Torbox, TOKENS["TORBOX"])
106 test_service("Premiumize", Premiumize, TOKENS["PREMIUMIZE"])
107 test_service("Debrider", Debrider, TOKENS["DEBRIDER"])